uipath-dev 0.0.1__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.
@@ -0,0 +1,119 @@
1
+ import logging
2
+ from datetime import datetime
3
+ from typing import Callable, Sequence
4
+
5
+ from opentelemetry import trace
6
+ from opentelemetry.sdk.trace import Event, ReadableSpan
7
+ from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
8
+ from opentelemetry.trace import StatusCode
9
+
10
+ from uipath.dev.models.messages import LogMessage, TraceMessage
11
+
12
+
13
+ class RunContextExporter(SpanExporter):
14
+ """Custom trace exporter that sends traces and logs to CLI UI."""
15
+
16
+ def __init__(
17
+ self,
18
+ on_trace: Callable[[TraceMessage], None],
19
+ on_log: Callable[[LogMessage], None],
20
+ ):
21
+ self.on_trace = on_trace
22
+ self.on_log = on_log
23
+ self.logger = logging.getLogger(__name__)
24
+
25
+ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
26
+ """Export spans to CLI UI."""
27
+ try:
28
+ for span in spans:
29
+ self._export_span(span)
30
+ return SpanExportResult.SUCCESS
31
+ except Exception as e:
32
+ self.logger.error(f"Failed to export spans: {e}")
33
+ return SpanExportResult.FAILURE
34
+
35
+ def _export_span(self, span: ReadableSpan):
36
+ """Export a single span to CLI UI."""
37
+ # Calculate duration
38
+ start_time = (
39
+ span.start_time / 1_000_000_000 if span.start_time is not None else 0
40
+ )
41
+ end_time = span.end_time / 1_000_000_000 if span.end_time is not None else None
42
+ duration_ms = (end_time - start_time) * 1000 if end_time else None
43
+
44
+ # Determine status
45
+ if span.status.status_code == StatusCode.ERROR:
46
+ status = "failed"
47
+ elif end_time:
48
+ status = "completed"
49
+ else:
50
+ status = "running"
51
+
52
+ # Extract span context information
53
+ span_context = span.get_span_context()
54
+
55
+ # Convert span IDs to string format (they're usually int64)
56
+ span_id = f"{span_context.span_id:016x}" # 16-char hex string
57
+ trace_id = f"{span_context.trace_id:032x}" # 32-char hex string
58
+
59
+ run_id = span.attributes.get("execution.id") if span.attributes else None
60
+ run_id_val = str(run_id) if run_id is not None else None
61
+
62
+ if run_id_val is None:
63
+ return
64
+
65
+ # Get parent span ID if available
66
+ parent_span_id = None
67
+ if hasattr(span, "parent") and span.parent:
68
+ parent_span_id = f"{span.parent.span_id:016x}"
69
+
70
+ # Create trace message with all required fields
71
+ trace_msg = TraceMessage(
72
+ run_id=run_id_val,
73
+ span_name=span.name,
74
+ span_id=span_id,
75
+ parent_span_id=parent_span_id,
76
+ trace_id=trace_id,
77
+ status=status,
78
+ duration_ms=duration_ms,
79
+ timestamp=datetime.fromtimestamp(start_time),
80
+ attributes=dict(span.attributes) if span.attributes else {},
81
+ )
82
+
83
+ # Send to UI
84
+ self.on_trace(trace_msg)
85
+
86
+ # Also send logs if there are events
87
+ if hasattr(span, "events") and span.events:
88
+ for event in span.events:
89
+ log_level = self._determine_log_level(event, span.status)
90
+ log_msg = LogMessage(
91
+ run_id=run_id_val,
92
+ level=log_level,
93
+ message=event.name,
94
+ timestamp=datetime.fromtimestamp(event.timestamp / 1_000_000_000),
95
+ )
96
+ self.on_log(log_msg)
97
+
98
+ def _determine_log_level(self, event: Event, span_status: trace.Status) -> str:
99
+ """Determine log level from span event."""
100
+ event_name = event.name.lower()
101
+
102
+ if span_status.status_code == StatusCode.ERROR:
103
+ return "ERROR"
104
+ elif "error" in event_name or "exception" in event_name:
105
+ return "ERROR"
106
+ elif "warn" in event_name:
107
+ return "WARNING"
108
+ elif "debug" in event_name:
109
+ return "DEBUG"
110
+ else:
111
+ return "INFO"
112
+
113
+ def shutdown(self) -> None:
114
+ """Shutdown the exporter."""
115
+ pass
116
+
117
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
118
+ """Force flush any pending spans."""
119
+ return True
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import re
6
+ import threading
7
+ from datetime import datetime
8
+ from typing import Callable, Pattern
9
+
10
+ from uipath.dev.models.messages import LogMessage
11
+
12
+
13
+ class RunContextLogHandler(logging.Handler):
14
+ """Custom log handler that sends logs to CLI UI."""
15
+
16
+ def __init__(
17
+ self,
18
+ run_id: str,
19
+ callback: Callable[[LogMessage], None],
20
+ ):
21
+ super().__init__()
22
+ self.run_id = run_id
23
+ self.callback = callback
24
+
25
+ def emit(self, record: logging.LogRecord):
26
+ """Emit a log record to CLI UI."""
27
+ try:
28
+ log_msg = LogMessage(
29
+ run_id=self.run_id,
30
+ level=record.levelname,
31
+ message=self.format(record),
32
+ timestamp=datetime.fromtimestamp(record.created),
33
+ )
34
+ self.callback(log_msg)
35
+ except Exception:
36
+ # Don't let logging errors crash the app
37
+ pass
38
+
39
+
40
+ # A dispatcher is a callable that accepts (level, message) pairs
41
+ DispatchLog = Callable[[str, str], None]
42
+
43
+ LEVEL_PATTERNS: list[tuple[str, Pattern[str]]] = [
44
+ ("DEBUG", re.compile(r"^(DEBUG)[:\s-]+", re.I)),
45
+ ("INFO", re.compile(r"^(INFO)[:\s-]+", re.I)),
46
+ ("WARN", re.compile(r"^(WARNING|WARN)[:\s-]+", re.I)),
47
+ ("ERROR", re.compile(r"^(ERROR|ERRO)[:\s-]+", re.I)),
48
+ ]
49
+
50
+
51
+ def patch_textual_stderr(dispatch_log: DispatchLog) -> int:
52
+ """Redirect subprocess stderr into a provided dispatcher.
53
+
54
+ Args:
55
+ dispatch_log: Callable invoked with (level, message) for each stderr line.
56
+ This will be called from a background thread, so the caller
57
+ should use `App.call_from_thread` or equivalent.
58
+
59
+ Returns:
60
+ int: The write file descriptor for stderr (pass to subprocesses).
61
+ """
62
+ from textual.app import _PrintCapture
63
+
64
+ read_fd, write_fd = os.pipe()
65
+
66
+ # Patch fileno() so subprocesses can write to our pipe
67
+ _PrintCapture.fileno = lambda self: write_fd # type: ignore[method-assign]
68
+
69
+ def read_stderr_pipe() -> None:
70
+ with os.fdopen(read_fd, "r", buffering=1) as pipe_reader:
71
+ try:
72
+ for raw in pipe_reader:
73
+ text = raw.rstrip()
74
+ level: str = "ERROR"
75
+ message: str = text
76
+
77
+ # Try to parse a known level prefix
78
+ for lvl, pattern in LEVEL_PATTERNS:
79
+ m = pattern.match(text)
80
+ if m:
81
+ level = lvl
82
+ message = text[m.end() :]
83
+ break
84
+
85
+ dispatch_log(level, message)
86
+
87
+ except Exception:
88
+ # Never raise from thread
89
+ pass
90
+
91
+ thread = threading.Thread(
92
+ target=read_stderr_pipe,
93
+ daemon=True,
94
+ name="stderr-reader",
95
+ )
96
+ thread.start()
97
+
98
+ return write_fd
@@ -0,0 +1,453 @@
1
+ """Panel for displaying execution run details, traces, and logs."""
2
+
3
+ from typing import Optional
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Container, Horizontal, Vertical
7
+ from textual.reactive import reactive
8
+ from textual.widgets import RichLog, TabbedContent, TabPane, Tree
9
+ from textual.widgets.tree import TreeNode
10
+
11
+ from uipath.dev.models.execution import ExecutionRun
12
+ from uipath.dev.models.messages import LogMessage, TraceMessage
13
+
14
+
15
+ class SpanDetailsDisplay(Container):
16
+ """Widget to display details of a selected span."""
17
+
18
+ def compose(self) -> ComposeResult:
19
+ """Compose the UI layout."""
20
+ yield RichLog(
21
+ id="span-details",
22
+ max_lines=1000,
23
+ highlight=True,
24
+ markup=True,
25
+ classes="detail-log",
26
+ )
27
+
28
+ def show_span_details(self, trace_msg: TraceMessage):
29
+ """Display detailed information about a trace span."""
30
+ details_log = self.query_one("#span-details", RichLog)
31
+ details_log.clear()
32
+
33
+ details_log.write(f"[bold cyan]Span: {trace_msg.span_name}[/bold cyan]")
34
+
35
+ details_log.write("") # Empty line
36
+
37
+ # Status with color
38
+ color_map = {
39
+ "started": "blue",
40
+ "running": "yellow",
41
+ "completed": "green",
42
+ "failed": "red",
43
+ "error": "red",
44
+ }
45
+ color = color_map.get(trace_msg.status.lower(), "white")
46
+ details_log.write(f"Status: [{color}]{trace_msg.status.upper()}[/{color}]")
47
+
48
+ # Timestamps
49
+ details_log.write(
50
+ f"Started: [dim]{trace_msg.timestamp.strftime('%H:%M:%S.%f')[:-3]}[/dim]"
51
+ )
52
+
53
+ if trace_msg.duration_ms is not None:
54
+ details_log.write(
55
+ f"Duration: [yellow]{trace_msg.duration_ms:.2f}ms[/yellow]"
56
+ )
57
+
58
+ # Additional attributes if available
59
+ if trace_msg.attributes:
60
+ details_log.write("")
61
+ details_log.write("[bold]Attributes:[/bold]")
62
+ for key, value in trace_msg.attributes.items():
63
+ details_log.write(f" {key}: {value}")
64
+
65
+ details_log.write("") # Empty line
66
+
67
+ # Format span details
68
+ details_log.write(f"[dim]Trace ID: {trace_msg.trace_id}[/dim]")
69
+ details_log.write(f"[dim]Span ID: {trace_msg.span_id}[/dim]")
70
+ details_log.write(f"[dim]Run ID: {trace_msg.run_id}[/dim]")
71
+
72
+ if trace_msg.parent_span_id:
73
+ details_log.write(f"[dim]Parent Span: {trace_msg.parent_span_id}[/dim]")
74
+
75
+
76
+ class RunDetailsPanel(Container):
77
+ """Panel showing traces and logs for selected run with tabbed interface."""
78
+
79
+ current_run: reactive[Optional[ExecutionRun]] = reactive(None)
80
+
81
+ def __init__(self, **kwargs):
82
+ """Initialize RunDetailsPanel."""
83
+ super().__init__(**kwargs)
84
+ self.span_tree_nodes = {} # Map span_id to tree nodes
85
+ self.current_run = None # Store reference to current run
86
+
87
+ def compose(self) -> ComposeResult:
88
+ """Compose the UI layout."""
89
+ with TabbedContent():
90
+ # Run details tab
91
+ with TabPane("Details", id="run-tab"):
92
+ yield RichLog(
93
+ id="run-details-log",
94
+ max_lines=1000,
95
+ highlight=True,
96
+ markup=True,
97
+ classes="detail-log",
98
+ )
99
+
100
+ # Traces tab
101
+ with TabPane("Traces", id="traces-tab"):
102
+ with Horizontal(classes="traces-content"):
103
+ # Left side - Span tree
104
+ with Vertical(
105
+ classes="spans-tree-section", id="spans-tree-container"
106
+ ):
107
+ yield Tree("Trace", id="spans-tree", classes="spans-tree")
108
+
109
+ # Right side - Span details
110
+ with Vertical(classes="span-details-section"):
111
+ yield SpanDetailsDisplay(id="span-details-display")
112
+
113
+ # Logs tab
114
+ with TabPane("Logs", id="logs-tab"):
115
+ yield RichLog(
116
+ id="logs-log",
117
+ max_lines=1000,
118
+ highlight=True,
119
+ markup=True,
120
+ classes="detail-log",
121
+ )
122
+
123
+ def watch_current_run(
124
+ self, old_value: Optional[ExecutionRun], new_value: Optional[ExecutionRun]
125
+ ):
126
+ """Watch for changes to the current run."""
127
+ if new_value is not None:
128
+ if old_value != new_value:
129
+ self.current_run = new_value
130
+ self.show_run(new_value)
131
+
132
+ def update_run(self, run: ExecutionRun):
133
+ """Update the displayed run information."""
134
+ self.current_run = run
135
+
136
+ def show_run(self, run: ExecutionRun):
137
+ """Display traces and logs for a specific run."""
138
+ # Populate run details tab
139
+ self._show_run_details(run)
140
+
141
+ # Populate logs - convert string logs to display format
142
+ logs_log = self.query_one("#logs-log", RichLog)
143
+ logs_log.clear()
144
+ for log in run.logs:
145
+ self.add_log(log)
146
+
147
+ # Clear and rebuild traces tree using TraceMessage objects
148
+ self._rebuild_spans_tree()
149
+
150
+ def switch_tab(self, tab_id: str) -> None:
151
+ """Switch to a specific tab by id (e.g. 'run-tab', 'traces-tab')."""
152
+ tabbed = self.query_one(TabbedContent)
153
+ tabbed.active = tab_id
154
+
155
+ def _flatten_values(self, value: object, prefix: str = "") -> list[str]:
156
+ """Flatten nested dict/list structures into dot-notation paths."""
157
+ lines: list[str] = []
158
+
159
+ if value is None:
160
+ lines.append(f"{prefix}: [dim]—[/dim]" if prefix else "[dim]—[/dim]")
161
+
162
+ elif isinstance(value, dict):
163
+ if not value:
164
+ lines.append(f"{prefix}: {{}}" if prefix else "{}")
165
+ else:
166
+ for k, v in value.items():
167
+ new_prefix = f"{prefix}.{k}" if prefix else k
168
+ lines.extend(self._flatten_values(v, new_prefix))
169
+
170
+ elif isinstance(value, list):
171
+ if not value:
172
+ lines.append(f"{prefix}: []" if prefix else "[]")
173
+ else:
174
+ for i, item in enumerate(value):
175
+ new_prefix = f"{prefix}[{i}]"
176
+ lines.extend(self._flatten_values(item, new_prefix))
177
+
178
+ elif isinstance(value, str):
179
+ if prefix:
180
+ split_lines = value.splitlines()
181
+ if split_lines:
182
+ lines.append(f"{prefix}: {split_lines[0]}")
183
+ for line in split_lines[1:]:
184
+ lines.append(f"{' ' * 2}{line}")
185
+ else:
186
+ lines.extend(value.splitlines())
187
+
188
+ else:
189
+ if prefix:
190
+ lines.append(f"{prefix}: {value}")
191
+ else:
192
+ lines.append(str(value))
193
+
194
+ return lines
195
+
196
+ def _write_block(
197
+ self, log: RichLog, title: str, data: object, style: str = "white"
198
+ ) -> None:
199
+ """Pretty-print a block with flattened dot-notation paths."""
200
+ log.write(f"[bold {style}]{title.upper()}:[/bold {style}]")
201
+ log.write("[dim]" + "=" * 50 + "[/dim]")
202
+
203
+ for line in self._flatten_values(data):
204
+ log.write(line)
205
+
206
+ log.write("")
207
+
208
+ def _show_run_details(self, run: ExecutionRun):
209
+ """Display detailed information about the run in the Details tab."""
210
+ run_details_log = self.query_one("#run-details-log", RichLog)
211
+ run_details_log.clear()
212
+
213
+ # Run header
214
+ run_details_log.write(f"[bold cyan]Run ID: {run.id}[/bold cyan]")
215
+ run_details_log.write("")
216
+
217
+ # Run status with color
218
+ status_color_map = {
219
+ "started": "blue",
220
+ "running": "yellow",
221
+ "completed": "green",
222
+ "failed": "red",
223
+ "error": "red",
224
+ }
225
+ status = getattr(run, "status", "unknown")
226
+ color = status_color_map.get(status.lower(), "white")
227
+ run_details_log.write(
228
+ f"[bold]Status:[/bold] [{color}]{status.upper()}[/{color}]"
229
+ )
230
+
231
+ # Timestamps
232
+ if hasattr(run, "start_time") and run.start_time:
233
+ run_details_log.write(
234
+ f"[bold]Started:[/bold] [dim]{run.start_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}[/dim]"
235
+ )
236
+
237
+ if hasattr(run, "end_time") and run.end_time:
238
+ run_details_log.write(
239
+ f"[bold]Ended:[/bold] [dim]{run.end_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}[/dim]"
240
+ )
241
+
242
+ # Duration
243
+ if hasattr(run, "duration_ms") and run.duration_ms is not None:
244
+ run_details_log.write(
245
+ f"[bold]Duration:[/bold] [yellow]{run.duration_ms:.2f}ms[/yellow]"
246
+ )
247
+ elif (
248
+ hasattr(run, "start_time")
249
+ and hasattr(run, "end_time")
250
+ and run.start_time
251
+ and run.end_time
252
+ ):
253
+ duration = (run.end_time - run.start_time).total_seconds() * 1000
254
+ run_details_log.write(
255
+ f"[bold]Duration:[/bold] [yellow]{duration:.2f}ms[/yellow]"
256
+ )
257
+
258
+ run_details_log.write("")
259
+
260
+ if hasattr(run, "input_data"):
261
+ self._write_block(run_details_log, "Input", run.input_data, style="green")
262
+
263
+ if hasattr(run, "resume_data") and run.resume_data:
264
+ self._write_block(run_details_log, "Resume", run.resume_data, style="green")
265
+
266
+ if hasattr(run, "output_data"):
267
+ self._write_block(
268
+ run_details_log, "Output", run.output_data, style="magenta"
269
+ )
270
+
271
+ # Error section (if applicable)
272
+ if hasattr(run, "error") and run.error:
273
+ run_details_log.write("[bold red]ERROR:[/bold red]")
274
+ run_details_log.write("[dim]" + "=" * 50 + "[/dim]")
275
+ if run.error.code:
276
+ run_details_log.write(f"[red]Code: {run.error.code}[/red]")
277
+ run_details_log.write(f"[red]Title: {run.error.title}[/red]")
278
+ run_details_log.write(f"[red]\n{run.error.detail}[/red]")
279
+ run_details_log.write("")
280
+
281
+ def _rebuild_spans_tree(self):
282
+ """Rebuild the spans tree from current run's traces."""
283
+ spans_tree = self.query_one("#spans-tree", Tree)
284
+ if spans_tree is None or spans_tree.root is None:
285
+ return
286
+
287
+ spans_tree.root.remove_children()
288
+
289
+ # Only clear the node mapping since we're rebuilding the tree structure
290
+ self.span_tree_nodes.clear()
291
+
292
+ if not self.current_run or not self.current_run.traces:
293
+ return
294
+
295
+ # Build spans tree from TraceMessage objects
296
+ self._build_spans_tree(self.current_run.traces)
297
+
298
+ # Expand the root "Trace" node
299
+ spans_tree.root.expand()
300
+
301
+ def _build_spans_tree(self, trace_messages: list[TraceMessage]):
302
+ """Build the spans tree from trace messages."""
303
+ spans_tree = self.query_one("#spans-tree", Tree)
304
+ root = spans_tree.root
305
+
306
+ # Filter out spans without parents (artificial root spans)
307
+ spans_by_id = {
308
+ msg.span_id: msg for msg in trace_messages if msg.parent_span_id is not None
309
+ }
310
+
311
+ # Build parent-to-children mapping once upfront
312
+ children_by_parent: dict[str, list[TraceMessage]] = {}
313
+ for msg in spans_by_id.values():
314
+ if msg.parent_span_id:
315
+ if msg.parent_span_id not in children_by_parent:
316
+ children_by_parent[msg.parent_span_id] = []
317
+ children_by_parent[msg.parent_span_id].append(msg)
318
+
319
+ # Find root spans (parent doesn't exist in our filtered data)
320
+ root_spans = [
321
+ msg
322
+ for msg in trace_messages
323
+ if msg.parent_span_id and msg.parent_span_id not in spans_by_id
324
+ ]
325
+
326
+ # Build tree recursively for each root span
327
+ for root_span in sorted(root_spans, key=lambda x: x.timestamp):
328
+ self._add_span_with_children(root, root_span, children_by_parent)
329
+
330
+ def _add_span_with_children(
331
+ self,
332
+ parent_node: TreeNode[str],
333
+ trace_msg: TraceMessage,
334
+ children_by_parent: dict[str, list[TraceMessage]],
335
+ ):
336
+ """Recursively add a span and all its children."""
337
+ # Create the node for this span
338
+ color_map = {
339
+ "started": "🔵",
340
+ "running": "🟡",
341
+ "completed": "🟢",
342
+ "failed": "🔴",
343
+ "error": "🔴",
344
+ }
345
+ status_icon = color_map.get(trace_msg.status.lower(), "⚪")
346
+ duration_str = (
347
+ f" ({trace_msg.duration_ms:.1f}ms)" if trace_msg.duration_ms else ""
348
+ )
349
+ label = f"{status_icon} {trace_msg.span_name}{duration_str}"
350
+
351
+ node = parent_node.add(label)
352
+ node.data = trace_msg.span_id
353
+ self.span_tree_nodes[trace_msg.span_id] = node
354
+ node.expand()
355
+
356
+ # Get children from prebuilt mapping - O(1) lookup
357
+ children = children_by_parent.get(trace_msg.span_id, [])
358
+ for child in sorted(children, key=lambda x: x.timestamp):
359
+ self._add_span_with_children(node, child, children_by_parent)
360
+
361
+ def on_tree_node_selected(self, event: Tree.NodeSelected[str]) -> None:
362
+ """Handle span selection in the tree."""
363
+ # Check if this is our spans tree
364
+ spans_tree = self.query_one("#spans-tree", Tree)
365
+ if event.control != spans_tree:
366
+ return
367
+
368
+ # Get the selected span data
369
+ if hasattr(event.node, "data") and event.node.data:
370
+ span_id = event.node.data
371
+ # Find the trace in current_run.traces
372
+ trace_msg = None
373
+ if self.current_run:
374
+ for trace in self.current_run.traces:
375
+ if trace.span_id == span_id:
376
+ trace_msg = trace
377
+ break
378
+
379
+ if trace_msg:
380
+ # Show span details
381
+ span_details_display = self.query_one(
382
+ "#span-details-display", SpanDetailsDisplay
383
+ )
384
+ span_details_display.show_span_details(trace_msg)
385
+
386
+ def update_run_details(self, run: ExecutionRun):
387
+ """Update run details if it matches the current run."""
388
+ if not self.current_run or run.id != self.current_run.id:
389
+ return
390
+
391
+ self._show_run_details(run)
392
+
393
+ def add_trace(self, trace_msg: TraceMessage):
394
+ """Add trace to current run if it matches."""
395
+ if not self.current_run or trace_msg.run_id != self.current_run.id:
396
+ return
397
+
398
+ # Rebuild the tree to include new trace
399
+ self._rebuild_spans_tree()
400
+
401
+ def add_log(self, log_msg: LogMessage):
402
+ """Add log to current run if it matches."""
403
+ if not self.current_run or log_msg.run_id != self.current_run.id:
404
+ return
405
+
406
+ color_map = {
407
+ "DEBUG": "dim cyan",
408
+ "INFO": "blue",
409
+ "WARN": "yellow",
410
+ "WARNING": "yellow",
411
+ "ERROR": "red",
412
+ "CRITICAL": "bold red",
413
+ }
414
+
415
+ color = color_map.get(log_msg.level.upper(), "white")
416
+ timestamp_str = log_msg.timestamp.strftime("%H:%M:%S")
417
+ level_short = log_msg.level[:4].upper()
418
+
419
+ logs_log = self.query_one("#logs-log", RichLog)
420
+ if isinstance(log_msg.message, str):
421
+ log_text = (
422
+ f"[dim]{timestamp_str}[/dim] "
423
+ f"[{color}]{level_short}[/{color}] "
424
+ f"{log_msg.message}"
425
+ )
426
+ logs_log.write(log_text)
427
+ else:
428
+ logs_log.write(log_msg.message)
429
+
430
+ def clear_display(self):
431
+ """Clear both traces and logs display."""
432
+ run_details_log = self.query_one("#run-details-log", RichLog)
433
+ logs_log = self.query_one("#logs-log", RichLog)
434
+ spans_tree = self.query_one("#spans-tree", Tree)
435
+
436
+ run_details_log.clear()
437
+ logs_log.clear()
438
+ spans_tree.clear()
439
+
440
+ self.current_run = None
441
+ self.span_tree_nodes.clear()
442
+
443
+ # Clear span details
444
+ span_details_display = self.query_one(
445
+ "#span-details-display", SpanDetailsDisplay
446
+ )
447
+ span_details_log = span_details_display.query_one("#span-details", RichLog)
448
+ span_details_log.clear()
449
+
450
+ def refresh_display(self):
451
+ """Refresh the display with current run data."""
452
+ if self.current_run:
453
+ self.show_run(self.current_run)