forked from UiPath/uipath-dev-python
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path__init__.py
More file actions
335 lines (276 loc) · 12.8 KB
/
Copy path__init__.py
File metadata and controls
335 lines (276 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
"""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)