"""UiPath Developer Console Application.""" import asyncio import json from datetime import datetime from pathlib import Path from typing import Any, cast import pyperclip # type: ignore[import-untyped] from textual import on from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Container, Horizontal from textual.widgets import Button, Footer, Input, ListView, RichLog from uipath.core.tracing import UiPathTraceManager from uipath.runtime import UiPathRuntimeFactoryProtocol from uipath.dev.infrastructure import ( patch_textual_stderr, ) from uipath.dev.models import ( ExecutionMode, ExecutionRun, ) from uipath.dev.models.chat import get_user_message, get_user_message_event from uipath.dev.models.data import ChatData, LogData, TraceData from uipath.dev.services import RunService from uipath.dev.services.debug_bridge import TextualDebugBridge from uipath.dev.ui.panels import NewRunPanel, RunDetailsPanel, RunHistoryPanel class UiPathDeveloperConsole(App[Any]): """UiPath developer console interface.""" TITLE = "UiPath Developer Console" SUB_TITLE = ( "Interactive terminal application for building, testing, and debugging " "UiPath Python runtimes, agents, and automation scripts." ) CSS_PATH = Path(__file__).parent / "ui" / "styles" / "terminal.tcss" BINDINGS = [ Binding("q", "quit", "Quit"), Binding("n", "new_run", "New"), Binding("r", "execute_run", "Run"), Binding("c", "copy", "Copy"), Binding("h", "clear_history", "Clear History"), Binding("escape", "cancel", "Cancel"), ] def __init__( self, runtime_factory: UiPathRuntimeFactoryProtocol, trace_manager: UiPathTraceManager, **kwargs, ): """Initialize the UiPath Dev Terminal App.""" # Capture subprocess stderr lines and route to our log handler self._stderr_write_fd: int = patch_textual_stderr(self._add_subprocess_log) super().__init__(**kwargs) self.runtime_factory = runtime_factory self.trace_manager = trace_manager # Core service: owns run state, logs, traces # Adapter layer: wrap data classes into Textual messages for UI self.run_service = RunService( runtime_factory=self.runtime_factory, trace_manager=self.trace_manager, on_run_updated=self._on_run_updated, on_log=self._on_log_for_ui, on_trace=self._on_trace_for_ui, on_chat=self._on_chat_for_ui, debug_bridge_factory=lambda mode: TextualDebugBridge(mode=mode), ) # Just defaults for convenience self.initial_entrypoint: str = "main.py" self.initial_input: str = '{\n "message": "Hello World"\n}' async def on_mount(self) -> None: """Apply factory settings on mount.""" await self.run_service.apply_factory_settings() def compose(self) -> ComposeResult: """Compose the UI layout.""" with Horizontal(): # Left sidebar - run history with Container(classes="run-history"): yield RunHistoryPanel(id="history-panel") # Main content area with Container(classes="main-content"): # New run panel (initially visible) yield NewRunPanel( id="new-run-panel", classes="new-run-panel", runtime_factory=self.runtime_factory, ) # Run details panel (initially hidden) yield RunDetailsPanel(id="details-panel", classes="hidden") yield Footer() async def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button press events.""" if event.button.id == "new-run-btn": await self.action_new_run() elif event.button.id == "execute-btn": await self.action_execute_run(mode=ExecutionMode.RUN) elif event.button.id == "debug-btn": await self.action_execute_run(mode=ExecutionMode.DEBUG) elif event.button.id == "chat-btn": await self.action_execute_run(mode=ExecutionMode.CHAT) elif event.button.id == "cancel-btn": await self.action_cancel() elif event.button.id == "debug-step-btn": await self.action_debug_step() elif event.button.id == "debug-continue-btn": await self.action_debug_continue() elif event.button.id == "debug-stop-btn": await self.action_debug_stop() async def on_list_view_selected(self, event: ListView.Selected) -> None: """Handle run selection from history.""" if event.list_view.id == "run-list" and event.item: run_id = getattr(event.item, "run_id", None) if run_id: history_panel = self.query_one("#history-panel", RunHistoryPanel) run = history_panel.get_run_by_id(run_id) if run: self._show_run_details(run) @on(Input.Submitted, "#chat-input") async def handle_chat_input(self, event: Input.Submitted) -> None: """Handle user submitting text into the chat.""" user_text = event.value.strip() if not user_text: return details_panel = self.query_one("#details-panel", RunDetailsPanel) if details_panel and details_panel.current_run: current_run = details_panel.current_run status = current_run.status if status == "running": self.app.notify( "Wait for agent response...", timeout=1.5, severity="warning" ) return if current_run.status == "suspended": resume_input: Any = {} try: resume_input = json.loads(user_text) except json.JSONDecodeError: resume_input = user_text current_run.resume_data = resume_input else: msg = get_user_message(user_text) msg_ev = get_user_message_event(user_text) # Route through adapter: data class -> Textual message chat_data = ChatData( event=msg_ev, message=msg, run_id=current_run.id, ) self._on_chat_for_ui(chat_data) current_run.add_event(msg_ev) current_run.input_data = {"messages": [msg.model_dump(by_alias=True)]} if current_run.mode == ExecutionMode.DEBUG: asyncio.create_task( self._resume_runtime(current_run, current_run.resume_data) ) else: asyncio.create_task(self._execute_runtime(current_run)) event.input.clear() async def action_new_run(self) -> None: """Show new run panel.""" new_panel = self.query_one("#new-run-panel") details_panel = self.query_one("#details-panel") new_panel.remove_class("hidden") details_panel.add_class("hidden") async def action_cancel(self) -> None: """Cancel and return to new run view.""" await self.action_new_run() async def action_execute_run(self, mode: ExecutionMode = ExecutionMode.RUN) -> None: """Execute a new run based on NewRunPanel inputs.""" new_run_panel = self.query_one("#new-run-panel", NewRunPanel) entrypoint, input_data = new_run_panel.get_input_values() if not entrypoint: return try: input_payload: dict[str, Any] = json.loads(input_data) except json.JSONDecodeError: return run = ExecutionRun(entrypoint, input_payload, mode=mode) history_panel = self.query_one("#history-panel", RunHistoryPanel) history_panel.add_run(run) self.run_service.register_run(run) self._show_run_details(run) if mode == ExecutionMode.CHAT: self._focus_chat_input() else: asyncio.create_task(self._execute_runtime(run)) async def action_debug_step(self) -> None: """Step to next breakpoint in debug mode.""" details_panel = self.query_one("#details-panel", RunDetailsPanel) if details_panel and details_panel.current_run: run = details_panel.current_run self.run_service.step_debug(run) async def action_debug_continue(self) -> None: """Continue execution without stopping at breakpoints.""" details_panel = self.query_one("#details-panel", RunDetailsPanel) if details_panel and details_panel.current_run: run = details_panel.current_run self.run_service.continue_debug(run) async def action_debug_stop(self) -> None: """Stop debug execution.""" details_panel = self.query_one("#details-panel", RunDetailsPanel) if details_panel and details_panel.current_run: run = details_panel.current_run self.run_service.stop_debug(run) async def action_clear_history(self) -> None: """Clear run history.""" history_panel = self.query_one("#history-panel", RunHistoryPanel) history_panel.clear_runs() await self.action_new_run() def action_copy(self) -> None: """Copy content of currently focused RichLog to clipboard and notify.""" focused = self.app.focused if isinstance(focused, RichLog): clipboard_text = "\n".join(line.text for line in focused.lines) pyperclip.copy(clipboard_text) self.app.notify("Copied to clipboard!", timeout=1.5) else: self.app.notify("Nothing to copy here.", timeout=1.5, severity="warning") async def _execute_runtime(self, run: ExecutionRun) -> None: """Wrapper that delegates execution to RunService.""" await self.run_service.execute(run) async def _resume_runtime(self, run: ExecutionRun, resume_data: Any) -> None: """Wrapper that delegates execution to RunService.""" await self.run_service.resume_debug(run, resume_data) def _on_run_updated(self, run: ExecutionRun) -> None: """Called whenever a run changes (status, times, logs, traces).""" if not self.is_running: return # Update the run in history history_panel = self.query_one("#history-panel", RunHistoryPanel) history_panel.update_run(run) # If this run is currently shown, refresh details details_panel = self.query_one("#details-panel", RunDetailsPanel) if details_panel.current_run and details_panel.current_run.id == run.id: details_panel.update_run_details(run) def _on_log_for_ui(self, log_data: LogData) -> None: """Adapter: wrap LogData into LogMessage and append to UI.""" if not self.is_running: return details_panel = self.query_one("#details-panel", RunDetailsPanel) details_panel.add_log(log_data) def _on_trace_for_ui(self, trace_data: TraceData) -> None: """Adapter: wrap TraceData into TraceMessage and append to UI.""" if not self.is_running: return details_panel = self.query_one("#details-panel", RunDetailsPanel) details_panel.add_trace(trace_data) def _on_chat_for_ui(self, chat_data: ChatData) -> None: """Adapter: wrap ChatData into ChatMessage and append to UI.""" if not self.is_running: return details_panel = self.query_one("#details-panel", RunDetailsPanel) details_panel.add_chat_message(chat_data) def _show_run_details(self, run: ExecutionRun) -> None: """Show details panel for a specific run.""" new_panel = self.query_one("#new-run-panel") details_panel = self.query_one("#details-panel", RunDetailsPanel) new_panel.add_class("hidden") details_panel.remove_class("hidden") details_panel.update_run(run) def _focus_chat_input(self) -> None: """Focus the chat input box.""" details_panel = self.query_one("#details-panel", RunDetailsPanel) details_panel.switch_tab("chat-tab") chat_input = details_panel.query_one("#chat-input", Input) chat_input.focus() def _add_subprocess_log(self, level: str, message: str) -> None: """Handle a stderr line coming from subprocesses.""" def add_log() -> None: details_panel = self.query_one("#details-panel", RunDetailsPanel) run: ExecutionRun = cast( ExecutionRun, getattr(details_panel, "current_run", None) ) if run: log_data = LogData(run.id, level, message, datetime.now()) # Route through RunService so state + UI stay in sync self.run_service.handle_log(log_data) self.call_from_thread(add_log)