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/chat_tui.py ADDED
@@ -0,0 +1,634 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from textual import events, work
9
+ from textual.app import App, ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.containers import Container, Horizontal, VerticalScroll
12
+ from textual.widgets import (
13
+ Button,
14
+ Input,
15
+ Label,
16
+ Markdown,
17
+ Static,
18
+ )
19
+
20
+ from src.core import chat_bridge
21
+ from src.core.assistant import ChatMessageResult
22
+ from src.core.config import EzShellConfig
23
+ from src.core.context import SystemContext
24
+ from src.core.provider import GroqProvider, get_provider
25
+ from src.core.pty_shell import PTYExecutor
26
+ from src.core.safety import RiskLevel, SafetyReport
27
+
28
+ CHAT_CSS = """
29
+ Screen {
30
+ align: center middle;
31
+ background: #060913 85%;
32
+ }
33
+
34
+ #dialog-container {
35
+ width: 88%;
36
+ max-width: 108;
37
+ height: 86%;
38
+ max-height: 38;
39
+ background: #0B0F19;
40
+ border: round #00E5FF;
41
+ border-title-color: #00E5FF;
42
+ border-title-style: bold;
43
+ border-title-align: left;
44
+ border-subtitle-color: #94A3B8;
45
+ border-subtitle-align: right;
46
+ padding: 0 1;
47
+ }
48
+
49
+ #context-bar {
50
+ height: 1;
51
+ margin: 0;
52
+ padding: 0 1;
53
+ color: #94A3B8;
54
+ border-bottom: solid #1E293B;
55
+ }
56
+
57
+ #chat-scroll {
58
+ height: 1fr;
59
+ scrollbar-color: #00E5FF #0F172A;
60
+ scrollbar-size: 1 1;
61
+ padding: 1 0;
62
+ }
63
+
64
+ .welcome-card {
65
+ background: #0D1527;
66
+ border: round #00E5FF;
67
+ border-title-color: #00E5FF;
68
+ border-title-style: bold;
69
+ padding: 1;
70
+ margin-bottom: 1;
71
+ }
72
+
73
+ .welcome-desc {
74
+ color: #FFFFFF;
75
+ }
76
+
77
+ .welcome-examples {
78
+ color: #FACC15;
79
+ margin-top: 1;
80
+ }
81
+
82
+ .user-bubble {
83
+ background: #111A2E;
84
+ border: round #00E5FF;
85
+ border-title-color: #00E5FF;
86
+ border-title-style: bold;
87
+ padding: 0 1;
88
+ margin-bottom: 1;
89
+ }
90
+
91
+ .user-text {
92
+ color: #FFFFFF;
93
+ padding: 0;
94
+ }
95
+
96
+ .ai-bubble {
97
+ background: #0C1222;
98
+ border: round #C084FC;
99
+ border-title-color: #C084FC;
100
+ border-title-style: bold;
101
+ padding: 0 1;
102
+ margin-bottom: 1;
103
+ }
104
+
105
+ .loading-tag {
106
+ color: #C084FC;
107
+ text-style: bold;
108
+ margin: 0 1;
109
+ }
110
+
111
+ .cmd-container {
112
+ background: #020617;
113
+ border: round #00FF88;
114
+ border-title-color: #00FF88;
115
+ border-title-style: bold;
116
+ padding: 0 1;
117
+ margin-top: 1;
118
+ }
119
+
120
+ .cmd-container-danger {
121
+ background: #020617;
122
+ border: round #FF2A6D;
123
+ border-title-color: #FF2A6D;
124
+ border-title-style: bold;
125
+ padding: 0 1;
126
+ margin-top: 1;
127
+ }
128
+
129
+ .cmd-code {
130
+ color: #00FF88;
131
+ text-style: bold;
132
+ background: #0F172A;
133
+ padding: 0 1;
134
+ margin: 1 0;
135
+ }
136
+
137
+ .cmd-btn-row {
138
+ height: auto;
139
+ margin-top: 1;
140
+ margin-bottom: 0;
141
+ }
142
+
143
+ .run-btn {
144
+ background: #00FF88;
145
+ color: #0B0F19;
146
+ text-style: bold;
147
+ border: round #00FF88;
148
+ margin-right: 1;
149
+ }
150
+
151
+ .run-btn-danger {
152
+ background: #FF2A6D;
153
+ color: #FFFFFF;
154
+ text-style: bold;
155
+ border: round #FF2A6D;
156
+ margin-right: 1;
157
+ }
158
+
159
+ .copy-btn {
160
+ background: #1E293B;
161
+ color: #94A3B8;
162
+ border: round #334155;
163
+ }
164
+
165
+ .exec-result {
166
+ background: #050D1A;
167
+ border: round #00FF88;
168
+ border-title-color: #00FF88;
169
+ border-title-style: bold;
170
+ padding: 0 1;
171
+ margin-top: 1;
172
+ }
173
+
174
+ .exec-result-err {
175
+ background: #14070D;
176
+ border: round #FF2A6D;
177
+ border-title-color: #FF2A6D;
178
+ border-title-style: bold;
179
+ padding: 0 1;
180
+ margin-top: 1;
181
+ }
182
+
183
+ .exec-output {
184
+ color: #94A3B8;
185
+ margin-top: 0;
186
+ }
187
+
188
+ #input-bar {
189
+ height: 3;
190
+ margin-top: 1;
191
+ align-vertical: middle;
192
+ }
193
+
194
+ #chat-input {
195
+ width: 1fr;
196
+ background: #111A2E;
197
+ color: #FFFFFF;
198
+ border: round #00E5FF;
199
+ }
200
+
201
+ #chat-input:focus {
202
+ border: round #C084FC;
203
+ }
204
+
205
+ #send-btn {
206
+ width: 12;
207
+ background: #00E5FF;
208
+ color: #0B0F19;
209
+ text-style: bold;
210
+ border: round #00E5FF;
211
+ margin-left: 1;
212
+ }
213
+
214
+ #shortcuts-bar {
215
+ height: 1;
216
+ color: #94A3B8;
217
+ padding: 0 1;
218
+ margin-top: 1;
219
+ }
220
+
221
+ .api-key-box {
222
+ background: #111A2E;
223
+ border: round #FFB800;
224
+ border-title-color: #FFB800;
225
+ border-title-style: bold;
226
+ padding: 1;
227
+ margin: 1 0;
228
+ }
229
+ """
230
+
231
+
232
+ class DraggableDialog(Container):
233
+ """Draggable modal dialog container supporting smooth mouse dragging and keyboard nudging."""
234
+
235
+ def __init__(self, *children, **kwargs):
236
+ super().__init__(*children, **kwargs)
237
+ self._dragging = False
238
+ self._start_x: float = 0.0
239
+ self._start_y: float = 0.0
240
+ self._offset_x: int = 0
241
+ self._offset_y: int = 0
242
+
243
+ def on_mouse_down(self, event: events.MouseDown) -> None:
244
+ # Drag initiated from the top 2 rows (header border and context bar)
245
+ if event.y <= 2:
246
+ self._dragging = True
247
+ self._start_x = event.screen_x
248
+ self._start_y = event.screen_y
249
+ self.capture_mouse()
250
+ event.stop()
251
+
252
+ def on_mouse_move(self, event: events.MouseMove) -> None:
253
+ if self._dragging:
254
+ dx = int(event.screen_x - self._start_x)
255
+ dy = int(event.screen_y - self._start_y)
256
+ self.styles.offset = (self._offset_x + dx, self._offset_y + dy)
257
+ event.stop()
258
+
259
+ def on_mouse_up(self, event: events.MouseUp) -> None:
260
+ if self._dragging:
261
+ dx = int(event.screen_x - self._start_x)
262
+ dy = int(event.screen_y - self._start_y)
263
+ self._offset_x += dx
264
+ self._offset_y += dy
265
+ self.styles.offset = (self._offset_x, self._offset_y)
266
+ self._dragging = False
267
+ self.release_mouse()
268
+ event.stop()
269
+
270
+ def move_by(self, dx: int, dy: int) -> None:
271
+ """Moves the dialog relative to its current position."""
272
+ self._offset_x += dx
273
+ self._offset_y += dy
274
+ self.styles.offset = (self._offset_x, self._offset_y)
275
+
276
+ def reset_position(self) -> None:
277
+ """Centers the dialog back to its original location."""
278
+ self._offset_x = 0
279
+ self._offset_y = 0
280
+ self.styles.offset = (0, 0)
281
+
282
+
283
+ class CommandBox(Static):
284
+ """Interactive command snippet box with rounded corners and Run & Copy actions."""
285
+
286
+ def __init__(self, command: str, safety_report: Optional[SafetyReport] = None, **kwargs):
287
+ super().__init__(**kwargs)
288
+ self.command = command
289
+ self.safety_report = safety_report
290
+
291
+ def compose(self) -> ComposeResult:
292
+ is_dangerous = (
293
+ self.safety_report is not None and self.safety_report.risk_level == RiskLevel.DANGEROUS
294
+ )
295
+ box_class = "cmd-container-danger" if is_dangerous else "cmd-container"
296
+ box_title = (
297
+ f" [!] DANGEROUS: {self.safety_report.reason} "
298
+ if is_dangerous
299
+ else " Suggested Command "
300
+ )
301
+
302
+ with Container(classes=box_class) as c:
303
+ c.border_title = box_title
304
+ yield Static(f" {self.command}", classes="cmd-code")
305
+
306
+ with Horizontal(classes="cmd-btn-row"):
307
+ btn_class = "run-btn-danger" if is_dangerous else "run-btn"
308
+ btn_label = "Confirm & Run" if is_dangerous else "Run Command"
309
+ yield Button(btn_label, classes=f"{btn_class} cmd-action-run")
310
+ yield Button("Copy to Input", classes="copy-btn cmd-action-copy")
311
+
312
+ def on_button_pressed(self, event: Button.Pressed) -> None:
313
+ app = self.app
314
+ if event.button.has_class("cmd-action-run"):
315
+ if isinstance(app, EzShellChatApp):
316
+ app.run_suggested_command(self.command)
317
+ elif event.button.has_class("cmd-action-copy"):
318
+ if isinstance(app, EzShellChatApp):
319
+ app.copy_command_to_input(self.command)
320
+
321
+
322
+ class UserBubble(Static):
323
+ """User message bubble with rounded cyan border and embedded title."""
324
+
325
+ def __init__(self, text: str, **kwargs):
326
+ super().__init__(**kwargs)
327
+ self.text = text
328
+
329
+ def compose(self) -> ComposeResult:
330
+ with Container(classes="user-bubble") as c:
331
+ c.border_title = " › You "
332
+ yield Static(self.text, classes="user-text")
333
+
334
+
335
+ class AssistantBubble(Static):
336
+ """Assistant response bubble with rounded purple border, markdown explanation and commands."""
337
+
338
+ def __init__(self, result: ChatMessageResult, **kwargs):
339
+ super().__init__(**kwargs)
340
+ self.result = result
341
+
342
+ def compose(self) -> ComposeResult:
343
+ with Container(classes="ai-bubble") as c:
344
+ c.border_title = " › ezShell "
345
+ yield Markdown(self.result.content)
346
+ for cmd, safety in zip(self.result.suggested_commands, self.result.safety_reports):
347
+ yield CommandBox(command=cmd, safety_report=safety)
348
+
349
+
350
+ class ExecutionResultBubble(Static):
351
+ """Displays command execution output in a rounded container."""
352
+
353
+ def __init__(self, command: str, exit_code: int, output: str, **kwargs):
354
+ super().__init__(**kwargs)
355
+ self.command = command
356
+ self.exit_code = exit_code
357
+ self.output = output
358
+
359
+ def compose(self) -> ComposeResult:
360
+ is_ok = self.exit_code == 0
361
+ box_class = "exec-result" if is_ok else "exec-result-err"
362
+ box_title = (
363
+ f" Output (Exit: 0): `{self.command}` "
364
+ if is_ok
365
+ else f" Output (Failed: {self.exit_code}): `{self.command}` "
366
+ )
367
+ with Container(classes=box_class) as c:
368
+ c.border_title = box_title
369
+ yield Static(
370
+ self.output.strip() or "(No output)",
371
+ classes="exec-output",
372
+ )
373
+
374
+
375
+ class ErrorBubble(Static):
376
+ """Displays error notice in a rounded container."""
377
+
378
+ def __init__(self, title: str, message: str, **kwargs):
379
+ super().__init__(**kwargs)
380
+ self.title = title
381
+ self.message = message
382
+
383
+ def compose(self) -> ComposeResult:
384
+ with Container(classes="api-key-box") as c:
385
+ c.border_title = f" {self.title} "
386
+ yield Static(self.message, classes="user-text")
387
+
388
+
389
+ class EzShellChatApp(App):
390
+ """Floating draggable modal TUI chat popup powered by Groq LLM and Textual."""
391
+
392
+ CSS = CHAT_CSS
393
+ TITLE = "ezShell Popup"
394
+
395
+ BINDINGS = [
396
+ Binding("escape", "quit_app", "Close", show=True),
397
+ Binding("ctrl+c", "quit_app", "Quit", show=False),
398
+ Binding("ctrl+l", "clear_chat", "Clear", show=True),
399
+ Binding("ctrl+r", "run_latest_command", "Run Latest", show=True),
400
+ Binding("alt+left", "move_left", "Move Left", show=False),
401
+ Binding("alt+right", "move_right", "Move Right", show=False),
402
+ Binding("alt+up", "move_up", "Move Up", show=False),
403
+ Binding("alt+down", "move_down", "Move Down", show=False),
404
+ Binding("alt+home", "reset_position", "Center", show=False),
405
+ ]
406
+
407
+ def __init__(self, config: Optional[EzShellConfig] = None):
408
+ super().__init__()
409
+ self.config = config or EzShellConfig.load()
410
+ self.context = SystemContext.collect()
411
+ self.provider = get_provider(self.config)
412
+ self.latest_command: Optional[str] = None
413
+
414
+ def _needs_key(self) -> bool:
415
+ """Clé locale requise seulement en appel direct à Groq (le proxy n'en a pas besoin)."""
416
+ return isinstance(self.provider, GroqProvider) and not self.provider.api_key
417
+
418
+ def compose(self) -> ComposeResult:
419
+ with DraggableDialog(id="dialog-container") as dlg:
420
+ # 1. Embedded rounded border title and movable hints
421
+ dlg.border_title = f" {chat_bridge.provider_label(self.provider, self.config)} "
422
+ dlg.border_subtitle = " [Drag / Alt+Arrows] Move • [Esc] Close "
423
+
424
+ # 2. Context line
425
+ cwd = self.context.current_directory
426
+ home = str(Path.home())
427
+ if cwd.startswith(home):
428
+ cwd = "~" + cwd[len(home) :]
429
+ branch_info = f" • ⎇ {self.context.git_branch}" if self.context.git_branch else ""
430
+ yield Static(
431
+ f"cwd: {cwd}{branch_info} • shell: {self.context.shell_name}",
432
+ id="context-bar",
433
+ )
434
+
435
+ # 3. Chat scrollable area
436
+ with VerticalScroll(id="chat-scroll"):
437
+ if self._needs_key():
438
+ with Container(classes="api-key-box") as box:
439
+ box.border_title = " GROQ_API_KEY Required "
440
+ yield Static(
441
+ "ezShell requires a Groq API key to activate instant AI diagnostics & chat.\n"
442
+ "Get a free key at https://console.groq.com and paste it below:",
443
+ classes="welcome-desc",
444
+ )
445
+ with Horizontal(classes="cmd-btn-row"):
446
+ yield Input(
447
+ placeholder="gsk_...",
448
+ password=True,
449
+ id="api-key-input",
450
+ )
451
+ yield Button("Save Key", id="save-key-btn", classes="run-btn")
452
+ else:
453
+ with Container(classes="welcome-card") as wc:
454
+ wc.border_title = " ezShell AI Terminal Assistant "
455
+ yield Static(
456
+ "Ask questions, explain code or errors, or describe any task you need executed.",
457
+ classes="welcome-desc",
458
+ )
459
+ yield Static(
460
+ "Examples:\n"
461
+ ' › "How do I find all processes using port 8000?"\n'
462
+ ' › "Show disk usage for current folder in human-readable format"\n'
463
+ ' › "Revert last commit but keep modified files"\n'
464
+ ' › "Clean up unused docker containers and dangling images"',
465
+ classes="welcome-examples",
466
+ )
467
+
468
+ # 4. Input bar
469
+ with Horizontal(id="input-bar"):
470
+ yield Input(
471
+ placeholder="Ask ezShell anything, or describe a command to run... (Enter to send)",
472
+ id="chat-input",
473
+ )
474
+ yield Button("Send ›", id="send-btn")
475
+
476
+ # 5. Shortcuts footer
477
+ with Horizontal(id="shortcuts-bar"):
478
+ yield Static(
479
+ " [Enter] Send [Ctrl+R] Run Latest [Alt+Arrows / Drag] Move [Ctrl+L] Clear [Esc] Close ",
480
+ )
481
+
482
+ def on_mount(self) -> None:
483
+ """Focus input on mount."""
484
+ inp = self.query_one("#chat-input", Input)
485
+ inp.focus()
486
+
487
+ def action_quit_app(self) -> None:
488
+ """Exit the popup modal."""
489
+ self.exit()
490
+
491
+ def action_clear_chat(self) -> None:
492
+ """Clears the chat screen and resets conversation history."""
493
+ scroll = self.query_one("#chat-scroll", VerticalScroll)
494
+ scroll.remove_children()
495
+
496
+ scroll.mount(
497
+ Static(
498
+ "Conversation history reset. You can ask a new question:", classes="welcome-desc"
499
+ )
500
+ )
501
+
502
+ def action_run_latest_command(self) -> None:
503
+ """Runs the most recent command suggested by AI."""
504
+ if self.latest_command:
505
+ self.run_suggested_command(self.latest_command)
506
+
507
+ def action_move_left(self) -> None:
508
+ self.query_one(DraggableDialog).move_by(-3, 0)
509
+
510
+ def action_move_right(self) -> None:
511
+ self.query_one(DraggableDialog).move_by(3, 0)
512
+
513
+ def action_move_up(self) -> None:
514
+ self.query_one(DraggableDialog).move_by(0, -2)
515
+
516
+ def action_move_down(self) -> None:
517
+ self.query_one(DraggableDialog).move_by(0, 2)
518
+
519
+ def action_reset_position(self) -> None:
520
+ self.query_one(DraggableDialog).reset_position()
521
+
522
+ def on_button_pressed(self, event: Button.Pressed) -> None:
523
+ """Handle send button or save key button."""
524
+ if event.button.id == "send-btn":
525
+ self.submit_message()
526
+ elif event.button.id == "save-key-btn":
527
+ self.save_api_key()
528
+
529
+ def on_input_submitted(self, event: Input.Submitted) -> None:
530
+ """Handle Enter in input."""
531
+ if event.input.id == "chat-input":
532
+ self.submit_message()
533
+ elif event.input.id == "api-key-input":
534
+ self.save_api_key()
535
+
536
+ def save_api_key(self) -> None:
537
+ """Saves Groq API key entered in popup UI."""
538
+ key_input = self.query_one("#api-key-input", Input)
539
+ val = key_input.value.strip()
540
+ if not val:
541
+ return
542
+
543
+ self.config.groq_api_key = val
544
+ self.config.save()
545
+ self.provider = get_provider(self.config)
546
+
547
+ # Refresh scroll area
548
+ scroll = self.query_one("#chat-scroll", VerticalScroll)
549
+ scroll.remove_children()
550
+ scroll.mount(
551
+ Static("✔ Groq API Key Activated! AI assistant is now ready.", classes="welcome-desc")
552
+ )
553
+ self.query_one("#chat-input", Input).focus()
554
+
555
+ def submit_message(self) -> None:
556
+ """Sends user message and requests AI response."""
557
+ inp = self.query_one("#chat-input", Input)
558
+ user_text = inp.value.strip()
559
+ if not user_text:
560
+ return
561
+
562
+ # Check special exit commands
563
+ if user_text.lower() in ("exit", "quit", ":q"):
564
+ self.exit()
565
+ return
566
+
567
+ inp.value = ""
568
+
569
+ # Check API key
570
+ if self._needs_key():
571
+ self.post_system_notice("GROQ_API_KEY is not configured. Please configure it to chat.")
572
+ return
573
+
574
+ scroll = self.query_one("#chat-scroll", VerticalScroll)
575
+
576
+ # Mount user bubble
577
+ scroll.mount(UserBubble(user_text))
578
+ scroll.scroll_end(animate=False)
579
+
580
+ # Mount loading tag
581
+ loading_label = Label("› ezShell thinking...", classes="loading-tag", id="loading-tag")
582
+ scroll.mount(loading_label)
583
+ scroll.scroll_end(animate=False)
584
+
585
+ # Call AI in background worker
586
+ self._async_fetch_ai_reply(user_text, loading_label)
587
+
588
+ @work
589
+ async def _async_fetch_ai_reply(self, user_text: str, loading_widget: Label) -> None:
590
+ """Worker thread to fetch LLM response without blocking the UI."""
591
+ try:
592
+ result = await asyncio.to_thread(chat_bridge.ask, user_text, provider=self.provider)
593
+ self._render_ai_reply(result, loading_widget)
594
+ except Exception as e:
595
+ self._render_ai_error(chat_bridge.describe_error(e), loading_widget)
596
+
597
+ def _render_ai_reply(self, result: ChatMessageResult, loading_widget: Label) -> None:
598
+ """Renders AI message inside the chat scroll container."""
599
+ loading_widget.remove()
600
+ scroll = self.query_one("#chat-scroll", VerticalScroll)
601
+
602
+ if result.suggested_commands:
603
+ self.latest_command = result.suggested_commands[-1]
604
+
605
+ scroll.mount(AssistantBubble(result))
606
+ scroll.scroll_end(animate=False)
607
+
608
+ def _render_ai_error(self, error_msg: str, loading_widget: Label) -> None:
609
+ """Renders error if Groq call failed."""
610
+ loading_widget.remove()
611
+ scroll = self.query_one("#chat-scroll", VerticalScroll)
612
+ scroll.mount(ErrorBubble("Error consulting Groq API", error_msg))
613
+ scroll.scroll_end(animate=False)
614
+
615
+ def run_suggested_command(self, cmd: str) -> None:
616
+ """Executes a suggested command directly from the popup and displays the output."""
617
+ scroll = self.query_one("#chat-scroll", VerticalScroll)
618
+
619
+ # Execute command via PTYExecutor
620
+ result = PTYExecutor.execute(cmd, cwd=os.getcwd(), forward_output=False)
621
+ scroll.mount(ExecutionResultBubble(cmd, result.exit_code, result.clean_output))
622
+ scroll.scroll_end(animate=False)
623
+
624
+ def copy_command_to_input(self, cmd: str) -> None:
625
+ """Pastes the command into the chat input field."""
626
+ inp = self.query_one("#chat-input", Input)
627
+ inp.value = cmd
628
+ inp.focus()
629
+
630
+ def post_system_notice(self, msg: str) -> None:
631
+ """Posts a system notice into chat."""
632
+ scroll = self.query_one("#chat-scroll", VerticalScroll)
633
+ scroll.mount(Static(f"[!] {msg}", classes="user-text"))
634
+ scroll.scroll_end(animate=False)