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