ezshell 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
src/ui/gui_window.py ADDED
@@ -0,0 +1,1018 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import threading
6
+ import warnings
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ # Suppress spurious CustomTkinter HighDPI warning when using native tk.PhotoImage
11
+ warnings.filterwarnings("ignore", message=".*Given image is not CTkImage.*")
12
+
13
+ import customtkinter as ctk
14
+ from PIL import Image, ImageTk
15
+
16
+ from src.core import chat_bridge
17
+ from src.core.assistant import ChatMessageResult
18
+ from src.core.config import EzShellConfig
19
+ from src.core.context import SystemContext, get_last_exit_code, get_last_shell_command
20
+ from src.core.provider import get_provider
21
+ from src.core.pty_shell import PTYExecutor
22
+ from src.core.safety import RiskLevel
23
+ from src.i18n import t
24
+
25
+
26
+ def clean_markdown_explanation(text: str) -> str:
27
+ """Cleans raw markdown formatting from AI text for a clean, human-readable desktop UI."""
28
+ if not text:
29
+ return ""
30
+ # Strip full fenced code blocks (```bash ... ```)
31
+ cleaned = re.sub(r"```[a-zA-Z]*\n[\s\S]*?\n```", "", text)
32
+ # Strip inline backticks `command` -> command
33
+ cleaned = re.sub(r"`(.*?)`", r"\1", cleaned)
34
+ # Strip bold asterisks **word** -> word
35
+ cleaned = re.sub(r"\*\*(.*?)\*\*", r"\1", cleaned)
36
+ # Strip repetitive section headers
37
+ cleaned = re.sub(r"(?im)^\s*Command\s*:?\s*$", "", cleaned)
38
+ cleaned = re.sub(r"(?im)^\s*What it does\s*:?\s*$", "", cleaned)
39
+ cleaned = re.sub(r"(?im)^\s*Explication\s*:?\s*$", "", cleaned)
40
+ cleaned = re.sub(r"(?im)^\s*Solution\s*:?\s*$", "", cleaned)
41
+ # Format bullet points nicely
42
+ lines = []
43
+ for line in cleaned.splitlines():
44
+ s = line.strip()
45
+ if not s:
46
+ continue
47
+ if s.startswith("- ") or s.startswith("* "):
48
+ lines.append(" • " + s[2:].strip())
49
+ else:
50
+ lines.append(s)
51
+ return "\n".join(lines).strip()
52
+
53
+
54
+ class EzShellDesktopApp(ctk.CTk):
55
+ """Modern, clean desktop GUI popup window for ezShell AI Assistant.
56
+
57
+ Theme: Refined developer palette (Royal Blue accent, deep slate background).
58
+ NO cyberpunk styling, NO electric cyan.
59
+ """
60
+
61
+ # Typography & Font Families
62
+ FONT_FAMILY_UI = "Inter"
63
+ FONT_FAMILY_TITLE = "Inter Display"
64
+ FONT_FAMILY_CODE = "JetBrains Mono"
65
+
66
+ DEFAULT_COLUMNS = 45
67
+ DEFAULT_ROWS = 15
68
+
69
+ FONT_SIZE_APP_TITLE = 18
70
+ FONT_SIZE_QUERY_CHEVRON = 16
71
+ FONT_SIZE_QUERY_TEXT = 14
72
+ FONT_SIZE_SECTION_HEADER = 11
73
+ FONT_SIZE_CAUSE_BODY = 14
74
+ FONT_SIZE_FIX_COMMAND = 14
75
+ FONT_SIZE_RISK_BADGE = 12
76
+ FONT_SIZE_INPUT_ENTRY = 13
77
+ FONT_SIZE_PILL_KEY = 11
78
+ FONT_SIZE_PILL_LABEL = 11
79
+ FONT_SIZE_EXEC_OUTPUT = 12
80
+
81
+ # Quiet charcoal palette with one restrained mint accent.
82
+ COLOR_PRIMARY_BLUE = "#2DD4BF"
83
+ COLOR_BLUE_HOVER = "#14B8A6"
84
+ COLOR_LIGHT_BLUE = "#99F6E4"
85
+ COLOR_BG_WINDOW = "#111315"
86
+ COLOR_BG_CARD = "#191C1F"
87
+ COLOR_BG_CARD_HOVER = "#23282B"
88
+ COLOR_BG_CODE = "#0C0E0F"
89
+ COLOR_BG_INPUT = "#171A1C"
90
+ COLOR_BORDER = "#2A3033"
91
+ COLOR_BORDER_FOCUS = "#2DD4BF"
92
+ COLOR_TEXT_PRIMARY = "#F4F1EA"
93
+ COLOR_TEXT_MUTED = "#A6AFB0"
94
+ COLOR_TEXT_SUBTLE = "#748082"
95
+ COLOR_SUCCESS = "#34D399"
96
+ COLOR_WARNING = "#FBBF24"
97
+ COLOR_DANGER = "#FB7185"
98
+
99
+ def __init__(
100
+ self,
101
+ config: Optional[EzShellConfig] = None,
102
+ initial_query: Optional[str] = None,
103
+ error_context: Optional[str] = None,
104
+ ):
105
+ ctk.set_appearance_mode("dark")
106
+ ctk.set_default_color_theme("blue")
107
+
108
+ super().__init__()
109
+
110
+ self.config = config or EzShellConfig.load()
111
+ self.context = SystemContext.collect()
112
+ self.provider = get_provider(self.config)
113
+
114
+ # Window properties
115
+ self.title("ezShell")
116
+ self.configure(fg_color=self.COLOR_BG_WINDOW)
117
+
118
+ # Taille par défaut en cellules de terminal (45 colonnes x 15 lignes), comme un émulateur
119
+ win_w, win_h = self._cell_size_px(self.DEFAULT_COLUMNS, self.DEFAULT_ROWS)
120
+ try:
121
+ sw = self.winfo_screenwidth()
122
+ sh = self.winfo_screenheight()
123
+ x = max(0, (sw - win_w) // 2)
124
+ y = max(0, (sh - win_h) // 2)
125
+ self.geometry(f"{win_w}x{win_h}+{x}+{y}")
126
+ except Exception:
127
+ self.geometry(f"{win_w}x{win_h}")
128
+
129
+ self.minsize(win_w, win_h)
130
+
131
+ # Keyboard shortcuts
132
+ self.bind("<Escape>", lambda e: self.destroy())
133
+ self.bind("<KeyPress>", self._handle_global_key)
134
+
135
+ # Drag tracking
136
+ self._drag_x = 0
137
+ self._drag_y = 0
138
+
139
+ # State
140
+ self.current_query = initial_query or ""
141
+ self.current_cause = ""
142
+ self.current_cmd = ""
143
+ self.current_risk_level = RiskLevel.SAFE
144
+ self.current_risk_reason = ""
145
+ self.last_detected_cmd = get_last_shell_command()
146
+ self.last_exit_code = get_last_exit_code()
147
+
148
+ # Load the transparent symbol logo at the compact size used by the header.
149
+ self.logo_image = None
150
+ logo_path = Path(__file__).resolve().parent.parent.parent / "assets" / "logos" / "ezshell-symbol-transparent.png"
151
+ if logo_path.is_file():
152
+ try:
153
+ logo = Image.open(logo_path).convert("RGBA").resize((28, 28), Image.Resampling.LANCZOS)
154
+ self.logo_image = ImageTk.PhotoImage(logo)
155
+ except Exception:
156
+ pass
157
+
158
+ self._build_ui()
159
+
160
+ # If a real query was passed, analyze it immediately
161
+ if initial_query:
162
+ self._query_groq(initial_query)
163
+
164
+
165
+ def _cell_size_px(self, columns: int, rows: int) -> tuple:
166
+ """Taille en pixels de `columns` x `rows` cellules de la police mono de l'application."""
167
+ import tkinter.font as tkfont
168
+
169
+ try:
170
+ font = tkfont.Font(root=self, family=self.FONT_FAMILY_CODE, size=self.FONT_SIZE_FIX_COMMAND)
171
+ cell_w, cell_h = font.measure("0"), font.metrics("linespace")
172
+ except Exception:
173
+ cell_w, cell_h = 8, 17
174
+ return columns * cell_w, rows * cell_h
175
+
176
+ def _autowrap(self, label: ctk.CTkLabel, margin: int = 0) -> None:
177
+ """Renvoie le texte du label à la ligne selon la largeur réelle de son conteneur."""
178
+ container = label.master
179
+
180
+ def _on_resize(event) -> None:
181
+ scaling = ctk.ScalingTracker.get_widget_scaling(label)
182
+ width = max(60, int((event.width - margin) / scaling))
183
+ try:
184
+ label.configure(wraplength=width)
185
+ except Exception:
186
+ pass
187
+
188
+ container.bind("<Configure>", _on_resize, add="+")
189
+ def _build_ui(self) -> None:
190
+ """Constructs the application layout."""
191
+ self.main_container = ctk.CTkFrame(self, fg_color="transparent")
192
+ self.main_container.pack(fill="both", expand=True, padx=16, pady=(12, 8))
193
+
194
+ # 1. Header Bar: [Icon] ez (blue) Shell (white) ............ [Esc] to close
195
+ self.header_frame = ctk.CTkFrame(self.main_container, fg_color="transparent", height=32)
196
+ self.header_frame.pack(fill="x", pady=(0, 8))
197
+
198
+ # Drag support on header
199
+ self.header_frame.bind("<ButtonPress-1>", self._start_drag)
200
+ self.header_frame.bind("<B1-Motion>", self._on_drag)
201
+
202
+ # Left: Logo icon + 'ez' in Blue + 'Shell' in White
203
+ header_left = ctk.CTkFrame(self.header_frame, fg_color="transparent")
204
+ header_left.pack(side="left")
205
+ header_left.bind("<ButtonPress-1>", self._start_drag)
206
+ header_left.bind("<B1-Motion>", self._on_drag)
207
+
208
+ if self.logo_image:
209
+ logo_lbl = ctk.CTkLabel(header_left, text="", image=self.logo_image)
210
+ logo_lbl.pack(side="left", padx=(0, 8))
211
+ logo_lbl.bind("<ButtonPress-1>", self._start_drag)
212
+ logo_lbl.bind("<B1-Motion>", self._on_drag)
213
+
214
+ app_title_ez = ctk.CTkLabel(
215
+ header_left,
216
+ text="ez",
217
+ text_color=self.COLOR_PRIMARY_BLUE,
218
+ font=ctk.CTkFont(family=self.FONT_FAMILY_TITLE, size=self.FONT_SIZE_APP_TITLE, weight="bold"),
219
+ )
220
+ app_title_ez.pack(side="left")
221
+ app_title_ez.bind("<ButtonPress-1>", self._start_drag)
222
+ app_title_ez.bind("<B1-Motion>", self._on_drag)
223
+
224
+ app_title_shell = ctk.CTkLabel(
225
+ header_left,
226
+ text="Shell",
227
+ text_color=self.COLOR_TEXT_PRIMARY,
228
+ font=ctk.CTkFont(family=self.FONT_FAMILY_TITLE, size=self.FONT_SIZE_APP_TITLE, weight="bold"),
229
+ )
230
+ app_title_shell.pack(side="left")
231
+ app_title_shell.bind("<ButtonPress-1>", self._start_drag)
232
+ app_title_shell.bind("<B1-Motion>", self._on_drag)
233
+
234
+ # Right: [Esc] to close pill
235
+ close_pill = ctk.CTkFrame(
236
+ self.header_frame,
237
+ fg_color=self.COLOR_BG_CARD,
238
+ border_color=self.COLOR_BORDER,
239
+ border_width=1,
240
+ corner_radius=6,
241
+ height=26,
242
+ )
243
+ close_pill.pack(side="right")
244
+
245
+ esc_badge = ctk.CTkLabel(
246
+ close_pill,
247
+ text="Esc",
248
+ text_color="#CBD5E1",
249
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=11, weight="bold"),
250
+ fg_color="#1E293B",
251
+ corner_radius=4,
252
+ width=32,
253
+ height=20,
254
+ )
255
+ esc_badge.pack(side="left", padx=(3, 5), pady=3)
256
+
257
+ close_text = ctk.CTkLabel(
258
+ close_pill,
259
+ text=t("esc_to_close"),
260
+ text_color=self.COLOR_TEXT_SUBTLE,
261
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=11),
262
+ )
263
+ close_text.pack(side="left", padx=(0, 7), pady=3)
264
+
265
+ close_pill.bind("<Button-1>", lambda e: self.destroy())
266
+ esc_badge.bind("<Button-1>", lambda e: self.destroy())
267
+ close_text.bind("<Button-1>", lambda e: self.destroy())
268
+
269
+ # Top divider line
270
+ ctk.CTkFrame(self.main_container, fg_color=self.COLOR_BORDER, height=1).pack(fill="x", pady=(0, 10))
271
+
272
+ # Footer réservé en premier (side="bottom") : le corps extensible ne peut plus le rogner
273
+ self.footer_bar = ctk.CTkFrame(self.main_container, fg_color="transparent", height=24)
274
+ self.footer_bar.pack(side="bottom", fill="x")
275
+ self._build_footer_status()
276
+ ctk.CTkFrame(self.main_container, fg_color=self.COLOR_BORDER, height=1).pack(
277
+ side="bottom", fill="x", pady=(8, 6)
278
+ )
279
+
280
+ # Dynamic body container
281
+ self.body_container = ctk.CTkFrame(self.main_container, fg_color="transparent")
282
+ self.body_container.pack(fill="both", expand=True)
283
+
284
+ if self.current_query:
285
+ self._render_result_view()
286
+ else:
287
+ self._render_launcher_view()
288
+
289
+ def _build_footer_status(self) -> None:
290
+ """Renders the refined bottom status bar with shortcuts and model info."""
291
+ for w in self.footer_bar.winfo_children():
292
+ w.destroy()
293
+
294
+ # Left: Keyboard shortcuts hint
295
+ ctk.CTkLabel(
296
+ self.footer_bar,
297
+ text=t("status_footer_hint"),
298
+ text_color=self.COLOR_TEXT_SUBTLE,
299
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=10),
300
+ anchor="w",
301
+ ).pack(side="left")
302
+
303
+ # Right: Active Provider & Model pill
304
+ provider_label = chat_bridge.provider_label(self.provider, self.config)
305
+
306
+ provider_frame = ctk.CTkFrame(self.footer_bar, fg_color="transparent")
307
+ provider_frame.pack(side="right")
308
+
309
+ # Green online dot
310
+ ctk.CTkLabel(
311
+ provider_frame,
312
+ text="●",
313
+ text_color=self.COLOR_SUCCESS,
314
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=9),
315
+ ).pack(side="left", padx=(0, 4))
316
+
317
+ ctk.CTkLabel(
318
+ provider_frame,
319
+ text=provider_label,
320
+ text_color=self.COLOR_TEXT_SUBTLE,
321
+ font=ctk.CTkFont(family=self.FONT_FAMILY_CODE, size=10),
322
+ anchor="e",
323
+ ).pack(side="left")
324
+
325
+ def _render_launcher_view(self) -> None:
326
+ """Renders clean, focused launcher mode with high-end developer dashboard layout."""
327
+ for w in self.body_container.winfo_children():
328
+ w.destroy()
329
+
330
+ # 1. Primary Search / Query Entry Card
331
+ search_card = ctk.CTkFrame(
332
+ self.body_container,
333
+ fg_color=self.COLOR_BG_INPUT,
334
+ border_color=self.COLOR_BORDER,
335
+ border_width=1,
336
+ corner_radius=10,
337
+ height=48,
338
+ )
339
+ search_card.pack(fill="x", pady=(2, 14))
340
+ search_card.pack_propagate(False)
341
+
342
+ prompt_icon = ctk.CTkLabel(
343
+ search_card,
344
+ text="›",
345
+ text_color=self.COLOR_PRIMARY_BLUE,
346
+ font=ctk.CTkFont(family=self.FONT_FAMILY_CODE, size=18, weight="bold"),
347
+ )
348
+ prompt_icon.pack(side="left", padx=(12, 6))
349
+
350
+ self.input_entry = ctk.CTkEntry(
351
+ search_card,
352
+ placeholder_text=t("input_placeholder"),
353
+ placeholder_text_color="#475569",
354
+ fg_color="transparent",
355
+ border_width=0,
356
+ text_color=self.COLOR_TEXT_PRIMARY,
357
+ font=ctk.CTkFont(family=self.FONT_FAMILY_CODE, size=self.FONT_SIZE_INPUT_ENTRY),
358
+ )
359
+ self.input_entry.pack(side="left", fill="both", expand=True, padx=(0, 8))
360
+ self.input_entry.bind("<Return>", self._on_input_return)
361
+ self.input_entry.focus()
362
+
363
+ # Focus ring animation
364
+ def _on_entry_focus(e):
365
+ search_card.configure(border_color=self.COLOR_PRIMARY_BLUE)
366
+
367
+ def _on_entry_blur(e):
368
+ search_card.configure(border_color=self.COLOR_BORDER)
369
+
370
+ self.input_entry.bind("<FocusIn>", _on_entry_focus)
371
+ self.input_entry.bind("<FocusOut>", _on_entry_blur)
372
+
373
+ # Sleek Send / Enter badge button
374
+ ask_btn = ctk.CTkButton(
375
+ search_card,
376
+ text=f"↵ {t('btn_send')}",
377
+ fg_color="#1E293B",
378
+ hover_color=self.COLOR_PRIMARY_BLUE,
379
+ text_color="#CBD5E1",
380
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=11, weight="bold"),
381
+ width=84,
382
+ height=30,
383
+ corner_radius=6,
384
+ command=self._on_submit_input,
385
+ )
386
+ ask_btn.pack(side="right", padx=(0, 8), pady=8)
387
+
388
+ # 2. Context Card: Detected Last Shell Command OR Clean System Status
389
+ if self.last_detected_cmd:
390
+ last_cmd_card = ctk.CTkFrame(
391
+ self.body_container,
392
+ fg_color=self.COLOR_BG_CARD,
393
+ border_color=self.COLOR_BORDER,
394
+ border_width=1,
395
+ corner_radius=8,
396
+ )
397
+ last_cmd_card.pack(fill="x", pady=(0, 14))
398
+
399
+ # Header row inside card
400
+ row_top = ctk.CTkFrame(last_cmd_card, fg_color="transparent")
401
+ row_top.pack(fill="x", padx=12, pady=(10, 6))
402
+
403
+ ctk.CTkLabel(
404
+ row_top,
405
+ text=f"› {t('last_cmd_detected')}",
406
+ text_color=self.COLOR_TEXT_MUTED,
407
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=11, weight="bold"),
408
+ anchor="w",
409
+ ).pack(side="left")
410
+
411
+ # Exit code pill
412
+ if self.last_exit_code is not None:
413
+ is_err = self.last_exit_code != 0
414
+ badge_bg = "#2B1216" if is_err else "#0F291E"
415
+ badge_fg = self.COLOR_DANGER if is_err else self.COLOR_SUCCESS
416
+ badge_text = f"✘ Code {self.last_exit_code}" if is_err else "✓ Code 0"
417
+
418
+ ctk.CTkLabel(
419
+ row_top,
420
+ text=badge_text,
421
+ text_color=badge_fg,
422
+ fg_color=badge_bg,
423
+ corner_radius=4,
424
+ font=ctk.CTkFont(family=self.FONT_FAMILY_CODE, size=10, weight="bold"),
425
+ padx=6,
426
+ pady=2,
427
+ ).pack(side="right")
428
+
429
+ # Monospace command container
430
+ cmd_box = ctk.CTkFrame(
431
+ last_cmd_card,
432
+ fg_color=self.COLOR_BG_CODE,
433
+ border_color=self.COLOR_BORDER,
434
+ border_width=1,
435
+ corner_radius=6,
436
+ height=34,
437
+ )
438
+ cmd_box.pack(fill="x", padx=12, pady=(0, 10))
439
+
440
+ cmd_mono = ctk.CTkLabel(
441
+ cmd_box,
442
+ text=f" $ {self.last_detected_cmd}",
443
+ text_color=self.COLOR_LIGHT_BLUE,
444
+ font=ctk.CTkFont(family=self.FONT_FAMILY_CODE, size=12, weight="bold"),
445
+ anchor="w",
446
+ )
447
+ cmd_mono.pack(fill="both", expand=True, padx=6, pady=6)
448
+ self._autowrap(cmd_mono, 24)
449
+
450
+ # Action buttons row
451
+ btn_row = ctk.CTkFrame(last_cmd_card, fg_color="transparent")
452
+ btn_row.pack(fill="x", padx=12, pady=(0, 10))
453
+
454
+ diag_btn = ctk.CTkButton(
455
+ btn_row,
456
+ text=f"⚡ {t('btn_diagnose_error')}",
457
+ fg_color=self.COLOR_PRIMARY_BLUE,
458
+ hover_color=self.COLOR_BLUE_HOVER,
459
+ text_color="#FFFFFF",
460
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=11, weight="bold"),
461
+ height=28,
462
+ corner_radius=5,
463
+ command=lambda: self._query_groq(
464
+ f"diagnose failed command: {self.last_detected_cmd}", mode="explain_error"
465
+ ),
466
+ )
467
+ diag_btn.pack(side="left", padx=(0, 8))
468
+
469
+ explain_btn = ctk.CTkButton(
470
+ btn_row,
471
+ text=f"ℹ {t('btn_explain_command')}",
472
+ fg_color="#1E293B",
473
+ hover_color="#334155",
474
+ border_color="#334155",
475
+ border_width=1,
476
+ text_color=self.COLOR_TEXT_MUTED,
477
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=11),
478
+ height=28,
479
+ corner_radius=5,
480
+ command=lambda: self._query_groq(
481
+ f"explain what this command does: {self.last_detected_cmd}",
482
+ mode="explain_command",
483
+ ),
484
+ )
485
+ explain_btn.pack(side="left")
486
+ else:
487
+ # Clean System Context Card when no prior failure is in history
488
+ status_card = ctk.CTkFrame(
489
+ self.body_container,
490
+ fg_color=self.COLOR_BG_CARD,
491
+ border_color=self.COLOR_BORDER,
492
+ border_width=1,
493
+ corner_radius=8,
494
+ )
495
+ status_card.pack(fill="x", pady=(0, 14))
496
+
497
+ top_row = ctk.CTkFrame(status_card, fg_color="transparent")
498
+ top_row.pack(fill="x", padx=12, pady=(8, 4))
499
+
500
+ ctk.CTkLabel(
501
+ top_row,
502
+ text=f"› {t('status_ready')}",
503
+ text_color=self.COLOR_TEXT_PRIMARY,
504
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=11, weight="bold"),
505
+ anchor="w",
506
+ ).pack(side="left")
507
+
508
+ ctk.CTkLabel(
509
+ top_row,
510
+ text="● Online",
511
+ text_color=self.COLOR_SUCCESS,
512
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=10, weight="bold"),
513
+ ).pack(side="right")
514
+
515
+ # System info pills row
516
+ chips_row = ctk.CTkFrame(status_card, fg_color="transparent")
517
+ chips_row.pack(fill="x", padx=12, pady=(0, 8))
518
+
519
+ cur_dir_name = Path(self.context.current_directory).name or "/"
520
+ chips = [
521
+ f"OS: {self.context.os_name} ({self.context.architecture})",
522
+ f"Shell: {self.context.shell_name}",
523
+ f"Cwd: {cur_dir_name}",
524
+ ]
525
+ if self.context.git_branch:
526
+ chips.append(f"Git: {self.context.git_branch}")
527
+
528
+ # Deux puces par ligne : elles tiennent dans la fenêtre compacte (45 colonnes)
529
+ row = None
530
+ for i, c in enumerate(chips):
531
+ if i % 2 == 0:
532
+ row = ctk.CTkFrame(chips_row, fg_color="transparent")
533
+ row.pack(fill="x", pady=(0, 4))
534
+ chip_lbl = ctk.CTkLabel(
535
+ row,
536
+ text=c,
537
+ text_color=self.COLOR_TEXT_MUTED,
538
+ fg_color="#0F172A",
539
+ corner_radius=4,
540
+ font=ctk.CTkFont(family=self.FONT_FAMILY_CODE, size=10),
541
+ padx=6,
542
+ pady=2,
543
+ )
544
+ chip_lbl.pack(side="left", padx=(0, 6))
545
+
546
+ # Quick Actions désactivées pour garder le launcher centré sur la question.
547
+ # Elles restent documentées ici pour une réactivation ultérieure :
548
+ # - ports : lister les ports ouverts et leurs PID
549
+ # - disque : afficher l'espace utilisé dans le dossier courant
550
+ # - Docker : nettoyer les conteneurs, volumes et images inutilisés
551
+ # - Git : annuler le dernier commit en conservant les changements locaux
552
+
553
+ def _render_result_view(self) -> None:
554
+ """Renders the clean diagnostic and result view with command prioritized at the top."""
555
+ for w in self.body_container.winfo_children():
556
+ w.destroy()
557
+
558
+ content_scroll = ctk.CTkScrollableFrame(
559
+ self.body_container,
560
+ fg_color="transparent",
561
+ scrollbar_button_color="#1E293B",
562
+ scrollbar_button_hover_color=self.COLOR_PRIMARY_BLUE,
563
+ )
564
+
565
+ # 1. User Query Pill Banner: > {query}
566
+ query_card = ctk.CTkFrame(
567
+ content_scroll,
568
+ fg_color=self.COLOR_BG_CARD,
569
+ border_color=self.COLOR_BORDER,
570
+ border_width=1,
571
+ corner_radius=8,
572
+ height=38,
573
+ )
574
+ query_card.pack(fill="x", pady=(0, 12))
575
+
576
+ q_inner = ctk.CTkFrame(query_card, fg_color="transparent")
577
+ q_inner.pack(fill="both", expand=True, padx=12, pady=6)
578
+
579
+ ctk.CTkLabel(
580
+ q_inner,
581
+ text="›",
582
+ text_color=self.COLOR_PRIMARY_BLUE,
583
+ font=ctk.CTkFont(family=self.FONT_FAMILY_CODE, size=self.FONT_SIZE_QUERY_CHEVRON, weight="bold"),
584
+ ).pack(side="left", padx=(0, 8))
585
+
586
+ self.query_label = ctk.CTkLabel(
587
+ q_inner,
588
+ text=self.current_query,
589
+ text_color=self.COLOR_TEXT_PRIMARY,
590
+ font=ctk.CTkFont(family=self.FONT_FAMILY_CODE, size=self.FONT_SIZE_QUERY_TEXT),
591
+ anchor="w",
592
+ )
593
+ self.query_label.pack(side="left", fill="x", expand=True)
594
+ self.query_label.configure(justify="left")
595
+ self._autowrap(self.query_label, 8)
596
+
597
+ # 2. Section: FIX / COMMANDE RECOMMANDEE (Prominently placed at top so it's instantly visible!)
598
+ if self.current_cmd:
599
+ ctk.CTkLabel(
600
+ content_scroll,
601
+ text=t("section_fix"),
602
+ text_color=self.COLOR_TEXT_MUTED,
603
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=self.FONT_SIZE_SECTION_HEADER, weight="bold"),
604
+ anchor="w",
605
+ ).pack(fill="x", pady=(0, 4))
606
+
607
+ self.cmd_box = ctk.CTkFrame(
608
+ content_scroll,
609
+ fg_color=self.COLOR_BG_CODE,
610
+ border_color=self.COLOR_BORDER,
611
+ border_width=1,
612
+ corner_radius=8,
613
+ height=46,
614
+ )
615
+ self.cmd_box.pack(fill="x", pady=(0, 8))
616
+
617
+ cmd_inner = ctk.CTkFrame(self.cmd_box, fg_color="transparent")
618
+ cmd_inner.pack(fill="both", expand=True, padx=12, pady=6)
619
+
620
+ ctk.CTkLabel(
621
+ cmd_inner,
622
+ text="$",
623
+ text_color=self.COLOR_TEXT_SUBTLE,
624
+ font=ctk.CTkFont(family=self.FONT_FAMILY_CODE, size=self.FONT_SIZE_FIX_COMMAND),
625
+ ).pack(side="left", padx=(0, 8))
626
+
627
+ self.cmd_text_lbl = ctk.CTkLabel(
628
+ cmd_inner,
629
+ text=self.current_cmd,
630
+ text_color="#60A5FA",
631
+ font=ctk.CTkFont(family=self.FONT_FAMILY_CODE, size=self.FONT_SIZE_FIX_COMMAND, weight="bold"),
632
+ anchor="w",
633
+ )
634
+ self.cmd_text_lbl.pack(side="left", fill="x", expand=True)
635
+ self.cmd_text_lbl.configure(justify="left")
636
+ self._autowrap(self.cmd_text_lbl, 8)
637
+
638
+ # Quick Copy Button inside the code box
639
+ self.quick_copy_btn = ctk.CTkButton(
640
+ cmd_inner,
641
+ text="Copier",
642
+ fg_color="#1E293B",
643
+ hover_color=self.COLOR_PRIMARY_BLUE,
644
+ text_color="#CBD5E1",
645
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=11),
646
+ width=60,
647
+ height=26,
648
+ corner_radius=4,
649
+ command=self.copy_current_command,
650
+ )
651
+ self.quick_copy_btn.pack(side="right")
652
+
653
+ # Action Pills Bar: [Enter run] [c copy] [e edit]
654
+ actions_bar = ctk.CTkFrame(content_scroll, fg_color="transparent")
655
+ actions_bar.pack(fill="x", pady=(0, 10))
656
+
657
+ self.btn_run = self._create_action_pill(
658
+ actions_bar,
659
+ key_text="Enter",
660
+ label_text=t("action_run"),
661
+ command=self.run_current_command,
662
+ accent=True,
663
+ )
664
+ self.btn_run.pack(side="left", padx=(0, 8))
665
+
666
+ self.btn_copy = self._create_action_pill(
667
+ actions_bar,
668
+ key_text="c",
669
+ label_text=t("action_copy"),
670
+ command=self.copy_current_command,
671
+ )
672
+ self.btn_copy.pack(side="left", padx=(0, 8))
673
+
674
+ self.btn_edit = self._create_action_pill(
675
+ actions_bar,
676
+ key_text="e",
677
+ label_text=t("action_edit"),
678
+ command=self.edit_current_command,
679
+ )
680
+ self.btn_edit.pack(side="left", padx=(0, 8))
681
+
682
+ # Risk Badge Row
683
+ risk_row = ctk.CTkFrame(content_scroll, fg_color="transparent")
684
+ risk_row.pack(fill="x", pady=(0, 12))
685
+
686
+ risk_color = (
687
+ self.COLOR_DANGER
688
+ if self.current_risk_level == RiskLevel.DANGEROUS
689
+ else (self.COLOR_WARNING if self.current_risk_level == RiskLevel.WARNING else self.COLOR_SUCCESS)
690
+ )
691
+ risk_label = (
692
+ t("risk_high")
693
+ if self.current_risk_level == RiskLevel.DANGEROUS
694
+ else (t("risk_medium") if self.current_risk_level == RiskLevel.WARNING else t("risk_low"))
695
+ )
696
+
697
+ ctk.CTkLabel(
698
+ risk_row,
699
+ text="●",
700
+ text_color=risk_color,
701
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=10),
702
+ ).pack(side="left", padx=(0, 4))
703
+
704
+ ctk.CTkLabel(
705
+ risk_row,
706
+ text=risk_label,
707
+ text_color=risk_color,
708
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=self.FONT_SIZE_RISK_BADGE, weight="bold"),
709
+ ).pack(side="left", padx=(0, 8))
710
+
711
+ risk_reason_lbl = ctk.CTkLabel(
712
+ risk_row,
713
+ text=self.current_risk_reason or t("risk_reason_default"),
714
+ text_color=self.COLOR_TEXT_MUTED,
715
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=self.FONT_SIZE_RISK_BADGE),
716
+ anchor="w",
717
+ justify="left",
718
+ )
719
+ risk_reason_lbl.pack(side="left", fill="x", expand=True)
720
+ self._autowrap(risk_reason_lbl, 4)
721
+
722
+ # 3. Section: EXPLANATION or CAUSE (Cleaned of raw markdown syntax)
723
+ is_error = any(w in self.current_query.lower() for w in ("diagnose", "error", "failed", "échec", "fail"))
724
+ section_title = t("section_cause") if is_error else t("section_explanation")
725
+
726
+ ctk.CTkLabel(
727
+ content_scroll,
728
+ text=section_title,
729
+ text_color=self.COLOR_TEXT_MUTED,
730
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=self.FONT_SIZE_SECTION_HEADER, weight="bold"),
731
+ anchor="w",
732
+ ).pack(fill="x", pady=(0, 4))
733
+
734
+ cleaned_body = clean_markdown_explanation(self.current_cause)
735
+
736
+ expl_card = ctk.CTkFrame(
737
+ content_scroll,
738
+ fg_color=self.COLOR_BG_CARD,
739
+ border_color=self.COLOR_BORDER,
740
+ border_width=1,
741
+ corner_radius=8,
742
+ )
743
+ expl_card.pack(fill="x", pady=(0, 12))
744
+
745
+ self.cause_body = ctk.CTkLabel(
746
+ expl_card,
747
+ text=cleaned_body or t("thinking"),
748
+ text_color=self.COLOR_TEXT_PRIMARY if self.current_cause else self.COLOR_PRIMARY_BLUE,
749
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=self.FONT_SIZE_CAUSE_BODY),
750
+ anchor="w",
751
+ justify="left",
752
+ )
753
+ self.cause_body.pack(fill="x", padx=12, pady=10)
754
+ self._autowrap(self.cause_body, 24)
755
+
756
+ # Execution Result Box (appears when Enter/run is clicked)
757
+ self.exec_box = ctk.CTkFrame(content_scroll, fg_color=self.COLOR_BG_CODE, border_color=self.COLOR_BORDER, border_width=1, corner_radius=6)
758
+ self.exec_label = ctk.CTkLabel(
759
+ self.exec_box,
760
+ text="",
761
+ font=ctk.CTkFont(family=self.FONT_FAMILY_CODE, size=self.FONT_SIZE_EXEC_OUTPUT),
762
+ justify="left",
763
+ anchor="w",
764
+ )
765
+ self.exec_label.pack(fill="both", expand=True, padx=10, pady=8)
766
+ self._autowrap(self.exec_label, 20)
767
+
768
+ # 4. Bottom Controls: Follow-up Input Entry (packé côté bas avant la zone défilante)
769
+
770
+ bottom_input_box = ctk.CTkFrame(
771
+ self.body_container,
772
+ fg_color=self.COLOR_BG_INPUT,
773
+ border_color=self.COLOR_BORDER,
774
+ border_width=1,
775
+ corner_radius=8,
776
+ height=40,
777
+ )
778
+ bottom_input_box.pack(side="bottom", fill="x")
779
+ bottom_input_box.pack_propagate(False)
780
+ ctk.CTkFrame(self.body_container, fg_color=self.COLOR_BORDER, height=1).pack(
781
+ side="bottom", fill="x", pady=(0, 8)
782
+ )
783
+ content_scroll.pack(fill="both", expand=True, pady=(0, 8))
784
+
785
+ ctk.CTkLabel(
786
+ bottom_input_box,
787
+ text="›",
788
+ text_color=self.COLOR_PRIMARY_BLUE,
789
+ font=ctk.CTkFont(family=self.FONT_FAMILY_CODE, size=16, weight="bold"),
790
+ ).pack(side="left", padx=(10, 6))
791
+
792
+ self.input_entry = ctk.CTkEntry(
793
+ bottom_input_box,
794
+ placeholder_text=t("input_placeholder"),
795
+ placeholder_text_color="#475569",
796
+ fg_color="transparent",
797
+ border_width=0,
798
+ text_color=self.COLOR_TEXT_PRIMARY,
799
+ font=ctk.CTkFont(family=self.FONT_FAMILY_CODE, size=self.FONT_SIZE_INPUT_ENTRY),
800
+ )
801
+ self.input_entry.pack(side="left", fill="both", expand=True, padx=(0, 6))
802
+ self.input_entry.bind("<Return>", self._on_input_return)
803
+
804
+ # Focus ring animation on bottom input
805
+ def _on_bottom_focus(e):
806
+ bottom_input_box.configure(border_color=self.COLOR_PRIMARY_BLUE)
807
+
808
+ def _on_bottom_blur(e):
809
+ bottom_input_box.configure(border_color=self.COLOR_BORDER)
810
+
811
+ self.input_entry.bind("<FocusIn>", _on_bottom_focus)
812
+ self.input_entry.bind("<FocusOut>", _on_bottom_blur)
813
+
814
+ # Back to launcher button
815
+ back_btn = ctk.CTkButton(
816
+ bottom_input_box,
817
+ text="← Retour",
818
+ fg_color="#1E293B",
819
+ hover_color=self.COLOR_PRIMARY_BLUE,
820
+ text_color="#94A3B8",
821
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=11),
822
+ width=70,
823
+ height=28,
824
+ corner_radius=5,
825
+ command=self._reset_to_launcher,
826
+ )
827
+ back_btn.pack(side="right", padx=(0, 6), pady=6)
828
+
829
+ def _reset_to_launcher(self) -> None:
830
+ """Returns from result view back to launcher view."""
831
+ self.current_query = ""
832
+ self.current_cause = ""
833
+ self.current_cmd = ""
834
+ self._render_launcher_view()
835
+
836
+ def _create_action_pill(
837
+ self,
838
+ parent: ctk.CTkFrame,
839
+ key_text: str,
840
+ label_text: str,
841
+ command,
842
+ accent: bool = False,
843
+ ) -> ctk.CTkFrame:
844
+ """Creates a sleek key action pill."""
845
+ bg_col = self.COLOR_PRIMARY_BLUE if accent else self.COLOR_BG_CARD
846
+ border_col = self.COLOR_BLUE_HOVER if accent else self.COLOR_BORDER
847
+
848
+ pill = ctk.CTkFrame(
849
+ parent,
850
+ fg_color=bg_col,
851
+ border_color=border_col,
852
+ border_width=1,
853
+ corner_radius=6,
854
+ height=28,
855
+ )
856
+ # Pas de pack_propagate(False) : la pastille prend la largeur de son contenu (sinon 200 px fixes)
857
+
858
+ key_lbl = ctk.CTkLabel(
859
+ pill,
860
+ text=key_text,
861
+ text_color="#FFFFFF" if accent else "#CBD5E1",
862
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=self.FONT_SIZE_PILL_KEY, weight="bold"),
863
+ fg_color=self.COLOR_BLUE_HOVER if accent else "#1E293B",
864
+ corner_radius=4,
865
+ width=28 if len(key_text) <= 2 else 42,
866
+ height=20,
867
+ )
868
+ key_lbl.pack(side="left", padx=(3, 5), pady=3)
869
+
870
+ lbl = ctk.CTkLabel(
871
+ pill,
872
+ text=label_text,
873
+ text_color="#FFFFFF" if accent else self.COLOR_TEXT_MUTED,
874
+ font=ctk.CTkFont(family=self.FONT_FAMILY_UI, size=self.FONT_SIZE_PILL_LABEL, weight="bold" if accent else "normal"),
875
+ )
876
+ lbl.pack(side="left", padx=(0, 8), pady=3)
877
+
878
+ def _on_enter(e):
879
+ if not accent:
880
+ pill.configure(fg_color="#1E293B", border_color=self.COLOR_PRIMARY_BLUE)
881
+
882
+ def _on_leave(e):
883
+ if not accent:
884
+ pill.configure(fg_color=self.COLOR_BG_CARD, border_color=self.COLOR_BORDER)
885
+
886
+ for w in (pill, key_lbl, lbl):
887
+ w.bind("<Button-1>", lambda e: command())
888
+ w.bind("<Enter>", _on_enter)
889
+ w.bind("<Leave>", _on_leave)
890
+
891
+ return pill
892
+
893
+ def _handle_global_key(self, event) -> None:
894
+ """Handles single-key shortcuts when the text entry is not focused."""
895
+ focused = self.focus_get()
896
+ if hasattr(self, "input_entry") and focused in (self.input_entry, self.input_entry._entry):
897
+ return
898
+
899
+ k = event.keysym.lower()
900
+ if k == "q":
901
+ self.destroy()
902
+ elif k == "c" and self.current_cmd:
903
+ self.copy_current_command()
904
+ elif k == "e" and self.current_cmd:
905
+ self.edit_current_command()
906
+ elif k == "return" and self.current_cmd:
907
+ self.run_current_command()
908
+
909
+ def run_current_command(self) -> None:
910
+ """Executes the fix command via PTYExecutor and displays terminal output."""
911
+ cmd = self.current_cmd.strip()
912
+ if not cmd:
913
+ return
914
+
915
+ result = PTYExecutor.execute(cmd, cwd=os.getcwd(), forward_output=False)
916
+
917
+ is_ok = result.exit_code == 0
918
+ status_col = self.COLOR_SUCCESS if is_ok else self.COLOR_DANGER
919
+ status_text = (
920
+ f"› {t('cmd_success')}\n{result.clean_output.strip() or t('cmd_no_output')}"
921
+ if is_ok
922
+ else f"✘ Command exited with code {result.exit_code}\n{result.clean_output.strip() or t('cmd_no_output')}"
923
+ )
924
+
925
+ self.exec_label.configure(text=status_text, text_color=status_col)
926
+ self.exec_box.pack(fill="x", pady=(0, 10))
927
+
928
+ def copy_current_command(self) -> None:
929
+ """Copies the fix command to clipboard."""
930
+ try:
931
+ self.clipboard_clear()
932
+ self.clipboard_append(self.current_cmd)
933
+ except Exception:
934
+ pass
935
+
936
+ if hasattr(self, "quick_copy_btn"):
937
+ self.quick_copy_btn.configure(text="✓ Copié !", fg_color=self.COLOR_SUCCESS)
938
+ self.after(1200, lambda: self.quick_copy_btn.configure(text="Copier", fg_color="#1E293B"))
939
+
940
+ if hasattr(self, "cmd_box"):
941
+ self.cmd_box.configure(border_color=self.COLOR_PRIMARY_BLUE)
942
+ self.after(500, lambda: self.cmd_box.configure(border_color=self.COLOR_BORDER))
943
+
944
+ def edit_current_command(self) -> None:
945
+ """Populates the input entry with the current command."""
946
+ self.input_entry.delete(0, "end")
947
+ self.input_entry.insert(0, self.current_cmd)
948
+ self.input_entry.focus()
949
+
950
+ def _on_submit_input(self) -> None:
951
+ """Queries AI with the text from input_entry."""
952
+ query = self.input_entry.get().strip()
953
+ if not query:
954
+ return
955
+
956
+ self.input_entry.delete(0, "end")
957
+ self._query_groq(query)
958
+
959
+ def _on_input_return(self, _event=None) -> str:
960
+ """Submits the focused input and prevents the global Return shortcut."""
961
+ self._on_submit_input()
962
+ return "break"
963
+
964
+ def _query_groq(self, query: str, mode: str = "translate") -> None:
965
+ """Sends query to active LLM in a background thread and updates the UI."""
966
+ self.current_query = query
967
+ self.current_cause = ""
968
+ self.current_cmd = ""
969
+ self.current_risk_reason = ""
970
+ self._render_result_view()
971
+
972
+ def _worker():
973
+ try:
974
+ result = chat_bridge.ask(
975
+ query if mode == "translate" else "",
976
+ mode=mode,
977
+ command=self.last_detected_cmd or "",
978
+ provider=self.provider,
979
+ )
980
+ self.after(0, lambda: self._apply_result(result))
981
+ except Exception as e:
982
+ msg = chat_bridge.describe_error(e)
983
+ self.after(0, lambda: self._apply_error(msg))
984
+
985
+ threading.Thread(target=_worker, daemon=True).start()
986
+
987
+ def _apply_result(self, result: ChatMessageResult) -> None:
988
+ """Parses and renders AI diagnosis and suggested command."""
989
+ self.current_cause = result.content.strip()
990
+
991
+ if result.suggested_commands:
992
+ self.current_cmd = result.suggested_commands[0]
993
+ if result.safety_reports:
994
+ report = result.safety_reports[0]
995
+ self.current_risk_level = report.risk_level
996
+ self.current_risk_reason = report.reason or t("risk_reason_default")
997
+ else:
998
+ self.current_cmd = ""
999
+ self.current_risk_reason = ""
1000
+
1001
+ self._render_result_view()
1002
+
1003
+ def _apply_error(self, err_msg: str) -> None:
1004
+ """Renders API error message."""
1005
+ self.current_cause = err_msg
1006
+ self.current_cmd = ""
1007
+ self._render_result_view()
1008
+
1009
+ def _start_drag(self, event) -> None:
1010
+ """Records mouse offset for window dragging."""
1011
+ self._drag_x = event.x
1012
+ self._drag_y = event.y
1013
+
1014
+ def _on_drag(self, event) -> None:
1015
+ """Updates window position as mouse is dragged."""
1016
+ x = self.winfo_x() + (event.x - self._drag_x)
1017
+ y = self.winfo_y() + (event.y - self._drag_y)
1018
+ self.geometry(f"+{x}+{y}")