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/inline.py ADDED
@@ -0,0 +1,330 @@
1
+ """Flux inline : `ezsh suggest`.
2
+
3
+ L'interface s'affiche sur le terminal (/dev/tty sous Unix, la console CONOUT$/CONIN$ sous
4
+ Windows) ; seule la commande finale sort sur stdout, que la fonction shell récupère.
5
+
6
+ Règles (DEVELOPMENT.md §1) : jamais d'exécution silencieuse (on n'exécute RIEN, on insère
7
+ dans le prompt via la fonction shell), le contenu des requêtes n'est jamais journalisé.
8
+ Deux modes de touches : *résultat* (⏎ c e / q Esc y) et *saisie* (⏎ envoie, Esc revient).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import select
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ from contextlib import contextmanager
19
+ from typing import Optional, TextIO, Tuple
20
+
21
+ from src.core.provider import (
22
+ LLMProvider,
23
+ MissingKey,
24
+ ProviderError,
25
+ ProviderOffline,
26
+ QuotaExceeded,
27
+ UpdateRequired,
28
+ get_provider,
29
+ )
30
+ from src.core.schema import InvalidResponse
31
+ from src.core.suggest import Suggestion, build_request, detect_lang, get_suggestion
32
+ from src.ui.panel import loading_text, message_panel, result_panel, tr
33
+
34
+ IS_WINDOWS = os.name == "nt"
35
+ WIN_CONSOLE = -2 # pseudo-descripteur : les touches se lisent via msvcrt, pas via un fd
36
+
37
+ if IS_WINDOWS: # pragma: no cover - exécuté sur la CI Windows
38
+ import msvcrt
39
+
40
+ termios = None # type: ignore
41
+ else:
42
+ import termios
43
+ import tty as _tty
44
+
45
+
46
+ def _enable_vt(stream: TextIO) -> None:
47
+ """Active les séquences ANSI sur la console Windows (effacement de ligne, couleurs)."""
48
+ try: # pragma: no cover - Windows uniquement
49
+ import ctypes
50
+
51
+ kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
52
+ handle = msvcrt.get_osfhandle(stream.fileno())
53
+ mode = ctypes.c_uint32()
54
+ if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
55
+ # ENABLE_VIRTUAL_TERMINAL_PROCESSING
56
+ kernel32.SetConsoleMode(handle, mode.value | 0x0004)
57
+ except Exception:
58
+ pass
59
+
60
+
61
+ def open_tty() -> Tuple[Optional[TextIO], Optional[int]]:
62
+ """(flux d'écriture, lecteur de touches) sur le terminal, même si stdout est capturé."""
63
+ if IS_WINDOWS: # pragma: no cover - Windows uniquement
64
+ try:
65
+ out = open("CONOUT$", "w", buffering=1, encoding="utf-8", errors="replace")
66
+ except OSError:
67
+ return None, None
68
+ _enable_vt(out)
69
+ return out, WIN_CONSOLE
70
+ try:
71
+ fd = os.open("/dev/tty", os.O_RDWR)
72
+ return os.fdopen(os.dup(fd), "w", buffering=1, encoding="utf-8"), fd
73
+ except OSError:
74
+ return None, None
75
+
76
+
77
+ @contextmanager
78
+ def raw_mode(fd: int):
79
+ if fd == WIN_CONSOLE: # msvcrt lit déjà touche par touche, sans écho
80
+ yield
81
+ return
82
+ old = termios.tcgetattr(fd)
83
+ try:
84
+ _tty.setcbreak(fd)
85
+ yield
86
+ finally:
87
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
88
+
89
+
90
+ # Touches étendues de la console Windows (préfixe \x00 ou \xe0)
91
+ _WIN_SPECIAL = {"K": "left", "M": "right", "G": "home", "O": "end", "S": "ignore"}
92
+
93
+
94
+ def _read_key_windows() -> str: # pragma: no cover - Windows uniquement
95
+ ch = msvcrt.getwch()
96
+ if ch in ("\x00", "\xe0"):
97
+ return _WIN_SPECIAL.get(msvcrt.getwch(), "ignore")
98
+ return {"\r": "enter", "\n": "enter", "\x03": "ctrl-c", "\x08": "backspace", "\x1b": "esc"}.get(
99
+ ch, ch
100
+ )
101
+
102
+
103
+ def read_key(fd: int) -> str:
104
+ """Une touche : enter, esc, backspace, left, right, home, end, ctrl-c, ou le caractère."""
105
+ if fd == WIN_CONSOLE:
106
+ return _read_key_windows()
107
+ b = os.read(fd, 1)
108
+ if not b:
109
+ return "esc"
110
+ ch = b.decode("utf-8", errors="ignore")
111
+ if ch in ("\r", "\n"):
112
+ return "enter"
113
+ if ch == "\x03":
114
+ return "ctrl-c"
115
+ if ch in ("\x7f", "\x08"):
116
+ return "backspace"
117
+ if ch == "\x1b":
118
+ if not select.select([fd], [], [], 0.05)[0]:
119
+ return "esc"
120
+ seq = os.read(fd, 8).decode("utf-8", errors="ignore")
121
+ return {
122
+ "[D": "left",
123
+ "[C": "right",
124
+ "[H": "home",
125
+ "[F": "end",
126
+ "[1~": "home",
127
+ "[4~": "end",
128
+ }.get(seq, "ignore")
129
+ if ch and ord(ch[0]) >= 0x80: # début d'un caractère UTF-8 multi-octets
130
+ need = 1 if b[0] >> 5 == 0b110 else 2 if b[0] >> 4 == 0b1110 else 3
131
+ ch = (b + os.read(fd, need)).decode("utf-8", errors="ignore")
132
+ return ch
133
+
134
+
135
+ def edit_line(fd: int, out: TextIO, prompt: str, initial: str = "") -> Optional[str]:
136
+ """Mini éditeur de ligne (stdout reste libre). Retourne None si Échap."""
137
+ buf, pos = list(initial), len(initial)
138
+
139
+ def redraw() -> None:
140
+ out.write("\r\x1b[2K" + prompt + "".join(buf))
141
+ out.write("\r" + "\x1b[%dC" % (len(prompt) + pos) if len(prompt) + pos else "\r")
142
+ out.flush()
143
+
144
+ redraw()
145
+ with raw_mode(fd):
146
+ while True:
147
+ k = read_key(fd)
148
+ if k == "enter":
149
+ out.write("\n")
150
+ return "".join(buf).strip()
151
+ if k in ("esc", "ctrl-c"):
152
+ out.write("\n")
153
+ return None
154
+ if k == "backspace" and pos:
155
+ del buf[pos - 1]
156
+ pos -= 1
157
+ elif k == "left":
158
+ pos = max(0, pos - 1)
159
+ elif k == "right":
160
+ pos = min(len(buf), pos + 1)
161
+ elif k == "home":
162
+ pos = 0
163
+ elif k == "end":
164
+ pos = len(buf)
165
+ elif len(k) >= 1 and k not in ("ignore", "backspace") and k.isprintable():
166
+ buf.insert(pos, k)
167
+ pos += 1
168
+ redraw()
169
+
170
+
171
+ def copy_to_clipboard(text: str) -> bool:
172
+ if IS_WINDOWS: # clip.exe lit l'UTF-16 avec BOM : accents et symboles conservés
173
+ try:
174
+ data = b"\xff\xfe" + text.encode("utf-16-le")
175
+ subprocess.run(["clip"], input=data, check=True, timeout=2)
176
+ return True
177
+ except Exception:
178
+ return False
179
+ for cmd in (
180
+ ["pbcopy"],
181
+ ["wl-copy"],
182
+ ["xclip", "-selection", "clipboard"],
183
+ ["xsel", "--clipboard", "--input"],
184
+ ):
185
+ if shutil.which(cmd[0]):
186
+ try:
187
+ subprocess.run(cmd, input=text.encode(), check=True, timeout=2)
188
+ return True
189
+ except Exception:
190
+ continue
191
+ return False
192
+
193
+
194
+ def _fetch(req, provider: LLMProvider, console, lang: str):
195
+ """Retourne ('ok', Suggestion) ou (kind, kwargs) pour message_panel."""
196
+ try:
197
+ with console.status(loading_text(lang), spinner="dots"):
198
+ return "ok", get_suggestion(req, provider)
199
+ except ProviderOffline:
200
+ return "offline", {}
201
+ except QuotaExceeded as q:
202
+ return ("trial" if q.scope == "anonymous" else "quota"), {
203
+ "scope": q.scope,
204
+ "reset": q.reset_at,
205
+ }
206
+ except UpdateRequired:
207
+ return "update_required", {}
208
+ except MissingKey:
209
+ return "nokey", {}
210
+ except InvalidResponse:
211
+ return "invalid", {}
212
+ except ProviderError:
213
+ return "error", {}
214
+
215
+
216
+ def run_inline(ask: Optional[str] = None, provider: Optional[LLMProvider] = None) -> int:
217
+ """0 : commande écrite sur stdout ; 1 : annulé ; 2 : environnement inadapté (pas de tty)."""
218
+ out, fd = open_tty()
219
+ if out is None or fd is None:
220
+ print("ezsh: terminal interactif requis (pas de /dev/tty ni de console).", file=sys.stderr)
221
+ return 2
222
+ # PSReadLine redessine le prompt à sa position d'origine : le hook PowerShell demande
223
+ # l'écran alternatif pour que le panneau ne décale pas la ligne de saisie.
224
+ alt = os.environ.get("EZSH_ALT_SCREEN") == "1"
225
+ if alt:
226
+ out.write("\x1b[?1049h\x1b[H")
227
+ out.flush()
228
+ try:
229
+ return _run_inline(out, fd, ask, provider)
230
+ finally:
231
+ if alt:
232
+ out.write("\x1b[?1049l")
233
+ out.flush()
234
+
235
+
236
+ def _run_inline(out: TextIO, fd: int, ask: Optional[str], provider: Optional[LLMProvider]) -> int:
237
+ from rich.console import Console
238
+ lang = detect_lang()
239
+ t = tr(lang)
240
+ width = min(80, shutil.get_terminal_size((80, 24)).columns)
241
+ console = Console(
242
+ file=out,
243
+ force_terminal=True,
244
+ legacy_windows=False, # VT activé sur la console Windows : rendu ANSI partout
245
+ width=width,
246
+ highlight=False,
247
+ markup=False,
248
+ emoji=False,
249
+ )
250
+ provider = provider or get_provider()
251
+
252
+ query = (ask or "").strip()
253
+ last_state: Tuple[str, object] = ("empty", {})
254
+
255
+ def ask_model(q: str) -> Tuple[str, object]:
256
+ return _fetch(build_request(q), provider, console, lang)
257
+
258
+ # ouverture : question explicite, sinon dernière commande, sinon panneau vide
259
+ from src.core.capture import read_last_run
260
+
261
+ if query or read_last_run():
262
+ last_state = ask_model(query)
263
+ show(console, last_state, lang)
264
+ _update_notice(console)
265
+
266
+ while True:
267
+ kind, payload = last_state
268
+ with raw_mode(fd):
269
+ key = read_key(fd)
270
+ if key in ("q", "esc", "ctrl-c"):
271
+ return 1
272
+ if key == "/":
273
+ q = edit_line(fd, out, f"{t['ask']} › ")
274
+ if q:
275
+ last_state = ask_model(q)
276
+ show(console, last_state, lang)
277
+ continue
278
+ if kind != "ok":
279
+ continue
280
+ s: Suggestion = payload # type: ignore[assignment]
281
+ if not s.command:
282
+ continue
283
+ if key == "c":
284
+ console.print(t["copied"] if copy_to_clipboard(s.command) else t["nocopy"], style="dim")
285
+ elif key == "e":
286
+ console.print(t["edit"], style="dim")
287
+ edited = edit_line(
288
+ fd, out, "$ ", s.command.replace("\n", " ; ") if s.multiline else s.command
289
+ )
290
+ if edited:
291
+ return _emit(edited, console, t)
292
+ elif key == "enter" and s.risk != "high" and not s.multiline:
293
+ return _emit(s.command, console, t)
294
+ elif key == "y" and (s.risk == "high" or s.multiline):
295
+ return _emit(s.command, console, t)
296
+
297
+
298
+ def _update_notice(console) -> None:
299
+ """Une ligne discrète, au plus une fois par jour, si une nouvelle version existe."""
300
+ try:
301
+ from src.core.updater import take_notice
302
+
303
+ line = take_notice()
304
+ if line:
305
+ console.print(line, style="dim")
306
+ except Exception: # le rappel ne doit jamais gêner le panneau
307
+ pass
308
+
309
+
310
+ def show(console, state: Tuple[str, object], lang: str) -> None:
311
+ kind, payload = state
312
+ console.print(
313
+ result_panel(payload, lang)
314
+ if kind == "ok"
315
+ else message_panel(kind, lang, **(payload or {}))
316
+ ) # type: ignore[arg-type]
317
+
318
+
319
+ def _emit(command: str, console, t: dict) -> int:
320
+ from src.core.sanitize import sanitize_command
321
+
322
+ safe = sanitize_command(command)
323
+ if safe.multiline: # garde-fou final : jamais de saut de ligne dans le prompt
324
+ safe_text = " ; ".join(line.strip() for line in safe.text.splitlines() if line.strip())
325
+ else:
326
+ safe_text = safe.text
327
+ console.print(t["inserted"], style="dim")
328
+ sys.stdout.write(safe_text)
329
+ sys.stdout.flush()
330
+ return 0
src/ui/logo.py ADDED
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+
11
+ INSTALL_MARKER_PATH = Path.home() / ".ezshell" / ".installed"
12
+
13
+
14
+ def is_first_install() -> bool:
15
+ """Checks if ezShell is being run for the first time by the user."""
16
+ return not INSTALL_MARKER_PATH.exists()
17
+
18
+
19
+ def mark_as_installed() -> None:
20
+ """Creates the marker file indicating ezShell has been installed and welcomed."""
21
+ try:
22
+ INSTALL_MARKER_PATH.parent.mkdir(parents=True, exist_ok=True)
23
+ INSTALL_MARKER_PATH.touch(exist_ok=True)
24
+ except Exception:
25
+ pass
26
+
27
+
28
+ def _gradient(text: str, r1: int, g1: int, b1: int, r2: int, g2: int, b2: int) -> str:
29
+ """Applies a horizontal truecolor gradient across a string of characters."""
30
+ n = max(len(text) - 1, 1)
31
+ result = ""
32
+ for i, ch in enumerate(text):
33
+ t = i / n
34
+ r = int(r1 + (r2 - r1) * t)
35
+ g = int(g1 + (g2 - g1) * t)
36
+ b = int(b1 + (b2 - b1) * t)
37
+ result += f"\033[38;2;{r};{g};{b}m{ch}"
38
+ return result + "\033[0m"
39
+
40
+
41
+ def _vertical_gradient_lines(
42
+ lines: List[str],
43
+ r1: int, g1: int, b1: int,
44
+ r2: int, g2: int, b2: int,
45
+ ) -> List[str]:
46
+ """Applies a vertical truecolor gradient across multiple lines."""
47
+ n = max(len(lines) - 1, 1)
48
+ result = []
49
+ for i, line in enumerate(lines):
50
+ t = i / n
51
+ r = int(r1 + (r2 - r1) * t)
52
+ g = int(g1 + (g2 - g1) * t)
53
+ b = int(b1 + (b2 - b1) * t)
54
+ result.append(f"\033[38;2;{r};{g};{b}m{line}\033[0m")
55
+ return result
56
+
57
+
58
+ def render_logo_ascii() -> List[str]:
59
+ """Renders the official ezShell logo as high-quality hand-crafted ASCII art.
60
+
61
+ The logo is the iconic "C>" terminal prompt shape: a rounded bracket "C"
62
+ with a chevron ">" and cursor bar "=" inside. Uses ANSI truecolor for a
63
+ cyan-to-blue vertical gradient that mimics the metallic 3D look.
64
+ """
65
+ # Raw ASCII art lines (no color) -- hand-crafted to match the symbol logo shape
66
+ raw = [
67
+ r" ██████████████████████████ ",
68
+ r" ████▀▀ ▀▀████ ",
69
+ r" ███▀ ████████████████████████ ▀███ ",
70
+ r" ███▀ ███▀ ▀███ ▀███ ",
71
+ r" ███ ███▀ ▀███ ███ ",
72
+ r" ███ ███ ███ ███ ",
73
+ r" ███ ███ ██▄ ███ ███ ",
74
+ r" ███ ███ ████▄ ███ ███ ",
75
+ r" ███ ███ █████▄ ████████ ███ ███ ",
76
+ r" ███ ███ ████▀ ███ ███ ",
77
+ r" ███ ███ ██▀ ███ ███ ",
78
+ r" ███ ███ ███ ███ ",
79
+ r" ███ ███▄ ▄███ ███ ",
80
+ r" ███▄ ███▄ ▄███ ▄███ ",
81
+ r" ███▄ ████████████████████████ ▄███ ",
82
+ r" ████▄▄ ▄▄████ ",
83
+ r" ██████████████████████████ ",
84
+ ]
85
+
86
+ # Apply a vertical gradient: bright cyan (#00E5FF) -> deep blue (#0066FF)
87
+ return _vertical_gradient_lines(
88
+ raw,
89
+ r1=0, g1=229, b1=255, # top: Electric Cyan
90
+ r2=0, g2=102, b2=255, # bottom: Deep Blue
91
+ )
92
+
93
+
94
+ def render_logo_compact() -> str:
95
+ """Returns a compact single-line colored logo mark for prompts and headers."""
96
+ c = "\033[1;38;2;0;229;255m" # Bold Electric Cyan
97
+ b = "\033[1;38;2;0;136;255m" # Bold Deep Blue
98
+ r = "\033[0m"
99
+ return f"{c}[{b}>{c}_{b}]{r}"
100
+
101
+
102
+ def render_logo_mini() -> List[str]:
103
+ """Returns a smaller 9-line logo variant for tighter terminal widths."""
104
+ raw = [
105
+ r" ▄▄████████████████▄▄ ",
106
+ r" ██▀ ██████████████ ▀██ ",
107
+ r" ██▀ ██▀ ▀██ ▀██ ",
108
+ r" ██ ██ ██▄ ██ ██ ",
109
+ r" ██ ██ ████ ████ ██ ██ ",
110
+ r" ██ ██ ██▀ ██ ██ ",
111
+ r" ██▄ ██▄ ▄██ ▄██ ",
112
+ r" ██▄ ██████████████ ▄██ ",
113
+ r" ▀▀████████████████▀▀ ",
114
+ ]
115
+ return _vertical_gradient_lines(
116
+ raw,
117
+ r1=0, g1=229, b1=255,
118
+ r2=0, g2=102, b2=255,
119
+ )
120
+
121
+
122
+ def render_title_text() -> str:
123
+ """Returns the 'ezShell' title as a gradient text string."""
124
+ return _gradient(
125
+ " e z S h e l l",
126
+ r1=0, g1=229, b1=255,
127
+ r2=0, g2=255, b2=136,
128
+ )
129
+
130
+
131
+ def show_welcome_banner(force: bool = False) -> None:
132
+ """Displays the welcome screen with the official ASCII logo on first installation.
133
+
134
+ Shows a side-by-side layout: the hand-crafted ASCII art logo on the left
135
+ and a rich info card with usage instructions on the right.
136
+
137
+ The card content is automatically rendered in the user's detected language
138
+ (French or English) via the ``src.i18n`` module.
139
+
140
+ Args:
141
+ force: If ``True``, display the banner regardless of whether it has been
142
+ shown before (useful for ``ezsh --logo``). Defaults to ``False``.
143
+ """
144
+ if not force and not is_first_install():
145
+ return
146
+
147
+ # Lazy import to avoid circular imports at module load time
148
+ from src.i18n import t # noqa: PLC0415
149
+
150
+ console = Console()
151
+ console.print()
152
+
153
+ # Choose logo size based on terminal width
154
+ term_width = console.width or 80
155
+ if term_width >= 90:
156
+ logo_lines = render_logo_ascii()
157
+ else:
158
+ logo_lines = render_logo_mini()
159
+
160
+ logo_block = "\n".join(logo_lines)
161
+
162
+ # Build bilingual info card using i18n strings
163
+ info_card = f"""[bold #00E5FF]{t("welcome_title")}[/bold #00E5FF]
164
+ [dim #94A3B8]{t("welcome_subtitle")}[/dim #94A3B8]
165
+
166
+ [bold #FFFFFF]{t("welcome_body")}[/bold #FFFFFF]
167
+
168
+ [bold #00FF88]{t("welcome_how_to_use")}[/bold #00FF88]
169
+ [bold #00E5FF]ezsh[/bold #00E5FF] {t("welcome_cmd_desktop")}
170
+ [bold #00E5FF]ezsh --tui[/bold #00E5FF] {t("welcome_cmd_tui")}
171
+ [bold #00E5FF]ezsh --set-key[/bold #00E5FF] {t("welcome_cmd_setkey")}
172
+ [bold #00E5FF]ezsh -c <cmd>[/bold #00E5FF] {t("welcome_cmd_single")}
173
+ [bold #00E5FF]ezsh --help[/bold #00E5FF] {t("welcome_cmd_help")}
174
+
175
+ [italic #94A3B8]{t("welcome_footer")}[/italic #94A3B8]
176
+ """
177
+
178
+ grid = Table.grid(padding=(0, 3))
179
+ grid.add_column(justify="center", vertical="middle")
180
+ grid.add_column(vertical="middle")
181
+
182
+ grid.add_row(
183
+ Text.from_ansi(logo_block),
184
+ Panel(
185
+ info_card.strip(),
186
+ border_style="#00E5FF",
187
+ padding=(1, 2),
188
+ ),
189
+ )
190
+
191
+ console.print(grid)
192
+ console.print()
193
+ mark_as_installed()
194
+
src/ui/panel.py ADDED
@@ -0,0 +1,163 @@
1
+ """Panneau inline Rich (DEVELOPMENT.md §7.5) : un rendu par état, 80 colonnes max.
2
+
3
+ Le risque est TOUJOURS indiqué par un libellé texte (jamais par la seule couleur).
4
+ Tout le texte venant du modèle est déjà nettoyé (schema.py / sanitize.py) ; on désactive
5
+ en plus le markup Rich pour qu'un texte du modèle ne puisse pas styler l'écran.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import List
11
+
12
+ from rich.console import Group
13
+ from rich.panel import Panel
14
+ from rich.text import Text
15
+
16
+ MAX_WIDTH = 80
17
+
18
+ T = {
19
+ "fr": {
20
+ "title": "ezShell",
21
+ "cause": "Cause",
22
+ "assume": "Hypothèses",
23
+ "conf": "Confiance",
24
+ "risk": {"low": "RISQUE FAIBLE", "medium": "RISQUE MOYEN", "high": "RISQUE ÉLEVÉ"},
25
+ "confv": {"low": "faible", "medium": "moyenne", "high": "élevée"},
26
+ "keys": "⏎ insérer · c copier · e éditer · / question · q quitter",
27
+ "keys_high": "y insérer quand même · c copier · e éditer · / question · q quitter",
28
+ "keys_multi": "y insérer (multiligne) · c copier · / question · q quitter",
29
+ "keys_none": "/ poser une question · q quitter",
30
+ "loading": "Analyse en cours…",
31
+ "floor": "Risque relevé par les règles de sécurité d'ezShell.",
32
+ "nooutput": "Sortie d'erreur non capturée : lance `ezsh run -- <commande>` pour de meilleurs diagnostics.",
33
+ "uncertain": "Réponse incertaine : vérifie avant d'agir.",
34
+ "multi": "Commande sur plusieurs lignes : rien ne sera exécuté à l'insertion.",
35
+ "nolast": "Aucune commande récente. Pose une question en langage naturel.",
36
+ "offline": "Hors ligne : impossible de joindre le service. Le terminal n'est pas bloqué.",
37
+ "quota": "Limite atteinte ({scope}). Réinitialisation : {reset}.",
38
+ "trial": "Limite d'essai atteinte. Crée un compte gratuit pour continuer (`ezsh login`, bientôt).",
39
+ "update_required": "Cette version d'ezShell n'est plus prise en charge. Lance `ezsh --update`.",
40
+ "pending": "Connexion en attente : valide le code {code} sur {url}.",
41
+ "error": "Le service est indisponible pour le moment. Réessaie dans un instant.",
42
+ "invalid": "Réponse du modèle illisible : rien n'est affiché ni inséré.",
43
+ "nokey": "Aucune clé configurée. `ezsh --set-key <clé>` ou EZSH_MOCK_LLM=1 pour tester sans réseau.",
44
+ "ask": "Ta question",
45
+ "edit": "Édite puis ⏎ (Échap : annuler)",
46
+ "copied": "Copié dans le presse-papiers.",
47
+ "nocopy": "Aucun outil de presse-papiers trouvé.",
48
+ "inserted": "Commande insérée : relis-la puis appuie sur Entrée.",
49
+ },
50
+ "en": {
51
+ "title": "ezShell",
52
+ "cause": "Cause",
53
+ "assume": "Assumptions",
54
+ "conf": "Confidence",
55
+ "risk": {"low": "LOW RISK", "medium": "MEDIUM RISK", "high": "HIGH RISK"},
56
+ "confv": {"low": "low", "medium": "medium", "high": "high"},
57
+ "keys": "⏎ insert · c copy · e edit · / ask · q quit",
58
+ "keys_high": "y insert anyway · c copy · e edit · / ask · q quit",
59
+ "keys_multi": "y insert (multi-line) · c copy · / ask · q quit",
60
+ "keys_none": "/ ask a question · q quit",
61
+ "loading": "Analyzing…",
62
+ "floor": "Risk raised by ezShell's safety rules.",
63
+ "nooutput": "Error output not captured: run `ezsh run -- <command>` for better diagnostics.",
64
+ "uncertain": "Uncertain answer: double-check before acting.",
65
+ "multi": "Multi-line command: nothing is executed on insertion.",
66
+ "nolast": "No recent command. Ask a question in plain language.",
67
+ "offline": "Offline: the service is unreachable. Your terminal is not blocked.",
68
+ "quota": "Limit reached ({scope}). Resets: {reset}.",
69
+ "trial": "Trial limit reached. Create a free account to continue (`ezsh login`, coming soon).",
70
+ "update_required": "This ezShell version is no longer supported. Run `ezsh --update`.",
71
+ "pending": "Login pending: confirm code {code} at {url}.",
72
+ "error": "The service is unavailable right now. Try again shortly.",
73
+ "invalid": "Unreadable model answer: nothing is shown or inserted.",
74
+ "nokey": "No key configured. `ezsh --set-key <key>` or EZSH_MOCK_LLM=1 to try offline.",
75
+ "ask": "Your question",
76
+ "edit": "Edit then ⏎ (Esc: cancel)",
77
+ "copied": "Copied to clipboard.",
78
+ "nocopy": "No clipboard tool found.",
79
+ "inserted": "Command inserted: review it, then press Enter.",
80
+ },
81
+ }
82
+
83
+ _RISK_STYLE = {"low": "bold green", "medium": "bold yellow", "high": "bold white on red"}
84
+ _BORDER = {"low": "green", "medium": "yellow", "high": "red"}
85
+
86
+
87
+ def tr(lang: str) -> dict:
88
+ return T.get(lang, T["en"])
89
+
90
+
91
+ def _wrap(body: List, title: str, border: str, footer: str) -> Panel:
92
+ return Panel(
93
+ Group(*body),
94
+ title=title,
95
+ title_align="left",
96
+ subtitle=footer,
97
+ subtitle_align="left",
98
+ border_style=border,
99
+ width=None,
100
+ padding=(0, 1),
101
+ )
102
+
103
+
104
+ def result_panel(s, lang: str) -> Panel:
105
+ """`s` : suggest.Suggestion."""
106
+ t = tr(lang)
107
+ body: List = []
108
+ if s.command:
109
+ cmd = Text("$ ", style="bold cyan")
110
+ cmd.append(s.command, style="bold")
111
+ body.append(cmd)
112
+ label = Text(f" {t['risk'][s.risk]} ", style=_RISK_STYLE[s.risk])
113
+ if s.risk_reason:
114
+ label.append(f" {s.risk_reason}", style="default")
115
+ body.append(label)
116
+ if s.risk_floor_applied:
117
+ body.append(Text(t["floor"], style="dim italic"))
118
+ if s.response.explanation:
119
+ body.append(Text(s.response.explanation))
120
+ if s.response.cause:
121
+ body.append(Text(f"{t['cause']} : {s.response.cause}", style="dim"))
122
+ uncertain = s.response.confidence == "low" or not s.command
123
+ if uncertain:
124
+ body.append(Text(f"⚠ {t['uncertain']}", style="yellow"))
125
+ if s.response.assumptions:
126
+ body.append(Text(f"{t['assume']} : " + " · ".join(s.response.assumptions), style="dim"))
127
+ body.append(Text(f"{t['conf']} : {t['confv'][s.response.confidence]}", style="dim"))
128
+ if s.multiline:
129
+ body.append(Text(f"⚠ {t['multi']}", style="yellow"))
130
+ if s.no_output_captured:
131
+ body.append(Text(f"ℹ {t['nooutput']}", style="dim"))
132
+ if not s.command:
133
+ footer = t["keys_none"]
134
+ elif s.multiline:
135
+ footer = t["keys_multi"]
136
+ elif s.risk == "high":
137
+ footer = t["keys_high"]
138
+ else:
139
+ footer = t["keys"]
140
+ return _wrap(body, t["title"], _BORDER[s.risk], footer)
141
+
142
+
143
+ def message_panel(kind: str, lang: str, **kw) -> Panel:
144
+ """États sans résultat : empty, offline, quota, trial, pending, error, invalid, nokey, update_required."""
145
+ t = tr(lang)
146
+ text = {
147
+ "empty": t["nolast"],
148
+ "offline": t["offline"],
149
+ "trial": t["trial"],
150
+ "error": t["error"],
151
+ "invalid": t["invalid"],
152
+ "nokey": t["nokey"],
153
+ "update_required": t["update_required"],
154
+ "quota": t["quota"].format(scope=kw.get("scope", "?"), reset=kw.get("reset") or "?"),
155
+ "pending": t["pending"].format(code=kw.get("code", "????"), url=kw.get("url", "")),
156
+ }[kind]
157
+ border = "cyan" if kind in ("empty", "pending") else "yellow"
158
+ footer = t["keys_none"] if kind in ("empty", "offline", "error", "invalid", "nokey", "update_required") else "q"
159
+ return _wrap([Text(text)], t["title"], border, footer)
160
+
161
+
162
+ def loading_text(lang: str) -> str:
163
+ return tr(lang)["loading"]