dlab-cli 0.1.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.
@@ -0,0 +1,505 @@
1
+ """
2
+ Main log display widget with collapsible events.
3
+
4
+ Displays formatted log events for the selected agent with
5
+ real-time updates and expand/collapse functionality.
6
+ """
7
+
8
+ from textual.widgets import Static
9
+ from textual.containers import VerticalScroll, Horizontal
10
+ from textual.reactive import reactive
11
+ from textual import events
12
+ from rich.text import Text
13
+ from rich.markdown import Markdown
14
+ from rich.console import Group
15
+
16
+ from dlab.tui.models import LogEvent
17
+
18
+
19
+ # Monokai-native color palette
20
+ _CYAN: str = "#66D9EF"
21
+ _GREEN: str = "#A6E22E"
22
+ _ORANGE: str = "#FD971F"
23
+ _RED: str = "#F92672"
24
+ _PURPLE: str = "#AE81FF"
25
+ _COMMENT: str = "#75715E"
26
+ _FG: str = "#F8F8F2"
27
+
28
+ # Color styles by event type
29
+ EVENT_STYLES: dict[str, str] = {
30
+ "step_start": _CYAN,
31
+ "step_finish": _GREEN,
32
+ "text": _FG,
33
+ "tool_use": _ORANGE,
34
+ "task_start": _ORANGE,
35
+ "task_finish": _GREEN,
36
+ "additional_output": _COMMENT,
37
+ "raw_text": _COMMENT,
38
+ "error": f"bold {_RED}",
39
+ }
40
+
41
+
42
+ def format_relative_time(event_ts: int, global_start_ts: int | None) -> str:
43
+ """
44
+ Format timestamp relative to global start.
45
+
46
+ Parameters
47
+ ----------
48
+ event_ts : int
49
+ Event timestamp in ms, or 0 for events without timestamp.
50
+ global_start_ts : int | None
51
+ Global start timestamp in ms, or None if unknown.
52
+
53
+ Returns
54
+ -------
55
+ str
56
+ Formatted relative time (e.g., "+5.2s"), "---" for no timestamp,
57
+ or "????.?s" if start unknown.
58
+ """
59
+ # Events with timestamp=0 have no time (additional_output events)
60
+ # All returns must be exactly 10 chars for column alignment
61
+ if event_ts == 0:
62
+ return " ---" # 10 chars
63
+ if global_start_ts is None:
64
+ return " ????.?s" # 10 chars
65
+ rel_seconds = (event_ts - global_start_ts) / 1000
66
+ return f"+{rel_seconds:8.1f}s" # 10 chars
67
+
68
+
69
+ def format_duration(ms: int | None) -> str:
70
+ """
71
+ Format duration in milliseconds.
72
+
73
+ Parameters
74
+ ----------
75
+ ms : int | None
76
+ Duration in milliseconds.
77
+
78
+ Returns
79
+ -------
80
+ str
81
+ Formatted duration or empty string.
82
+ """
83
+ if ms is None:
84
+ return ""
85
+ seconds = ms / 1000
86
+ if seconds < 60:
87
+ return f"[{seconds:.1f}s]"
88
+ minutes = seconds / 60
89
+ return f"[{minutes:.1f}m]"
90
+
91
+
92
+ class LogEventPrefix(Static):
93
+ """Fixed-width prefix showing selection, time, and event type."""
94
+
95
+ DEFAULT_CSS = """
96
+ LogEventPrefix {
97
+ width: 27;
98
+ min-width: 27;
99
+ max-width: 27;
100
+ }
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ time_str: str,
106
+ event_type: str,
107
+ style: str,
108
+ **kwargs,
109
+ ) -> None:
110
+ super().__init__(**kwargs)
111
+ self._time_str = time_str
112
+ self._event_type = event_type
113
+ self._style = style
114
+ self._is_selected = False
115
+
116
+ def set_selected(self, selected: bool) -> None:
117
+ """Update selection state."""
118
+ self._is_selected = selected
119
+ self.refresh()
120
+
121
+ def render(self) -> Text:
122
+ """Render the prefix."""
123
+ if self._is_selected:
124
+ text = Text("► ", style=f"bold {_PURPLE}")
125
+ else:
126
+ text = Text(" ")
127
+
128
+ text.append(self._time_str, style="dim")
129
+ text.append(" ", style="dim")
130
+ text.append(f"{self._event_type:12}", style=self._style)
131
+ text.append(" ", style="dim")
132
+
133
+ return text
134
+
135
+
136
+ class LogEventDescription(Static):
137
+ """Flexible-width description that wraps properly."""
138
+
139
+ DEFAULT_CSS = """
140
+ LogEventDescription {
141
+ width: 1fr;
142
+ }
143
+ """
144
+
145
+ def __init__(
146
+ self,
147
+ description: str,
148
+ full_description: str,
149
+ event_type: str,
150
+ style: str,
151
+ duration_str: str,
152
+ is_long: bool,
153
+ start_expanded: bool = False,
154
+ **kwargs,
155
+ ) -> None:
156
+ super().__init__(**kwargs)
157
+ self._description = description
158
+ self._full_description = full_description
159
+ self._event_type = event_type
160
+ self._style = style
161
+ self._duration_str = duration_str
162
+ self._is_long = is_long
163
+ self._is_collapsed = not start_expanded
164
+
165
+ def set_collapsed(self, collapsed: bool) -> None:
166
+ """Update collapsed state."""
167
+ self._is_collapsed = collapsed
168
+ self.refresh(layout=True)
169
+
170
+ def render(self):
171
+ """Render the description."""
172
+ if self._is_long and self._is_collapsed:
173
+ # Only take first line if multiline, then truncate
174
+ first_line = self._description.split("\n")[0]
175
+ if len(first_line) > 100:
176
+ first_line = first_line[:100] + "..."
177
+ text = Text(first_line, style=self._style)
178
+ text.append(" [+]", style="dim italic")
179
+ if self._duration_str:
180
+ text.append(f" {self._duration_str}", style="dim")
181
+ return text
182
+ else:
183
+ # Use full description when expanded
184
+ desc = self._full_description
185
+
186
+ # Check if this should be rendered as Markdown
187
+ is_markdown = self._event_type == "text"
188
+ # Also render "write: *.md" content as Markdown
189
+ if desc.startswith("write:") and ".md" in desc.split("\n")[0]:
190
+ is_markdown = True
191
+
192
+ if is_markdown:
193
+ md = Markdown(desc)
194
+ if self._is_long or self._duration_str:
195
+ suffix = Text()
196
+ if self._is_long:
197
+ suffix.append(" [-]", style="dim italic")
198
+ if self._duration_str:
199
+ suffix.append(f" {self._duration_str}", style="dim")
200
+ return Group(md, suffix)
201
+ return md
202
+ else:
203
+ text = Text(desc, style=self._style)
204
+ if self._is_long:
205
+ text.append(" [-]", style="dim italic")
206
+ if self._duration_str:
207
+ text.append(f" {self._duration_str}", style="dim")
208
+ return text
209
+
210
+
211
+ class LogEventWidget(Horizontal):
212
+ """
213
+ Single log event display using horizontal layout.
214
+
215
+ Shows timestamp, event type, and description with appropriate styling.
216
+ Long text content can be collapsed. Description wraps properly.
217
+ """
218
+
219
+ DEFAULT_CSS = """
220
+ LogEventWidget {
221
+ height: auto;
222
+ width: 100%;
223
+ }
224
+ """
225
+
226
+ is_collapsed: reactive[bool] = reactive(True)
227
+ is_selected: reactive[bool] = reactive(False)
228
+
229
+ def __init__(
230
+ self,
231
+ event: LogEvent,
232
+ global_start_ts: int | None,
233
+ start_expanded: bool = False,
234
+ **kwargs,
235
+ ) -> None:
236
+ """
237
+ Initialize event widget.
238
+
239
+ Parameters
240
+ ----------
241
+ event : LogEvent
242
+ The log event to display.
243
+ global_start_ts : int | None
244
+ Global start timestamp for relative time calculation, or None if unknown.
245
+ """
246
+ super().__init__(**kwargs)
247
+ self.event = event
248
+ self.global_start_ts = global_start_ts
249
+ self._start_expanded = start_expanded
250
+ # Check if description has multiple lines or is long
251
+ self._is_long = len(event.description) > 100 or "\n" in event.description
252
+
253
+ self._style = EVENT_STYLES.get(event.event_type, "white")
254
+ self._time_str = format_relative_time(event.timestamp, global_start_ts)
255
+ self._duration_str = format_duration(event.duration_ms)
256
+
257
+ def compose(self):
258
+ """Create child widgets."""
259
+ yield LogEventPrefix(
260
+ self._time_str,
261
+ self.event.event_type,
262
+ self._style,
263
+ )
264
+ yield LogEventDescription(
265
+ self.event.description,
266
+ self.event.full_description,
267
+ self.event.event_type,
268
+ self._style,
269
+ self._duration_str,
270
+ self._is_long,
271
+ start_expanded=self._start_expanded,
272
+ )
273
+
274
+ def on_mount(self) -> None:
275
+ """Apply initial expanded state after mounting."""
276
+ if self._start_expanded:
277
+ self.is_collapsed = False
278
+
279
+ def watch_is_collapsed(self, value: bool) -> None:
280
+ """Update description collapsed state."""
281
+ try:
282
+ desc = self.query_one(LogEventDescription)
283
+ desc.set_collapsed(value)
284
+ except Exception:
285
+ pass
286
+
287
+ def watch_is_selected(self, value: bool) -> None:
288
+ """Update prefix selected state."""
289
+ try:
290
+ prefix = self.query_one(LogEventPrefix)
291
+ prefix.set_selected(value)
292
+ except Exception:
293
+ pass
294
+
295
+ def toggle_collapse(self) -> None:
296
+ """Toggle collapsed state."""
297
+ if self._is_long:
298
+ self.is_collapsed = not self.is_collapsed
299
+
300
+
301
+ class LogView(VerticalScroll, can_focus=True):
302
+ """
303
+ Scrollable container of log events for selected agent.
304
+
305
+ Features:
306
+ - Real-time event appending
307
+ - Auto-scroll to bottom (toggleable)
308
+ - Collapsible long events
309
+ - Search highlighting
310
+ - Keyboard navigation with selection
311
+ """
312
+
313
+ # Override scroll bindings to use for message navigation instead
314
+ BINDINGS = [
315
+ ("up", "select_prev", "Previous"),
316
+ ("down", "select_next", "Next"),
317
+ ("enter", "toggle_expand", "Expand"),
318
+ ]
319
+
320
+ auto_scroll: reactive[bool] = reactive(True)
321
+ search_query: reactive[str] = reactive("")
322
+ selected_index: reactive[int] = reactive(-1)
323
+
324
+ def __init__(self, **kwargs) -> None:
325
+ super().__init__(**kwargs)
326
+ self._events: list[LogEvent] = []
327
+ self._global_start_ts: int | None = None
328
+ self._widgets: list[LogEventWidget] = []
329
+ self._expand_all_mode: bool = False
330
+
331
+ def watch_selected_index(self, old_index: int, new_index: int) -> None:
332
+ """Update selection visuals when index changes."""
333
+ if 0 <= old_index < len(self._widgets):
334
+ self._widgets[old_index].is_selected = False
335
+ if 0 <= new_index < len(self._widgets):
336
+ self._widgets[new_index].is_selected = True
337
+ self._widgets[new_index].scroll_visible()
338
+
339
+ def on_focus(self) -> None:
340
+ """Auto-select first event when focusing if none selected."""
341
+ if self.selected_index == -1 and self._widgets:
342
+ self.selected_index = 0
343
+
344
+ def set_events(
345
+ self,
346
+ events: list[LogEvent],
347
+ global_start_ts: int | None,
348
+ ) -> None:
349
+ """
350
+ Replace all events (when switching agents).
351
+
352
+ Parameters
353
+ ----------
354
+ events : list[LogEvent]
355
+ List of events to display.
356
+ global_start_ts : int | None
357
+ Global start timestamp for relative time (from main.log), or None if unknown.
358
+ """
359
+ self._events = list(events) # Copy to avoid shared reference with AgentState
360
+ self._global_start_ts = global_start_ts
361
+ self.selected_index = -1 # Reset selection
362
+ self._expand_all_mode = False # Reset expand state
363
+ self._rebuild_widgets()
364
+
365
+ def _rebuild_widgets(self) -> None:
366
+ """Rebuild all event widgets."""
367
+ # Clear existing
368
+ self.remove_children()
369
+ self._widgets = []
370
+
371
+ # Create new widgets
372
+ for event in self._events:
373
+ widget = LogEventWidget(event, self._global_start_ts)
374
+ self._widgets.append(widget)
375
+ self.mount(widget)
376
+
377
+ if self.auto_scroll:
378
+ self.scroll_end(animate=False)
379
+
380
+ def append_event(self, event: LogEvent) -> None:
381
+ """
382
+ Add new event (real-time update).
383
+
384
+ Parameters
385
+ ----------
386
+ event : LogEvent
387
+ Event to append.
388
+ """
389
+ self._events.append(event)
390
+ widget = LogEventWidget(
391
+ event,
392
+ self._global_start_ts,
393
+ start_expanded=self._expand_all_mode,
394
+ )
395
+ self._widgets.append(widget)
396
+ self.mount(widget)
397
+
398
+ if self.auto_scroll:
399
+ self.scroll_end(animate=False)
400
+
401
+ def expand_all(self) -> None:
402
+ """Expand all collapsible events."""
403
+ self._expand_all_mode = True
404
+ for widget in self._widgets:
405
+ widget.is_collapsed = False
406
+ self.refresh(layout=True)
407
+ if 0 <= self.selected_index < len(self._widgets):
408
+ self._widgets[self.selected_index].scroll_visible()
409
+
410
+ def collapse_all(self) -> None:
411
+ """Collapse all collapsible events."""
412
+ self._expand_all_mode = False
413
+ for widget in self._widgets:
414
+ widget.is_collapsed = True
415
+ self.refresh(layout=True)
416
+ if 0 <= self.selected_index < len(self._widgets):
417
+ self._widgets[self.selected_index].scroll_visible()
418
+
419
+ def highlight_search(self, query: str) -> list[int]:
420
+ """
421
+ Highlight search matches.
422
+
423
+ Parameters
424
+ ----------
425
+ query : str
426
+ Search query.
427
+
428
+ Returns
429
+ -------
430
+ list[int]
431
+ Indices of matching events.
432
+ """
433
+ self.search_query = query
434
+ matches: list[int] = []
435
+
436
+ if not query:
437
+ return matches
438
+
439
+ query_lower = query.lower()
440
+ for i, event in enumerate(self._events):
441
+ if query_lower in event.description.lower():
442
+ matches.append(i)
443
+
444
+ return matches
445
+
446
+ def scroll_to_event(self, index: int) -> None:
447
+ """
448
+ Scroll to event at index.
449
+
450
+ Parameters
451
+ ----------
452
+ index : int
453
+ Event index.
454
+ """
455
+ if 0 <= index < len(self._widgets):
456
+ self._widgets[index].scroll_visible()
457
+
458
+ def select_next(self) -> None:
459
+ """Select the next event."""
460
+ if not self._widgets:
461
+ return
462
+ if self.selected_index < len(self._widgets) - 1:
463
+ self.selected_index += 1
464
+ elif self.selected_index == -1:
465
+ self.selected_index = 0
466
+
467
+ def select_prev(self) -> None:
468
+ """Select the previous event."""
469
+ if not self._widgets:
470
+ return
471
+ if self.selected_index > 0:
472
+ self.selected_index -= 1
473
+ elif self.selected_index == -1:
474
+ self.selected_index = len(self._widgets) - 1
475
+
476
+ def toggle_selected(self) -> None:
477
+ """Toggle expand/collapse of selected event."""
478
+ if 0 <= self.selected_index < len(self._widgets):
479
+ self._widgets[self.selected_index].toggle_collapse()
480
+ self.refresh(layout=True)
481
+
482
+ def action_select_next(self) -> None:
483
+ """Action handler for down key."""
484
+ self.select_next()
485
+
486
+ def action_select_prev(self) -> None:
487
+ """Action handler for up key."""
488
+ self.select_prev()
489
+
490
+ def action_toggle_expand(self) -> None:
491
+ """Action handler for enter key."""
492
+ self.toggle_selected()
493
+
494
+ def get_selected_content(self) -> str | None:
495
+ """
496
+ Get the full content of the selected event.
497
+
498
+ Returns
499
+ -------
500
+ str | None
501
+ Full description of selected event, or None if nothing selected.
502
+ """
503
+ if 0 <= self.selected_index < len(self._events):
504
+ return self._events[self.selected_index].full_description
505
+ return None
@@ -0,0 +1,151 @@
1
+ """
2
+ Search popup overlay widget for the TUI.
3
+
4
+ Provides a popup search interface that appears on '/' key
5
+ and searches the current view (logs or file content).
6
+ """
7
+
8
+ from textual.containers import Horizontal
9
+ from textual.widgets import Input, Static
10
+ from textual.message import Message
11
+
12
+
13
+ class SearchPopup(Horizontal):
14
+ """
15
+ Search popup overlay that appears on '/' key.
16
+
17
+ Shows a search input with match count indicator.
18
+ Searches only the current active view (Logs or Files tab).
19
+ """
20
+
21
+ DEFAULT_CSS = """
22
+ SearchPopup {
23
+ layer: popup;
24
+ width: 50;
25
+ height: 3;
26
+ align: center middle;
27
+ background: $surface;
28
+ border: hkey $accent;
29
+ padding: 0 1;
30
+ display: none;
31
+ }
32
+
33
+ SearchPopup.visible {
34
+ display: block;
35
+ }
36
+
37
+ SearchPopup #search-input {
38
+ width: 1fr;
39
+ border: none;
40
+ }
41
+
42
+ SearchPopup #search-count {
43
+ width: auto;
44
+ min-width: 6;
45
+ padding: 0 1;
46
+ text-align: right;
47
+ }
48
+ """
49
+
50
+ class SearchSubmitted(Message):
51
+ """Message sent when search is submitted."""
52
+
53
+ def __init__(self, query: str) -> None:
54
+ self.query = query
55
+ super().__init__()
56
+
57
+ class SearchChanged(Message):
58
+ """Message sent when search text changes."""
59
+
60
+ def __init__(self, query: str) -> None:
61
+ self.query = query
62
+ super().__init__()
63
+
64
+ class SearchClosed(Message):
65
+ """Message sent when search popup is closed."""
66
+
67
+ pass
68
+
69
+ class NextMatch(Message):
70
+ """Message sent to jump to next match."""
71
+
72
+ pass
73
+
74
+ class PrevMatch(Message):
75
+ """Message sent to jump to previous match."""
76
+
77
+ pass
78
+
79
+ def __init__(self, **kwargs) -> None:
80
+ super().__init__(**kwargs)
81
+ self._match_index: int = 0
82
+ self._match_count: int = 0
83
+
84
+ def compose(self):
85
+ """Compose the widget."""
86
+ yield Input(placeholder="Search...", id="search-input")
87
+ yield Static("", id="search-count")
88
+
89
+ def show(self) -> None:
90
+ """Show the popup and focus the input."""
91
+ self.add_class("visible")
92
+ search_input = self.query_one("#search-input", Input)
93
+ search_input.focus()
94
+
95
+ def hide(self) -> None:
96
+ """Hide the popup and clear search."""
97
+ self.remove_class("visible")
98
+ search_input = self.query_one("#search-input", Input)
99
+ search_input.value = ""
100
+ self._match_index = 0
101
+ self._match_count = 0
102
+ self._update_count_display()
103
+ self.post_message(self.SearchClosed())
104
+
105
+ def is_visible(self) -> bool:
106
+ """Check if popup is currently visible."""
107
+ return self.has_class("visible")
108
+
109
+ def update_match_count(self, current: int, total: int) -> None:
110
+ """
111
+ Update the match count display.
112
+
113
+ Parameters
114
+ ----------
115
+ current : int
116
+ Current match index (1-based).
117
+ total : int
118
+ Total number of matches.
119
+ """
120
+ self._match_index = current
121
+ self._match_count = total
122
+ self._update_count_display()
123
+
124
+ def _update_count_display(self) -> None:
125
+ """Update the count label."""
126
+ count_label = self.query_one("#search-count", Static)
127
+ if self._match_count > 0:
128
+ count_label.update(f"{self._match_index}/{self._match_count}")
129
+ elif self.query_one("#search-input", Input).value:
130
+ count_label.update("0/0")
131
+ else:
132
+ count_label.update("")
133
+
134
+ def on_input_changed(self, event: Input.Changed) -> None:
135
+ """Handle input text changes."""
136
+ if event.input.id == "search-input":
137
+ self.post_message(self.SearchChanged(event.value))
138
+
139
+ def on_input_submitted(self, event: Input.Submitted) -> None:
140
+ """Handle Enter key - jump to next match."""
141
+ if event.input.id == "search-input":
142
+ self.post_message(self.NextMatch())
143
+
144
+ def on_key(self, event) -> None:
145
+ """Handle key events."""
146
+ if event.key == "escape":
147
+ self.hide()
148
+ event.stop()
149
+ elif event.key == "shift+enter":
150
+ self.post_message(self.PrevMatch())
151
+ event.stop()