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
dlab/tui/app.py
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main Textual application for dlab connect TUI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from textual.app import App, ComposeResult
|
|
9
|
+
from textual.containers import Horizontal, Vertical
|
|
10
|
+
from textual.widgets import Header, Footer, Static, TabbedContent, TabPane
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.timer import Timer
|
|
13
|
+
|
|
14
|
+
from dlab.tui.widgets.agent_list import AgentSelector
|
|
15
|
+
from dlab.tui.widgets.log_view import LogView
|
|
16
|
+
from dlab.tui.widgets.status_bar import StatusBar
|
|
17
|
+
from dlab.tui.widgets.artifacts_pane import ArtifactList, FileViewer
|
|
18
|
+
from dlab.tui.widgets.search_popup import SearchPopup
|
|
19
|
+
from dlab.tui.log_watcher import LogWatcher
|
|
20
|
+
from dlab.tui.models import SessionState, LogEvent
|
|
21
|
+
from dlab.timeline import (
|
|
22
|
+
is_log_complete,
|
|
23
|
+
discover_agents,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_default_agent(work_dir: Path) -> str | None:
|
|
28
|
+
"""
|
|
29
|
+
Load default agent name from opencode.json.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
work_dir : Path
|
|
34
|
+
Work directory path.
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
str | None
|
|
39
|
+
Default agent name or None if not found.
|
|
40
|
+
"""
|
|
41
|
+
opencode_json = work_dir / ".opencode" / "opencode.json"
|
|
42
|
+
if opencode_json.exists():
|
|
43
|
+
try:
|
|
44
|
+
data = json.loads(opencode_json.read_text())
|
|
45
|
+
return data.get("default_agent")
|
|
46
|
+
except (json.JSONDecodeError, IOError):
|
|
47
|
+
pass
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_global_start_ts(logs_dir: Path) -> int | None:
|
|
52
|
+
"""
|
|
53
|
+
Get the global start timestamp from main.log.
|
|
54
|
+
|
|
55
|
+
The global start is defined as the first timestamp in main.log.
|
|
56
|
+
This is used as the reference point for ALL relative timestamps
|
|
57
|
+
across all agents in the session.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
logs_dir : Path
|
|
62
|
+
Path to _opencode_logs directory.
|
|
63
|
+
|
|
64
|
+
Returns
|
|
65
|
+
-------
|
|
66
|
+
int | None
|
|
67
|
+
First timestamp from main.log in milliseconds, or None if not found.
|
|
68
|
+
"""
|
|
69
|
+
main_log = logs_dir / "main.log"
|
|
70
|
+
if not main_log.exists():
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
with open(main_log, "r") as f:
|
|
75
|
+
for line in f:
|
|
76
|
+
line = line.strip()
|
|
77
|
+
if not line or not line.startswith("{"):
|
|
78
|
+
continue
|
|
79
|
+
try:
|
|
80
|
+
data = json.loads(line)
|
|
81
|
+
ts = data.get("timestamp")
|
|
82
|
+
if ts and isinstance(ts, int) and ts > 0:
|
|
83
|
+
return ts
|
|
84
|
+
except json.JSONDecodeError:
|
|
85
|
+
continue
|
|
86
|
+
except (IOError, OSError):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ConnectApp(App):
|
|
93
|
+
"""
|
|
94
|
+
TUI application for monitoring running dlab sessions.
|
|
95
|
+
|
|
96
|
+
Layout:
|
|
97
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
98
|
+
│ Header: dlab connect - {work_dir} │
|
|
99
|
+
├──────────────┬──────────────────────────────────────────────┤
|
|
100
|
+
│ Agents │ [Logs] [Files] ← TabbedContent │
|
|
101
|
+
│ ● main-poet │ ─────────────────────────────────────────────│
|
|
102
|
+
│ ○ inst-1 │ + 0.0s | step_start | Started │
|
|
103
|
+
│ ○ inst-2 │ + 1.2s | text | I'll help... │
|
|
104
|
+
│──────────────│ + 5.0s | tool_use | write: ... │
|
|
105
|
+
│ Files │ │
|
|
106
|
+
│ 📄 poem.md │ (scrollable content) │
|
|
107
|
+
│ 🐍 script.py │ │
|
|
108
|
+
├──────────────┴──────────────────────────────────────────────┤
|
|
109
|
+
│ RUNNING | Cost: $0.05 | Duration: 45s | Agent: main-poet │
|
|
110
|
+
└─────────────────────────────────────────────────────────────┘
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
CSS = """
|
|
114
|
+
#main-container {
|
|
115
|
+
height: 1fr;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#left-sidebar {
|
|
119
|
+
width: 28;
|
|
120
|
+
min-width: 20;
|
|
121
|
+
max-width: 36;
|
|
122
|
+
border-right: vkey $surface-lighten-1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.section-header {
|
|
126
|
+
height: 1;
|
|
127
|
+
padding: 0 1;
|
|
128
|
+
color: $text-muted;
|
|
129
|
+
text-style: bold;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
AgentSelector {
|
|
133
|
+
height: 1fr;
|
|
134
|
+
padding: 0 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
ArtifactList {
|
|
138
|
+
height: 1fr;
|
|
139
|
+
padding: 0 1;
|
|
140
|
+
border-top: hkey $surface-lighten-1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#main-tabs {
|
|
144
|
+
width: 1fr;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* Flatten tab underline bar */
|
|
148
|
+
Underline > .underline--bar {
|
|
149
|
+
background: $foreground 5%;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
LogView {
|
|
153
|
+
padding: 0 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
FileViewer {
|
|
157
|
+
padding: 0 1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
StatusBar {
|
|
161
|
+
height: 1;
|
|
162
|
+
padding: 0 1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* Search popup overlay */
|
|
166
|
+
SearchPopup {
|
|
167
|
+
dock: bottom;
|
|
168
|
+
margin-bottom: 2;
|
|
169
|
+
margin-left: 28;
|
|
170
|
+
}
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
BINDINGS = [
|
|
174
|
+
Binding("q", "quit", "Quit"),
|
|
175
|
+
Binding("/", "show_search", "Search"),
|
|
176
|
+
Binding("escape", "hide_search", "Close", show=False),
|
|
177
|
+
Binding("e", "expand_all", "Expand All"),
|
|
178
|
+
Binding("c", "collapse_all", "Collapse All"),
|
|
179
|
+
Binding("o", "open_file", "Open"),
|
|
180
|
+
Binding("y", "yank_log", "Yank"),
|
|
181
|
+
Binding("f", "flush_clip", "Flush"),
|
|
182
|
+
Binding("j", "next_agent", "Next Agent", show=False),
|
|
183
|
+
Binding("k", "prev_agent", "Prev Agent", show=False),
|
|
184
|
+
Binding("left", "focus_sidebar", "Sidebar"),
|
|
185
|
+
Binding("right", "focus_main", "Main"),
|
|
186
|
+
Binding("tab", "cycle_sidebar_focus", "Cycle", show=False),
|
|
187
|
+
Binding("up", "prev_item", "Up", show=False),
|
|
188
|
+
Binding("down", "next_item", "Down", show=False),
|
|
189
|
+
Binding("enter", "select_item", "Select", show=False),
|
|
190
|
+
Binding("1", "show_logs_tab", "Logs", show=False),
|
|
191
|
+
Binding("2", "show_files_tab", "Files", show=False),
|
|
192
|
+
Binding("n", "next_match", "Next Match", show=False),
|
|
193
|
+
Binding("N", "prev_match", "Prev Match", show=False),
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
TITLE = "dlab connect"
|
|
197
|
+
theme = "monokai"
|
|
198
|
+
|
|
199
|
+
def __init__(self, work_dir: Path) -> None:
|
|
200
|
+
super().__init__()
|
|
201
|
+
self._work_dir = work_dir
|
|
202
|
+
self._logs_dir = work_dir / "_opencode_logs"
|
|
203
|
+
self._state = SessionState(work_dir=work_dir)
|
|
204
|
+
self._watcher: LogWatcher | None = None
|
|
205
|
+
self._selected_agent: str | None = None
|
|
206
|
+
self._known_agents: set[str] = set()
|
|
207
|
+
self._update_timer: Timer | None = None
|
|
208
|
+
self._default_agent = load_default_agent(work_dir)
|
|
209
|
+
self._search_matches: list[int] = []
|
|
210
|
+
self._current_match_index: int = 0
|
|
211
|
+
|
|
212
|
+
def compose(self) -> ComposeResult:
|
|
213
|
+
"""Create child widgets."""
|
|
214
|
+
yield Header(show_clock=False)
|
|
215
|
+
|
|
216
|
+
with Horizontal(id="main-container"):
|
|
217
|
+
with Vertical(id="left-sidebar"):
|
|
218
|
+
yield Static("Agents", classes="section-header")
|
|
219
|
+
yield AgentSelector(id="agent-selector")
|
|
220
|
+
yield Static("Files", classes="section-header")
|
|
221
|
+
yield ArtifactList(self._work_dir, id="artifact-list")
|
|
222
|
+
|
|
223
|
+
with TabbedContent(id="main-tabs"):
|
|
224
|
+
with TabPane("Logs", id="logs-tab"):
|
|
225
|
+
yield LogView(id="log-view")
|
|
226
|
+
with TabPane("Files", id="files-tab"):
|
|
227
|
+
yield FileViewer(id="file-viewer")
|
|
228
|
+
|
|
229
|
+
yield SearchPopup(id="search-popup")
|
|
230
|
+
yield StatusBar(id="status-bar")
|
|
231
|
+
yield Footer()
|
|
232
|
+
|
|
233
|
+
async def on_mount(self) -> None:
|
|
234
|
+
"""Initialize on app mount."""
|
|
235
|
+
self.title = f"dlab connect - {self._work_dir.name}"
|
|
236
|
+
|
|
237
|
+
# Discover known agents
|
|
238
|
+
opencode_dir = self._work_dir / ".opencode"
|
|
239
|
+
if opencode_dir.exists():
|
|
240
|
+
self._known_agents = discover_agents(opencode_dir)
|
|
241
|
+
|
|
242
|
+
# Check if job is running
|
|
243
|
+
main_log = self._logs_dir / "main.log"
|
|
244
|
+
self._state.is_job_running = main_log.exists() and not is_log_complete(main_log)
|
|
245
|
+
|
|
246
|
+
# Get global start timestamp from main.log FIRST
|
|
247
|
+
# This is the authoritative reference for all relative timestamps
|
|
248
|
+
self._state.global_start_ts = get_global_start_ts(self._logs_dir)
|
|
249
|
+
|
|
250
|
+
# Start log watcher
|
|
251
|
+
self._watcher = LogWatcher(self._logs_dir)
|
|
252
|
+
self._watcher.start()
|
|
253
|
+
|
|
254
|
+
# Poll for initial events (populates queue before processing)
|
|
255
|
+
self._watcher.poll()
|
|
256
|
+
|
|
257
|
+
# Process initial events
|
|
258
|
+
self._process_pending_events()
|
|
259
|
+
|
|
260
|
+
# Select first agent
|
|
261
|
+
agent_selector = self.query_one("#agent-selector", AgentSelector)
|
|
262
|
+
agent_selector.select_first()
|
|
263
|
+
|
|
264
|
+
# Show placeholder in file viewer
|
|
265
|
+
file_viewer = self.query_one("#file-viewer", FileViewer)
|
|
266
|
+
file_viewer.show_placeholder()
|
|
267
|
+
|
|
268
|
+
# Start periodic update timer
|
|
269
|
+
self._update_timer = self.set_interval(0.5, self._on_update_tick)
|
|
270
|
+
|
|
271
|
+
async def on_unmount(self) -> None:
|
|
272
|
+
"""Cleanup on app unmount."""
|
|
273
|
+
if self._watcher:
|
|
274
|
+
self._watcher.stop()
|
|
275
|
+
if self._update_timer:
|
|
276
|
+
self._update_timer.stop()
|
|
277
|
+
|
|
278
|
+
def _get_display_name(self, source: str) -> str:
|
|
279
|
+
"""
|
|
280
|
+
Get display name for a source, renaming 'main' to 'main-{agent}'.
|
|
281
|
+
|
|
282
|
+
Parameters
|
|
283
|
+
----------
|
|
284
|
+
source : str
|
|
285
|
+
Original source name from log file.
|
|
286
|
+
|
|
287
|
+
Returns
|
|
288
|
+
-------
|
|
289
|
+
str
|
|
290
|
+
Display name for the agent.
|
|
291
|
+
"""
|
|
292
|
+
if source == "main" and self._default_agent:
|
|
293
|
+
return f"main-{self._default_agent}"
|
|
294
|
+
return source
|
|
295
|
+
|
|
296
|
+
def _process_pending_events(self) -> None:
|
|
297
|
+
"""Process any pending events from the watcher."""
|
|
298
|
+
if not self._watcher:
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
events = self._watcher.get_events()
|
|
302
|
+
for source, raw_event in events:
|
|
303
|
+
display_name = self._get_display_name(source)
|
|
304
|
+
event = LogEvent.from_raw(raw_event, display_name)
|
|
305
|
+
agent_state = self._state.get_or_create_agent(display_name)
|
|
306
|
+
|
|
307
|
+
was_added = agent_state.add_event(event)
|
|
308
|
+
|
|
309
|
+
if was_added:
|
|
310
|
+
# Only update global_start_ts with valid timestamps (not 0)
|
|
311
|
+
# raw_text and additional_output events have timestamp=0
|
|
312
|
+
if event.timestamp > 0 and (
|
|
313
|
+
self._state.global_start_ts is None
|
|
314
|
+
or event.timestamp < self._state.global_start_ts
|
|
315
|
+
):
|
|
316
|
+
self._state.global_start_ts = event.timestamp
|
|
317
|
+
|
|
318
|
+
if display_name == self._selected_agent:
|
|
319
|
+
log_view = self.query_one("#log-view", LogView)
|
|
320
|
+
log_view.append_event(event)
|
|
321
|
+
|
|
322
|
+
if events:
|
|
323
|
+
self._update_agent_list()
|
|
324
|
+
self._update_status_bar()
|
|
325
|
+
|
|
326
|
+
def _get_log_path(self, display_name: str) -> Path:
|
|
327
|
+
"""
|
|
328
|
+
Get the log file path for a display name.
|
|
329
|
+
|
|
330
|
+
Parameters
|
|
331
|
+
----------
|
|
332
|
+
display_name : str
|
|
333
|
+
Display name of the agent.
|
|
334
|
+
|
|
335
|
+
Returns
|
|
336
|
+
-------
|
|
337
|
+
Path
|
|
338
|
+
Path to the log file.
|
|
339
|
+
"""
|
|
340
|
+
if display_name.startswith("main-"):
|
|
341
|
+
return self._logs_dir / "main.log"
|
|
342
|
+
|
|
343
|
+
log_path = self._logs_dir / f"{display_name}.log"
|
|
344
|
+
if log_path.exists():
|
|
345
|
+
return log_path
|
|
346
|
+
|
|
347
|
+
parts = display_name.split("/")
|
|
348
|
+
if len(parts) == 2:
|
|
349
|
+
return self._logs_dir / parts[0] / f"{parts[1]}.log"
|
|
350
|
+
|
|
351
|
+
return log_path
|
|
352
|
+
|
|
353
|
+
def _update_agent_list(self) -> None:
|
|
354
|
+
"""Update the agent selector with current state."""
|
|
355
|
+
agent_selector = self.query_one("#agent-selector", AgentSelector)
|
|
356
|
+
|
|
357
|
+
def sort_by_start_time(name: str) -> int:
|
|
358
|
+
agent_state = self._state.agents.get(name)
|
|
359
|
+
if agent_state and agent_state.start_time:
|
|
360
|
+
return int(agent_state.start_time.timestamp() * 1000)
|
|
361
|
+
return 0
|
|
362
|
+
|
|
363
|
+
agents = sorted(self._state.agents.keys(), key=sort_by_start_time)
|
|
364
|
+
|
|
365
|
+
running: set[str] = set()
|
|
366
|
+
main_display_name = self._get_display_name("main")
|
|
367
|
+
|
|
368
|
+
# Check if main agent is complete — if so, all sub-agents are done
|
|
369
|
+
# (sub-agents may lack a clean stop event if the container was killed)
|
|
370
|
+
main_log = self._get_log_path("main")
|
|
371
|
+
main_complete = main_log.exists() and is_log_complete(main_log)
|
|
372
|
+
|
|
373
|
+
if not main_complete:
|
|
374
|
+
for name in self._state.agents.keys():
|
|
375
|
+
log_path = self._get_log_path(name)
|
|
376
|
+
if log_path.exists() and not is_log_complete(log_path):
|
|
377
|
+
running.add(name)
|
|
378
|
+
|
|
379
|
+
agent_selector.update_agents(agents, running)
|
|
380
|
+
|
|
381
|
+
self._state.is_job_running = main_display_name in running
|
|
382
|
+
|
|
383
|
+
def _update_status_bar(self) -> None:
|
|
384
|
+
"""Update the status bar."""
|
|
385
|
+
status_bar = self.query_one("#status-bar", StatusBar)
|
|
386
|
+
status_bar.update_status(
|
|
387
|
+
is_running=self._state.is_job_running,
|
|
388
|
+
cost=self._state.total_cost,
|
|
389
|
+
duration=self._state.duration_seconds,
|
|
390
|
+
agent=self._selected_agent,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def _on_update_tick(self) -> None:
|
|
394
|
+
"""Periodic update tick."""
|
|
395
|
+
# Poll log files directly as fallback for unreliable watchdog events
|
|
396
|
+
# (especially on macOS where FSEvents can miss Docker/atomic writes)
|
|
397
|
+
if self._watcher:
|
|
398
|
+
self._watcher.poll()
|
|
399
|
+
|
|
400
|
+
self._process_pending_events()
|
|
401
|
+
self._update_status_bar()
|
|
402
|
+
|
|
403
|
+
# Refresh artifacts periodically (detect new files created during execution)
|
|
404
|
+
artifact_list = self.query_one("#artifact-list", ArtifactList)
|
|
405
|
+
artifact_list.refresh_if_changed()
|
|
406
|
+
|
|
407
|
+
def on_agent_selector_agent_selected(
|
|
408
|
+
self, event: AgentSelector.AgentSelected
|
|
409
|
+
) -> None:
|
|
410
|
+
"""Handle agent selection."""
|
|
411
|
+
self._selected_agent = event.agent_name
|
|
412
|
+
|
|
413
|
+
log_view = self.query_one("#log-view", LogView)
|
|
414
|
+
|
|
415
|
+
if event.agent_name in self._state.agents:
|
|
416
|
+
agent_state = self._state.agents[event.agent_name]
|
|
417
|
+
# Use global_start_ts from main.log - this is authoritative
|
|
418
|
+
# NEVER use 0 - if global_start_ts is None, we have no valid reference
|
|
419
|
+
start_ts = self._state.global_start_ts
|
|
420
|
+
if start_ts is None:
|
|
421
|
+
# Fallback: try to get it again from main.log
|
|
422
|
+
start_ts = get_global_start_ts(self._logs_dir)
|
|
423
|
+
if start_ts:
|
|
424
|
+
self._state.global_start_ts = start_ts
|
|
425
|
+
log_view.set_events(
|
|
426
|
+
agent_state.events,
|
|
427
|
+
start_ts, # Can be None - LogView must handle this
|
|
428
|
+
)
|
|
429
|
+
else:
|
|
430
|
+
log_view.set_events([], self._state.global_start_ts)
|
|
431
|
+
|
|
432
|
+
# Update artifact list for selected agent
|
|
433
|
+
artifact_list = self.query_one("#artifact-list", ArtifactList)
|
|
434
|
+
artifact_list.set_agent(event.agent_name)
|
|
435
|
+
|
|
436
|
+
self._update_status_bar()
|
|
437
|
+
|
|
438
|
+
def on_artifact_list_file_selected(self, event: ArtifactList.FileSelected) -> None:
|
|
439
|
+
"""Handle file selection from artifact list."""
|
|
440
|
+
# Switch to Files tab
|
|
441
|
+
tabs = self.query_one("#main-tabs", TabbedContent)
|
|
442
|
+
tabs.active = "files-tab"
|
|
443
|
+
|
|
444
|
+
# Show file content
|
|
445
|
+
file_viewer = self.query_one("#file-viewer", FileViewer)
|
|
446
|
+
file_viewer.show_file(event.path)
|
|
447
|
+
|
|
448
|
+
def on_search_popup_search_changed(self, event: SearchPopup.SearchChanged) -> None:
|
|
449
|
+
"""Handle search text changes."""
|
|
450
|
+
self._perform_search(event.query)
|
|
451
|
+
|
|
452
|
+
def on_search_popup_next_match(self, event: SearchPopup.NextMatch) -> None:
|
|
453
|
+
"""Jump to next search match."""
|
|
454
|
+
if self._search_matches:
|
|
455
|
+
self._current_match_index = (self._current_match_index + 1) % len(
|
|
456
|
+
self._search_matches
|
|
457
|
+
)
|
|
458
|
+
self._jump_to_match()
|
|
459
|
+
|
|
460
|
+
def on_search_popup_prev_match(self, event: SearchPopup.PrevMatch) -> None:
|
|
461
|
+
"""Jump to previous search match."""
|
|
462
|
+
if self._search_matches:
|
|
463
|
+
self._current_match_index = (self._current_match_index - 1) % len(
|
|
464
|
+
self._search_matches
|
|
465
|
+
)
|
|
466
|
+
self._jump_to_match()
|
|
467
|
+
|
|
468
|
+
def on_search_popup_search_closed(self, event: SearchPopup.SearchClosed) -> None:
|
|
469
|
+
"""Handle search popup closed."""
|
|
470
|
+
log_view = self.query_one("#log-view", LogView)
|
|
471
|
+
log_view.highlight_search("")
|
|
472
|
+
self._search_matches = []
|
|
473
|
+
self._current_match_index = 0
|
|
474
|
+
|
|
475
|
+
def _perform_search(self, query: str) -> None:
|
|
476
|
+
"""Perform search on current view."""
|
|
477
|
+
tabs = self.query_one("#main-tabs", TabbedContent)
|
|
478
|
+
search_popup = self.query_one("#search-popup", SearchPopup)
|
|
479
|
+
|
|
480
|
+
if tabs.active == "logs-tab":
|
|
481
|
+
log_view = self.query_one("#log-view", LogView)
|
|
482
|
+
self._search_matches = log_view.highlight_search(query)
|
|
483
|
+
self._current_match_index = 0
|
|
484
|
+
|
|
485
|
+
if self._search_matches:
|
|
486
|
+
search_popup.update_match_count(1, len(self._search_matches))
|
|
487
|
+
log_view.scroll_to_event(self._search_matches[0])
|
|
488
|
+
else:
|
|
489
|
+
search_popup.update_match_count(0, 0)
|
|
490
|
+
else:
|
|
491
|
+
# TODO: Implement file content search
|
|
492
|
+
self._search_matches = []
|
|
493
|
+
search_popup.update_match_count(0, 0)
|
|
494
|
+
|
|
495
|
+
def _jump_to_match(self) -> None:
|
|
496
|
+
"""Jump to current match index."""
|
|
497
|
+
if not self._search_matches:
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
search_popup = self.query_one("#search-popup", SearchPopup)
|
|
501
|
+
search_popup.update_match_count(
|
|
502
|
+
self._current_match_index + 1, len(self._search_matches)
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
tabs = self.query_one("#main-tabs", TabbedContent)
|
|
506
|
+
if tabs.active == "logs-tab":
|
|
507
|
+
log_view = self.query_one("#log-view", LogView)
|
|
508
|
+
log_view.scroll_to_event(self._search_matches[self._current_match_index])
|
|
509
|
+
|
|
510
|
+
def action_show_search(self) -> None:
|
|
511
|
+
"""Show the search popup."""
|
|
512
|
+
search_popup = self.query_one("#search-popup", SearchPopup)
|
|
513
|
+
search_popup.show()
|
|
514
|
+
|
|
515
|
+
def action_hide_search(self) -> None:
|
|
516
|
+
"""Hide the search popup."""
|
|
517
|
+
search_popup = self.query_one("#search-popup", SearchPopup)
|
|
518
|
+
if search_popup.is_visible():
|
|
519
|
+
search_popup.hide()
|
|
520
|
+
|
|
521
|
+
def action_expand_all(self) -> None:
|
|
522
|
+
"""Expand all log events."""
|
|
523
|
+
log_view = self.query_one("#log-view", LogView)
|
|
524
|
+
log_view.expand_all()
|
|
525
|
+
|
|
526
|
+
def action_collapse_all(self) -> None:
|
|
527
|
+
"""Collapse all log events."""
|
|
528
|
+
log_view = self.query_one("#log-view", LogView)
|
|
529
|
+
log_view.collapse_all()
|
|
530
|
+
|
|
531
|
+
def action_next_agent(self) -> None:
|
|
532
|
+
"""Select next agent."""
|
|
533
|
+
agent_selector = self.query_one("#agent-selector", AgentSelector)
|
|
534
|
+
agent_selector.action_cursor_down()
|
|
535
|
+
|
|
536
|
+
def action_prev_agent(self) -> None:
|
|
537
|
+
"""Select previous agent."""
|
|
538
|
+
agent_selector = self.query_one("#agent-selector", AgentSelector)
|
|
539
|
+
agent_selector.action_cursor_up()
|
|
540
|
+
|
|
541
|
+
def action_focus_sidebar(self) -> None:
|
|
542
|
+
"""Focus the left sidebar (agent selector)."""
|
|
543
|
+
agent_selector = self.query_one("#agent-selector", AgentSelector)
|
|
544
|
+
agent_selector.focus()
|
|
545
|
+
|
|
546
|
+
def action_focus_main(self) -> None:
|
|
547
|
+
"""Focus the main area (current tab content)."""
|
|
548
|
+
tabs = self.query_one("#main-tabs", TabbedContent)
|
|
549
|
+
if tabs.active == "logs-tab":
|
|
550
|
+
log_view = self.query_one("#log-view", LogView)
|
|
551
|
+
log_view.focus()
|
|
552
|
+
else:
|
|
553
|
+
file_viewer = self.query_one("#file-viewer", FileViewer)
|
|
554
|
+
file_viewer.focus()
|
|
555
|
+
|
|
556
|
+
def action_cycle_sidebar_focus(self) -> None:
|
|
557
|
+
"""Cycle focus between agent selector and artifact list."""
|
|
558
|
+
focused = self.focused
|
|
559
|
+
agent_selector = self.query_one("#agent-selector", AgentSelector)
|
|
560
|
+
artifact_list = self.query_one("#artifact-list", ArtifactList)
|
|
561
|
+
|
|
562
|
+
if focused == agent_selector:
|
|
563
|
+
artifact_list.focus()
|
|
564
|
+
elif focused == artifact_list:
|
|
565
|
+
agent_selector.focus()
|
|
566
|
+
else:
|
|
567
|
+
agent_selector.focus()
|
|
568
|
+
|
|
569
|
+
def action_prev_item(self) -> None:
|
|
570
|
+
"""Navigate to previous item in focused pane."""
|
|
571
|
+
focused = self.focused
|
|
572
|
+
if isinstance(focused, AgentSelector):
|
|
573
|
+
focused.action_cursor_up()
|
|
574
|
+
elif isinstance(focused, LogView):
|
|
575
|
+
focused.select_prev()
|
|
576
|
+
elif isinstance(focused, ArtifactList):
|
|
577
|
+
focused.action_cursor_up()
|
|
578
|
+
|
|
579
|
+
def action_next_item(self) -> None:
|
|
580
|
+
"""Navigate to next item in focused pane."""
|
|
581
|
+
focused = self.focused
|
|
582
|
+
if isinstance(focused, AgentSelector):
|
|
583
|
+
focused.action_cursor_down()
|
|
584
|
+
elif isinstance(focused, LogView):
|
|
585
|
+
focused.select_next()
|
|
586
|
+
elif isinstance(focused, ArtifactList):
|
|
587
|
+
focused.action_cursor_down()
|
|
588
|
+
|
|
589
|
+
def action_select_item(self) -> None:
|
|
590
|
+
"""Select/expand current item."""
|
|
591
|
+
focused = self.focused
|
|
592
|
+
if isinstance(focused, LogView):
|
|
593
|
+
focused.toggle_selected()
|
|
594
|
+
elif isinstance(focused, AgentSelector):
|
|
595
|
+
log_view = self.query_one("#log-view", LogView)
|
|
596
|
+
log_view.toggle_selected()
|
|
597
|
+
elif isinstance(focused, ArtifactList):
|
|
598
|
+
# Trigger file selection via ListView's built-in selection
|
|
599
|
+
pass
|
|
600
|
+
|
|
601
|
+
def action_show_logs_tab(self) -> None:
|
|
602
|
+
"""Switch to Logs tab."""
|
|
603
|
+
tabs = self.query_one("#main-tabs", TabbedContent)
|
|
604
|
+
tabs.active = "logs-tab"
|
|
605
|
+
|
|
606
|
+
def action_show_files_tab(self) -> None:
|
|
607
|
+
"""Switch to Files tab."""
|
|
608
|
+
tabs = self.query_one("#main-tabs", TabbedContent)
|
|
609
|
+
tabs.active = "files-tab"
|
|
610
|
+
|
|
611
|
+
def action_next_match(self) -> None:
|
|
612
|
+
"""Jump to next search match."""
|
|
613
|
+
search_popup = self.query_one("#search-popup", SearchPopup)
|
|
614
|
+
if search_popup.is_visible() and self._search_matches:
|
|
615
|
+
self._current_match_index = (self._current_match_index + 1) % len(
|
|
616
|
+
self._search_matches
|
|
617
|
+
)
|
|
618
|
+
self._jump_to_match()
|
|
619
|
+
|
|
620
|
+
def action_prev_match(self) -> None:
|
|
621
|
+
"""Jump to previous search match."""
|
|
622
|
+
search_popup = self.query_one("#search-popup", SearchPopup)
|
|
623
|
+
if search_popup.is_visible() and self._search_matches:
|
|
624
|
+
self._current_match_index = (self._current_match_index - 1) % len(
|
|
625
|
+
self._search_matches
|
|
626
|
+
)
|
|
627
|
+
self._jump_to_match()
|
|
628
|
+
|
|
629
|
+
def action_open_file(self) -> None:
|
|
630
|
+
"""Open the highlighted file in the system's default viewer."""
|
|
631
|
+
artifact_list = self.query_one("#artifact-list", ArtifactList)
|
|
632
|
+
artifact_list.open_highlighted()
|
|
633
|
+
|
|
634
|
+
def action_yank_log(self) -> None:
|
|
635
|
+
"""Append selected log event content to /tmp/clip.txt."""
|
|
636
|
+
log_view = self.query_one("#log-view", LogView)
|
|
637
|
+
content = log_view.get_selected_content()
|
|
638
|
+
|
|
639
|
+
if not content:
|
|
640
|
+
self.notify("No event selected (use ↑↓ to select)", timeout=2)
|
|
641
|
+
return
|
|
642
|
+
|
|
643
|
+
# Append to clip file
|
|
644
|
+
clip_file = "/tmp/clip.txt"
|
|
645
|
+
with open(clip_file, "a") as f:
|
|
646
|
+
f.write(content)
|
|
647
|
+
f.write("\n\n---\n\n") # Separator between entries
|
|
648
|
+
|
|
649
|
+
# Count entries
|
|
650
|
+
try:
|
|
651
|
+
with open(clip_file, "r") as f:
|
|
652
|
+
count = f.read().count("---")
|
|
653
|
+
except Exception:
|
|
654
|
+
count = 1
|
|
655
|
+
|
|
656
|
+
preview = content[:40].replace("\n", " ") + "..."
|
|
657
|
+
self.notify(f"Yanked ({count}): {preview}", timeout=2)
|
|
658
|
+
|
|
659
|
+
def action_flush_clip(self) -> None:
|
|
660
|
+
"""Clear /tmp/clip.txt."""
|
|
661
|
+
clip_file = "/tmp/clip.txt"
|
|
662
|
+
with open(clip_file, "w") as f:
|
|
663
|
+
pass # Empty the file
|
|
664
|
+
self.notify("Flushed /tmp/clip.txt", timeout=2)
|