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/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)
|