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.
- ezshell-0.2.0.dist-info/METADATA +174 -0
- ezshell-0.2.0.dist-info/RECORD +37 -0
- ezshell-0.2.0.dist-info/WHEEL +5 -0
- ezshell-0.2.0.dist-info/entry_points.txt +3 -0
- ezshell-0.2.0.dist-info/licenses/LICENSE +21 -0
- ezshell-0.2.0.dist-info/top_level.txt +1 -0
- src/__init__.py +3 -0
- src/app.py +286 -0
- src/core/__init__.py +0 -0
- src/core/assistant.py +525 -0
- src/core/capture.py +213 -0
- src/core/chat_bridge.py +107 -0
- src/core/config.py +238 -0
- src/core/context.py +229 -0
- src/core/groq_client.py +376 -0
- src/core/llm_client.py +111 -0
- src/core/prompts.py +262 -0
- src/core/provider.py +233 -0
- src/core/proxy_client.py +166 -0
- src/core/pty_shell.py +273 -0
- src/core/redact.py +95 -0
- src/core/risk.py +455 -0
- src/core/safety.py +268 -0
- src/core/sanitize.py +39 -0
- src/core/schema.py +65 -0
- src/core/shell_hook.py +388 -0
- src/core/suggest.py +94 -0
- src/core/updater.py +428 -0
- src/i18n.py +422 -0
- src/main.py +346 -0
- src/ui/chat_tui.py +634 -0
- src/ui/gui_window.py +1018 -0
- src/ui/inline.py +330 -0
- src/ui/logo.py +194 -0
- src/ui/panel.py +163 -0
- src/ui/prompt.py +184 -0
- src/ui/render.py +269 -0
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"]
|