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.
- dlab/__init__.py +6 -0
- dlab/cli.py +1075 -0
- dlab/config.py +190 -0
- dlab/create_dpack.py +1096 -0
- dlab/create_dpack_wizard.py +1471 -0
- dlab/create_parallel_agent_wizard.py +582 -0
- dlab/data/__init__.py +0 -0
- dlab/data/models.json +1793 -0
- dlab/docker.py +591 -0
- dlab/local.py +269 -0
- dlab/model_fallback.py +360 -0
- dlab/parallel_tool.py +18 -0
- dlab/session.py +389 -0
- dlab/timeline.py +684 -0
- dlab/tui/__init__.py +9 -0
- dlab/tui/app.py +664 -0
- dlab/tui/log_watcher.py +208 -0
- dlab/tui/models.py +438 -0
- dlab/tui/widgets/__init__.py +18 -0
- dlab/tui/widgets/agent_list.py +170 -0
- dlab/tui/widgets/artifacts_pane.py +618 -0
- dlab/tui/widgets/log_view.py +505 -0
- dlab/tui/widgets/search_popup.py +151 -0
- dlab/tui/widgets/status_bar.py +106 -0
- dlab_cli-0.1.0.dist-info/METADATA +237 -0
- dlab_cli-0.1.0.dist-info/RECORD +30 -0
- dlab_cli-0.1.0.dist-info/WHEEL +5 -0
- dlab_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dlab_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- dlab_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|