devpilot-agentic-cli 1.0.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.
- agent/__init__.py +1 -0
- agent/a2a_client.py +94 -0
- agent/a2a_server.py +148 -0
- agent/cli.py +233 -0
- agent/config.py +232 -0
- agent/context.py +182 -0
- agent/history.py +172 -0
- agent/loop.py +102 -0
- agent/mcp_client.py +104 -0
- agent/providers/__init__.py +4 -0
- agent/providers/anthropic_provider.py +169 -0
- agent/providers/base.py +148 -0
- agent/providers/factory.py +35 -0
- agent/providers/openai_provider.py +194 -0
- agent/providers/system_prompt.py +132 -0
- agent/setup_wizard.py +309 -0
- agent/tools/__init__.py +15 -0
- agent/tools/a2a.py +56 -0
- agent/tools/base.py +52 -0
- agent/tools/diagram.py +131 -0
- agent/tools/doc_gen.py +163 -0
- agent/tools/fs.py +411 -0
- agent/tools/git_ops.py +145 -0
- agent/tools/registry.py +219 -0
- agent/tools/search_code.py +120 -0
- agent/tools/shell.py +118 -0
- agent/tools/web_search.py +105 -0
- agent/tui/__init__.py +3 -0
- agent/tui/app.py +557 -0
- agent/ui.py +263 -0
- devpilot_agentic_cli-1.0.0.dist-info/METADATA +288 -0
- devpilot_agentic_cli-1.0.0.dist-info/RECORD +35 -0
- devpilot_agentic_cli-1.0.0.dist-info/WHEEL +5 -0
- devpilot_agentic_cli-1.0.0.dist-info/entry_points.txt +2 -0
- devpilot_agentic_cli-1.0.0.dist-info/top_level.txt +1 -0
agent/tui/app.py
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent/tui/app.py
|
|
3
|
+
────────────────
|
|
4
|
+
Textual TUI for DevPilot.
|
|
5
|
+
Provides a premium, full-screen terminal IDE experience.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from textual.app import App, ComposeResult
|
|
13
|
+
from textual.containers import Horizontal, Vertical, ScrollableContainer, VerticalScroll
|
|
14
|
+
from textual.widgets import Header, Footer, Input, RichLog, Button, Label, Tree, Static, LoadingIndicator
|
|
15
|
+
from textual.widgets.tree import TreeNode
|
|
16
|
+
from textual.screen import ModalScreen
|
|
17
|
+
from textual.events import MouseDown, MouseUp, MouseMove
|
|
18
|
+
from textual.reactive import reactive
|
|
19
|
+
from textual import work, on
|
|
20
|
+
|
|
21
|
+
from rich.markdown import Markdown
|
|
22
|
+
from rich.syntax import Syntax
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
|
|
25
|
+
from agent.ui import (
|
|
26
|
+
UI, UIEvent, AssistantMessageEvent, StreamTokenEvent, ToolCallEvent,
|
|
27
|
+
ToolResultEvent, ErrorEvent, InfoEvent, SuccessEvent, DiffEvent, ThinkingEvent,
|
|
28
|
+
)
|
|
29
|
+
from agent.loop import run_agent_loop
|
|
30
|
+
|
|
31
|
+
CSS = """
|
|
32
|
+
Screen {
|
|
33
|
+
background: #1e1e1e;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#main-container {
|
|
37
|
+
height: 100%;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#project-map {
|
|
41
|
+
width: 25%;
|
|
42
|
+
background: #181818;
|
|
43
|
+
padding: 0 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#map-title {
|
|
47
|
+
text-style: bold;
|
|
48
|
+
color: #4fc1ff;
|
|
49
|
+
padding: 1 0;
|
|
50
|
+
background: #181818;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#project-tree {
|
|
54
|
+
background: #181818;
|
|
55
|
+
color: #cccccc;
|
|
56
|
+
padding: 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#chat-area {
|
|
60
|
+
width: 50%;
|
|
61
|
+
height: 100%;
|
|
62
|
+
padding: 0 1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#chat-log {
|
|
66
|
+
height: 1fr;
|
|
67
|
+
border: none;
|
|
68
|
+
background: #1e1e1e;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#live-stream {
|
|
72
|
+
background: #1e1e1e;
|
|
73
|
+
border: solid #007acc;
|
|
74
|
+
padding: 0 1;
|
|
75
|
+
display: none;
|
|
76
|
+
height: auto;
|
|
77
|
+
max-height: 10;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#agent-spinner {
|
|
81
|
+
height: 1;
|
|
82
|
+
color: #4fc1ff;
|
|
83
|
+
background: #1e1e1e;
|
|
84
|
+
display: none;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#chat-input {
|
|
88
|
+
dock: bottom;
|
|
89
|
+
margin: 1;
|
|
90
|
+
background: #252526;
|
|
91
|
+
border: round #007acc;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
VerticalResizer {
|
|
95
|
+
width: 1;
|
|
96
|
+
height: 100%;
|
|
97
|
+
background: #2d2d2d;
|
|
98
|
+
content-align: center middle;
|
|
99
|
+
color: #4fc1ff;
|
|
100
|
+
text-style: bold;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
VerticalResizer:hover {
|
|
104
|
+
background: #007acc;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
VerticalResizer.-dragging {
|
|
108
|
+
background: #007acc;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#drawer-title {
|
|
112
|
+
text-style: bold;
|
|
113
|
+
color: #4fc1ff;
|
|
114
|
+
padding: 1 0;
|
|
115
|
+
background: #181818;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#drawer-log {
|
|
119
|
+
background: #181818;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* Modal */
|
|
123
|
+
PermissionModal {
|
|
124
|
+
align: center middle;
|
|
125
|
+
background: rgba(0,0,0,0.75);
|
|
126
|
+
}
|
|
127
|
+
#modal-dialog {
|
|
128
|
+
width: 64;
|
|
129
|
+
height: auto;
|
|
130
|
+
max-height: 80%;
|
|
131
|
+
padding: 1 2;
|
|
132
|
+
background: #252526;
|
|
133
|
+
border: thick #d7ba7d;
|
|
134
|
+
}
|
|
135
|
+
#modal-title {
|
|
136
|
+
text-style: bold;
|
|
137
|
+
color: #d7ba7d;
|
|
138
|
+
padding-bottom: 1;
|
|
139
|
+
}
|
|
140
|
+
#modal-preview {
|
|
141
|
+
height: 1fr;
|
|
142
|
+
max-height: 20;
|
|
143
|
+
background: #1e1e1e;
|
|
144
|
+
border: solid #333;
|
|
145
|
+
padding: 1;
|
|
146
|
+
overflow-y: auto;
|
|
147
|
+
}
|
|
148
|
+
#modal-buttons {
|
|
149
|
+
layout: horizontal;
|
|
150
|
+
align: center middle;
|
|
151
|
+
height: auto;
|
|
152
|
+
margin-top: 1;
|
|
153
|
+
}
|
|
154
|
+
Button { margin: 0 1; }
|
|
155
|
+
|
|
156
|
+
.copy-btn {
|
|
157
|
+
display: none;
|
|
158
|
+
margin-top: 1;
|
|
159
|
+
background: #007acc;
|
|
160
|
+
color: white;
|
|
161
|
+
border: none;
|
|
162
|
+
height: 1;
|
|
163
|
+
min-width: 10;
|
|
164
|
+
}
|
|
165
|
+
ChatMessage:hover .copy-btn {
|
|
166
|
+
display: block;
|
|
167
|
+
}
|
|
168
|
+
ChatMessage {
|
|
169
|
+
height: auto;
|
|
170
|
+
margin-bottom: 1;
|
|
171
|
+
}
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class PermissionModal(ModalScreen[str]):
|
|
176
|
+
"""Centered modal that pauses the worker until the user decides."""
|
|
177
|
+
|
|
178
|
+
def __init__(self, tool_name: str, preview_lines: list[str]) -> None:
|
|
179
|
+
self.tool_name = tool_name
|
|
180
|
+
self.preview_text = "\n".join(preview_lines)
|
|
181
|
+
super().__init__()
|
|
182
|
+
|
|
183
|
+
def compose(self) -> ComposeResult:
|
|
184
|
+
with Vertical(id="modal-dialog"):
|
|
185
|
+
yield Label(f"⚠ Permission required: {self.tool_name}", id="modal-title")
|
|
186
|
+
with ScrollableContainer(id="modal-preview"):
|
|
187
|
+
yield Label(self.preview_text)
|
|
188
|
+
with Horizontal(id="modal-buttons"):
|
|
189
|
+
yield Button("Allow Once", id="btn-allow", variant="primary")
|
|
190
|
+
yield Button("Allow All", id="btn-allow-all", variant="warning")
|
|
191
|
+
yield Button("Deny", id="btn-deny", variant="error")
|
|
192
|
+
|
|
193
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
194
|
+
mapping = {"btn-allow": "y", "btn-allow-all": "a", "btn-deny": "n"}
|
|
195
|
+
button_id = event.button.id or ""
|
|
196
|
+
self.dismiss(mapping.get(button_id, "n"))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class VerticalResizer(Static):
|
|
200
|
+
"""A vertical drag handle to resize sidebars."""
|
|
201
|
+
|
|
202
|
+
def on_mouse_down(self, event: MouseDown) -> None:
|
|
203
|
+
self.add_class("-dragging")
|
|
204
|
+
self.capture_mouse()
|
|
205
|
+
|
|
206
|
+
def on_mouse_up(self, event: MouseUp) -> None:
|
|
207
|
+
self.remove_class("-dragging")
|
|
208
|
+
self.release_mouse()
|
|
209
|
+
|
|
210
|
+
def on_mouse_move(self, event: MouseMove) -> None:
|
|
211
|
+
if self.has_class("-dragging"):
|
|
212
|
+
from typing import Any
|
|
213
|
+
app: Any = self.app
|
|
214
|
+
total_width = app.console.size.width
|
|
215
|
+
if total_width > 0:
|
|
216
|
+
if self.id == "left-resizer":
|
|
217
|
+
new_percent = int((event.screen_x / total_width) * 100)
|
|
218
|
+
else:
|
|
219
|
+
new_percent = int(((total_width - event.screen_x) / total_width) * 100)
|
|
220
|
+
|
|
221
|
+
# Constrain to reasonable minimum and maximum
|
|
222
|
+
if 5 <= new_percent <= 40:
|
|
223
|
+
if self.id == "left-resizer":
|
|
224
|
+
app._left_width = new_percent
|
|
225
|
+
else:
|
|
226
|
+
app._right_width = new_percent
|
|
227
|
+
app._apply_sidebar_widths()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class ProjectMap(Vertical):
|
|
231
|
+
"""
|
|
232
|
+
Left sidebar — uses a native Textual Tree widget so nodes are
|
|
233
|
+
properly indented, truncated at the pane boundary, and collapsible.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def compose(self) -> ComposeResult:
|
|
237
|
+
yield Label("📁 Project Context", id="map-title")
|
|
238
|
+
self.file_tree: Tree[str] = Tree("", id="project-tree")
|
|
239
|
+
self.file_tree.show_root = False
|
|
240
|
+
self.file_tree.guide_depth = 2
|
|
241
|
+
yield self.file_tree
|
|
242
|
+
|
|
243
|
+
def populate(self, workdir_path: "Path", repo_context: Any) -> None: # type: ignore[name-defined]
|
|
244
|
+
"""Rebuild the tree from the workdir, ignoring ignored dirs."""
|
|
245
|
+
from pathlib import Path
|
|
246
|
+
|
|
247
|
+
self.file_tree.clear()
|
|
248
|
+
root = self.file_tree.root
|
|
249
|
+
|
|
250
|
+
IGNORE = {
|
|
251
|
+
".git", "node_modules", ".venv", "__pycache__",
|
|
252
|
+
"dist", "build", ".next", ".tox", "coverage_html_report",
|
|
253
|
+
".devpilot_sessions", ".pytest_cache",
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
def _add(node: TreeNode, directory: Path, depth: int = 0) -> None:
|
|
257
|
+
if depth > 6:
|
|
258
|
+
return
|
|
259
|
+
try:
|
|
260
|
+
items = sorted(directory.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
|
|
261
|
+
except OSError:
|
|
262
|
+
return
|
|
263
|
+
for item in items:
|
|
264
|
+
if item.name.startswith(".") and item.name not in (".env", ".gitignore", ".github"):
|
|
265
|
+
continue
|
|
266
|
+
if item.name in IGNORE or item.name.endswith(".egg-info"):
|
|
267
|
+
continue
|
|
268
|
+
if item.is_dir():
|
|
269
|
+
child = node.add(f"📁 {item.name}", expand=depth < 1)
|
|
270
|
+
_add(child, item, depth + 1)
|
|
271
|
+
else:
|
|
272
|
+
# Mark files the model has already read with a dot
|
|
273
|
+
rel = str(item.relative_to(workdir_path))
|
|
274
|
+
read = rel in getattr(repo_context, "_read_files", {})
|
|
275
|
+
icon = "●" if read else "📄"
|
|
276
|
+
node.add_leaf(f"{icon} {item.name}")
|
|
277
|
+
|
|
278
|
+
_add(root, workdir_path)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class StreamingMessage(Vertical):
|
|
282
|
+
"""Live-updating widget during streaming. Replaced by ChatMessage on completion."""
|
|
283
|
+
def __init__(self, role: str, **kwargs: Any) -> None:
|
|
284
|
+
super().__init__(**kwargs)
|
|
285
|
+
self.role = role
|
|
286
|
+
self._buffer = ""
|
|
287
|
+
self._md_widget = Static(Markdown(" █"))
|
|
288
|
+
|
|
289
|
+
def compose(self) -> ComposeResult:
|
|
290
|
+
color = "green" if self.role == "You" else "cyan"
|
|
291
|
+
yield Static(f"[bold {color}]{self.role}:[/bold {color}]", markup=True)
|
|
292
|
+
yield self._md_widget
|
|
293
|
+
|
|
294
|
+
def update_token(self, token: str) -> None:
|
|
295
|
+
self._buffer += token
|
|
296
|
+
self._md_widget.update(Markdown(self._buffer + " █"))
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class ChatMessage(Vertical):
|
|
300
|
+
"""Final rendered message with Copy button."""
|
|
301
|
+
def __init__(self, role: str, text: str, **kwargs: Any) -> None:
|
|
302
|
+
super().__init__(**kwargs)
|
|
303
|
+
self.role = role
|
|
304
|
+
self.text = text
|
|
305
|
+
|
|
306
|
+
def compose(self) -> ComposeResult:
|
|
307
|
+
color = "green" if self.role == "You" else "cyan"
|
|
308
|
+
yield Static(f"[bold {color}]{self.role}:[/bold {color}]", markup=True)
|
|
309
|
+
yield Static(Markdown(self.text))
|
|
310
|
+
if self.role == "DevPilot":
|
|
311
|
+
yield Button("📋 Copy", classes="copy-btn")
|
|
312
|
+
|
|
313
|
+
@on(Button.Pressed, ".copy-btn")
|
|
314
|
+
def copy_text(self, event: Button.Pressed) -> None:
|
|
315
|
+
self.app.copy_to_clipboard(self.text)
|
|
316
|
+
self.app.notify("Copied to clipboard!", title="Success")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class DevPilotApp(App):
|
|
320
|
+
"""The main DevPilot Textual application."""
|
|
321
|
+
|
|
322
|
+
CSS = CSS
|
|
323
|
+
TITLE = "DevPilot V2"
|
|
324
|
+
BINDINGS = [
|
|
325
|
+
("ctrl+b", "toggle_map", "Toggle Map"),
|
|
326
|
+
("f1", "shrink_sidebar", "Shrink Sidebar"),
|
|
327
|
+
("f2", "grow_sidebar", "Grow Sidebar"),
|
|
328
|
+
("f3", "copy_last", "Copy Response"),
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
def __init__(
|
|
332
|
+
self,
|
|
333
|
+
provider: Any,
|
|
334
|
+
registry: Any,
|
|
335
|
+
history: Any,
|
|
336
|
+
config: Any,
|
|
337
|
+
repo_context: Any,
|
|
338
|
+
) -> None:
|
|
339
|
+
super().__init__()
|
|
340
|
+
self.provider = provider
|
|
341
|
+
self.registry = registry
|
|
342
|
+
self.history = history
|
|
343
|
+
self.config = config
|
|
344
|
+
self.repo_context = repo_context
|
|
345
|
+
self._active_stream: StreamingMessage | None = None
|
|
346
|
+
self._last_assistant_message = ""
|
|
347
|
+
self._left_width = 25
|
|
348
|
+
UI.set_tui_app(self)
|
|
349
|
+
|
|
350
|
+
def compose(self) -> ComposeResult:
|
|
351
|
+
yield Header(show_clock=True)
|
|
352
|
+
with Horizontal(id="main-container"):
|
|
353
|
+
self.project_map = ProjectMap(id="project-map")
|
|
354
|
+
yield self.project_map
|
|
355
|
+
|
|
356
|
+
yield VerticalResizer("↔", id="left-resizer")
|
|
357
|
+
|
|
358
|
+
with Vertical(id="chat-area"):
|
|
359
|
+
self.chat_log = VerticalScroll(id="chat-log")
|
|
360
|
+
yield self.chat_log
|
|
361
|
+
self.spinner = LoadingIndicator(id="agent-spinner")
|
|
362
|
+
yield self.spinner
|
|
363
|
+
yield Input(
|
|
364
|
+
placeholder="Ask DevPilot… (type 'exit' to quit)",
|
|
365
|
+
id="chat-input",
|
|
366
|
+
)
|
|
367
|
+
yield Footer()
|
|
368
|
+
|
|
369
|
+
async def on_mount(self) -> None:
|
|
370
|
+
from pathlib import Path
|
|
371
|
+
self._apply_sidebar_widths()
|
|
372
|
+
# Use the config workdir — the actual DevPilot project root
|
|
373
|
+
self._refresh_project_map()
|
|
374
|
+
welcome_msg = "DevPilot is ready. Type your task below to begin."
|
|
375
|
+
await self.chat_log.mount(ChatMessage("DevPilot", welcome_msg))
|
|
376
|
+
self.chat_log.scroll_end(animate=False)
|
|
377
|
+
self._last_assistant_message = welcome_msg
|
|
378
|
+
self.sub_title = (
|
|
379
|
+
f"Model: {self.config.model} │ "
|
|
380
|
+
f"Workdir: {self.config.workdir} │ "
|
|
381
|
+
f"Session: active"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
def _refresh_project_map(self) -> None:
|
|
385
|
+
from pathlib import Path
|
|
386
|
+
workdir = Path(self.config.workdir).resolve()
|
|
387
|
+
self.project_map.populate(workdir, self.repo_context)
|
|
388
|
+
|
|
389
|
+
def action_toggle_map(self) -> None:
|
|
390
|
+
self.project_map.display = not self.project_map.display
|
|
391
|
+
self._apply_sidebar_widths()
|
|
392
|
+
|
|
393
|
+
def action_shrink_sidebar(self) -> None:
|
|
394
|
+
if self._left_width > 5: self._left_width -= 5
|
|
395
|
+
self._apply_sidebar_widths()
|
|
396
|
+
|
|
397
|
+
def action_grow_sidebar(self) -> None:
|
|
398
|
+
if self._left_width < 50: self._left_width += 5
|
|
399
|
+
self._apply_sidebar_widths()
|
|
400
|
+
|
|
401
|
+
def action_copy_last(self) -> None:
|
|
402
|
+
if self._last_assistant_message:
|
|
403
|
+
self.copy_to_clipboard(self._last_assistant_message)
|
|
404
|
+
self.notify("Copied last response to clipboard!", title="Success")
|
|
405
|
+
else:
|
|
406
|
+
self.notify("Nothing to copy yet.", severity="warning")
|
|
407
|
+
|
|
408
|
+
def _apply_sidebar_widths(self) -> None:
|
|
409
|
+
self.project_map.styles.width = f"{self._left_width}%"
|
|
410
|
+
chat_width = 100 - (self._left_width if self.project_map.display else 0)
|
|
411
|
+
self.query_one("#chat-area").styles.width = f"{chat_width}%"
|
|
412
|
+
|
|
413
|
+
@work(exclusive=True)
|
|
414
|
+
async def run_agent_task(self, user_input: str) -> None:
|
|
415
|
+
self.history.append(self.provider.make_user_message(user_input))
|
|
416
|
+
try:
|
|
417
|
+
await run_agent_loop(
|
|
418
|
+
provider=self.provider,
|
|
419
|
+
registry=self.registry,
|
|
420
|
+
history=self.history,
|
|
421
|
+
config=self.config,
|
|
422
|
+
max_iterations=self.config.max_iterations,
|
|
423
|
+
context=self.repo_context,
|
|
424
|
+
)
|
|
425
|
+
except Exception as e:
|
|
426
|
+
self.post_message(ErrorEvent(f"Agent loop crashed: {e}"))
|
|
427
|
+
|
|
428
|
+
async def on_worker_state_changed(self, event: Any) -> None:
|
|
429
|
+
"""Re-enable input when the agent loop finishes."""
|
|
430
|
+
if event.worker.name == "run_agent_task" and event.state.name in ("SUCCESS", "ERROR", "CANCELLED"):
|
|
431
|
+
self.spinner.display = False
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
inp = self.query_one("#chat-input", Input)
|
|
435
|
+
inp.disabled = False
|
|
436
|
+
inp.focus()
|
|
437
|
+
except Exception:
|
|
438
|
+
pass
|
|
439
|
+
|
|
440
|
+
# If we had a buffered stream, write it out
|
|
441
|
+
if self._active_stream:
|
|
442
|
+
final_text = self._active_stream._buffer
|
|
443
|
+
await self._active_stream.remove()
|
|
444
|
+
self._active_stream = None
|
|
445
|
+
await self.chat_log.mount(ChatMessage("DevPilot", final_text))
|
|
446
|
+
self.chat_log.scroll_end(animate=False)
|
|
447
|
+
self._last_assistant_message = final_text
|
|
448
|
+
# Refresh tree to show newly read/written files
|
|
449
|
+
self._refresh_project_map()
|
|
450
|
+
|
|
451
|
+
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
452
|
+
user_input = event.value.strip()
|
|
453
|
+
if not user_input:
|
|
454
|
+
return
|
|
455
|
+
if user_input.lower() in ("exit", "quit"):
|
|
456
|
+
self.exit()
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
inp = self.query_one("#chat-input", Input)
|
|
460
|
+
inp.disabled = True
|
|
461
|
+
inp.value = ""
|
|
462
|
+
|
|
463
|
+
await self.chat_log.mount(ChatMessage("You", user_input))
|
|
464
|
+
self.chat_log.scroll_end(animate=False)
|
|
465
|
+
|
|
466
|
+
self.spinner.display = True
|
|
467
|
+
self.run_agent_task(user_input)
|
|
468
|
+
|
|
469
|
+
# ── UI event routing ──────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
@on(AssistantMessageEvent)
|
|
472
|
+
@on(StreamTokenEvent)
|
|
473
|
+
@on(ToolCallEvent)
|
|
474
|
+
@on(ToolResultEvent)
|
|
475
|
+
@on(ErrorEvent)
|
|
476
|
+
@on(InfoEvent)
|
|
477
|
+
@on(SuccessEvent)
|
|
478
|
+
@on(DiffEvent)
|
|
479
|
+
@on(ThinkingEvent)
|
|
480
|
+
async def handle_ui_events(self, event: UIEvent) -> None:
|
|
481
|
+
if isinstance(event, AssistantMessageEvent):
|
|
482
|
+
final_text = ""
|
|
483
|
+
if self._active_stream:
|
|
484
|
+
final_text = self._active_stream._buffer
|
|
485
|
+
await self._active_stream.remove()
|
|
486
|
+
self._active_stream = None
|
|
487
|
+
if event.text.strip():
|
|
488
|
+
final_text += ("\n" + event.text.strip()) if final_text else event.text.strip()
|
|
489
|
+
|
|
490
|
+
if final_text:
|
|
491
|
+
await self.chat_log.mount(ChatMessage("DevPilot", final_text))
|
|
492
|
+
self.chat_log.scroll_end(animate=False)
|
|
493
|
+
self._last_assistant_message = final_text
|
|
494
|
+
|
|
495
|
+
elif isinstance(event, StreamTokenEvent):
|
|
496
|
+
if self.spinner.display:
|
|
497
|
+
self.spinner.display = False
|
|
498
|
+
if not self._active_stream:
|
|
499
|
+
self._active_stream = StreamingMessage("DevPilot")
|
|
500
|
+
await self.chat_log.mount(self._active_stream)
|
|
501
|
+
self.chat_log.scroll_end(animate=False)
|
|
502
|
+
self._active_stream.update_token(event.token)
|
|
503
|
+
self.chat_log.scroll_end(animate=False)
|
|
504
|
+
|
|
505
|
+
elif isinstance(event, ToolCallEvent):
|
|
506
|
+
if self._active_stream:
|
|
507
|
+
final_text = self._active_stream._buffer
|
|
508
|
+
await self._active_stream.remove()
|
|
509
|
+
self._active_stream = None
|
|
510
|
+
await self.chat_log.mount(ChatMessage("DevPilot", final_text))
|
|
511
|
+
self.chat_log.scroll_end(animate=False)
|
|
512
|
+
self._last_assistant_message = final_text
|
|
513
|
+
|
|
514
|
+
self.spinner.display = True
|
|
515
|
+
|
|
516
|
+
if isinstance(event.tool_input, dict):
|
|
517
|
+
args = ", ".join(f"{k}={v!r}" for k, v in event.tool_input.items())
|
|
518
|
+
inp_str = f"({args})"
|
|
519
|
+
else:
|
|
520
|
+
import json
|
|
521
|
+
try:
|
|
522
|
+
inp_str = json.dumps(event.tool_input)
|
|
523
|
+
except Exception:
|
|
524
|
+
inp_str = str(event.tool_input)
|
|
525
|
+
|
|
526
|
+
if len(inp_str) > 150:
|
|
527
|
+
inp_str = inp_str[:147] + "..."
|
|
528
|
+
|
|
529
|
+
await self.chat_log.mount(Static(f"[dim cyan]🔧 Used {event.tool_name}{inp_str}[/dim cyan]", markup=True))
|
|
530
|
+
self.chat_log.scroll_end(animate=False)
|
|
531
|
+
|
|
532
|
+
elif isinstance(event, ToolResultEvent):
|
|
533
|
+
if event.is_error:
|
|
534
|
+
err_line = event.content.splitlines()[0] if event.content else "Unknown error"
|
|
535
|
+
await self.chat_log.mount(Static(f"[dim red]❌ {event.tool_name} failed: {err_line}[/dim red]", markup=True))
|
|
536
|
+
self.chat_log.scroll_end(animate=False)
|
|
537
|
+
|
|
538
|
+
elif isinstance(event, ErrorEvent):
|
|
539
|
+
await self.chat_log.mount(Static(f"[bold red]❌ {event.msg}[/bold red]", markup=True))
|
|
540
|
+
self.chat_log.scroll_end(animate=False)
|
|
541
|
+
|
|
542
|
+
elif isinstance(event, InfoEvent):
|
|
543
|
+
await self.chat_log.mount(Static(f"[dim cyan]ℹ {event.msg}[/dim cyan]", markup=True))
|
|
544
|
+
self.chat_log.scroll_end(animate=False)
|
|
545
|
+
|
|
546
|
+
elif isinstance(event, SuccessEvent):
|
|
547
|
+
await self.chat_log.mount(Static(f"[bold green]✓ {event.msg}[/bold green]", markup=True))
|
|
548
|
+
self.chat_log.scroll_end(animate=False)
|
|
549
|
+
|
|
550
|
+
elif isinstance(event, DiffEvent):
|
|
551
|
+
from textual.widgets import Label
|
|
552
|
+
await self.chat_log.mount(Label(f"[yellow]📝 {'New' if event.is_new else 'Diff'}: {event.path}[/yellow]", markup=True))
|
|
553
|
+
self.chat_log.scroll_end(animate=False)
|
|
554
|
+
|
|
555
|
+
elif isinstance(event, ThinkingEvent):
|
|
556
|
+
await self.chat_log.mount(Static(f"[dim]🧠 Extended Thinking...[/dim]", markup=True))
|
|
557
|
+
self.chat_log.scroll_end(animate=False)
|