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/timeline.py ADDED
@@ -0,0 +1,684 @@
1
+ """
2
+ Timeline parsing and visualization for dlab sessions.
3
+
4
+ This module parses OpenCode log files and constructs execution timelines
5
+ with Gantt chart visualization showing parallel agent execution.
6
+
7
+ Supports both completed and running jobs - running jobs show agents
8
+ with "RUNNING" status and extend to current time in the Gantt chart.
9
+ """
10
+
11
+ import json
12
+ import re
13
+ import sys
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+
19
+ def ms_to_datetime(timestamp_ms: int) -> datetime:
20
+ """Convert millisecond timestamp to datetime."""
21
+ return datetime.fromtimestamp(timestamp_ms / 1000)
22
+
23
+
24
+ def is_log_complete(log_path: Path) -> bool:
25
+ """
26
+ Check if a log file represents a completed run.
27
+
28
+ A log is complete if:
29
+ - Its last step_finish event has reason: "stop" or "error", OR
30
+ - It contains an "error" event (job crashed/terminated)
31
+
32
+ Running logs have step_finish events with reason: "tool-calls".
33
+
34
+ Parameters
35
+ ----------
36
+ log_path : Path
37
+ Path to the log file.
38
+
39
+ Returns
40
+ -------
41
+ bool
42
+ True if the log shows a completed run, False if still running.
43
+ """
44
+ if not log_path.exists():
45
+ return False
46
+
47
+ last_step_finish: dict[str, Any] | None = None
48
+ has_error: bool = False
49
+
50
+ with open(log_path, "r") as f:
51
+ for line in f:
52
+ line = line.strip()
53
+ if not line or line.startswith("[STDERR]"):
54
+ continue
55
+ try:
56
+ data = json.loads(line)
57
+ event_type = data.get("type")
58
+ if event_type == "step_finish":
59
+ last_step_finish = data
60
+ elif event_type == "error":
61
+ has_error = True
62
+ except json.JSONDecodeError:
63
+ continue
64
+
65
+ # Error event means job terminated (crashed)
66
+ if has_error:
67
+ return True
68
+
69
+ if last_step_finish is None:
70
+ return False
71
+
72
+ reason = last_step_finish.get("part", {}).get("reason", "")
73
+ return reason in ("stop", "error")
74
+
75
+
76
+ def natural_sort_key(name: str) -> tuple:
77
+ """
78
+ Sort key for natural ordering.
79
+
80
+ Orders: main first, task subagents, instance-1..N, consolidator last.
81
+
82
+ Parameters
83
+ ----------
84
+ name : str
85
+ Log source name to sort.
86
+
87
+ Returns
88
+ -------
89
+ tuple
90
+ Sort key tuple.
91
+ """
92
+ if name == "main":
93
+ return (0, 0, "")
94
+ if name == "consolidator":
95
+ return (4, 0, "")
96
+
97
+ # Check for task subagent pattern (e.g., "popo-poet (task)")
98
+ if name.endswith(" (task)"):
99
+ return (1, 0, name)
100
+
101
+ # Check for instance-N pattern
102
+ match = re.match(r"instance-(\d+)", name)
103
+ if match:
104
+ return (2, int(match.group(1)), "")
105
+
106
+ # Check for parallel run directories (e.g., modeler-parallel-run-123456)
107
+ match = re.match(r"(.+)-parallel-run-(\d+)", name)
108
+ if match:
109
+ return (3, int(match.group(2)), match.group(1))
110
+
111
+ # Default: alphabetical
112
+ return (3, 0, name)
113
+
114
+
115
+ def discover_agents(opencode_dir: Path) -> set[str]:
116
+ """
117
+ Discover agent names from .opencode/agents/*.md files.
118
+
119
+ Parameters
120
+ ----------
121
+ opencode_dir : Path
122
+ Path to .opencode directory.
123
+
124
+ Returns
125
+ -------
126
+ set[str]
127
+ Set of agent names (filenames without .md).
128
+ """
129
+ agents_dir = opencode_dir / "agents"
130
+ if not agents_dir.exists():
131
+ return set()
132
+
133
+ return {f.stem for f in agents_dir.glob("*.md")}
134
+
135
+
136
+ def format_duration(ms: int) -> str:
137
+ """
138
+ Format milliseconds as human-readable duration.
139
+
140
+ Parameters
141
+ ----------
142
+ ms : int
143
+ Duration in milliseconds.
144
+
145
+ Returns
146
+ -------
147
+ str
148
+ Human-readable duration string (e.g., "5.2s", "3.1m", "1.5h").
149
+ """
150
+ seconds = ms / 1000
151
+ if seconds < 60:
152
+ return f"{seconds:.1f}s"
153
+ minutes = seconds / 60
154
+ if minutes < 60:
155
+ return f"{minutes:.1f}m"
156
+ hours = minutes / 60
157
+ return f"{hours:.1f}h"
158
+
159
+
160
+ def parse_log_file(log_path: Path) -> list[dict[str, Any]]:
161
+ """
162
+ Parse a single log file and extract events.
163
+
164
+ Parameters
165
+ ----------
166
+ log_path : Path
167
+ Path to the log file.
168
+
169
+ Returns
170
+ -------
171
+ list[dict[str, Any]]
172
+ List of parsed events with timestamp, type, and description.
173
+ """
174
+ events = []
175
+
176
+ with open(log_path, "r") as f:
177
+ for line in f:
178
+ line = line.strip()
179
+ if not line:
180
+ continue
181
+
182
+ # Skip non-JSON lines (like [STDERR] prefixed lines)
183
+ if line.startswith("[STDERR]"):
184
+ continue
185
+
186
+ try:
187
+ data = json.loads(line)
188
+ except json.JSONDecodeError:
189
+ continue
190
+
191
+ timestamp = data.get("timestamp")
192
+ event_type = data.get("type")
193
+ part = data.get("part", {})
194
+
195
+ if not timestamp or not event_type:
196
+ continue
197
+
198
+ event: dict[str, Any] = {
199
+ "timestamp": timestamp,
200
+ "datetime": ms_to_datetime(timestamp),
201
+ "type": event_type,
202
+ "source": log_path.stem,
203
+ }
204
+
205
+ # Extract relevant details based on event type
206
+ if event_type == "step_start":
207
+ event["description"] = "Step started"
208
+
209
+ elif event_type == "step_finish":
210
+ event["description"] = f"Step finished ({part.get('reason', 'unknown')})"
211
+ cost = part.get("cost", 0)
212
+ tokens = part.get("tokens", {})
213
+ if cost:
214
+ event["cost"] = cost
215
+ if tokens:
216
+ event["tokens"] = tokens
217
+
218
+ elif event_type == "text":
219
+ text = part.get("text", "")
220
+ # Truncate long text
221
+ if len(text) > 100:
222
+ text = text[:100] + "..."
223
+ event["description"] = f"Text: {text}"
224
+
225
+ elif event_type == "tool_use":
226
+ tool = part.get("tool", "unknown")
227
+ state = part.get("state", {})
228
+ status = state.get("status", "unknown")
229
+ input_data = state.get("input", {})
230
+
231
+ # Get tool-specific description
232
+ if tool == "bash":
233
+ cmd = input_data.get("command", "")
234
+ desc = input_data.get("description", "")
235
+ if len(cmd) > 50:
236
+ cmd = cmd[:50] + "..."
237
+ event["description"] = f"Tool: {tool} ({status}) - {desc or cmd}"
238
+ elif tool == "read":
239
+ filepath = input_data.get("filePath", "")
240
+ event["description"] = f"Tool: {tool} ({status}) - {Path(filepath).name}"
241
+ elif tool == "write":
242
+ filepath = input_data.get("filePath", "")
243
+ event["description"] = f"Tool: {tool} ({status}) - {Path(filepath).name}"
244
+ elif tool == "task":
245
+ subagent = input_data.get("subagent_type", "")
246
+ desc = input_data.get("description", "")
247
+ event["description"] = f"Tool: {tool} ({status}) - {subagent}: {desc}"
248
+ # Store task subagent details for virtual agent creation
249
+ if subagent and status == "completed":
250
+ event["task_subagent"] = subagent
251
+ event["task_output"] = state.get("output", "")
252
+ task_time = state.get("time", {})
253
+ if task_time:
254
+ event["task_start_ts"] = task_time.get("start")
255
+ event["task_end_ts"] = task_time.get("end")
256
+ # Mark as idle period for the calling agent
257
+ event["idle_period"] = (task_time.get("start"), task_time.get("end"))
258
+ elif tool == "parallel-agents":
259
+ agent = input_data.get("agent", "")
260
+ prompts = input_data.get("prompts", [])
261
+ event["description"] = f"Tool: {tool} ({status}) - {agent} x{len(prompts)}"
262
+ # Mark as idle period for the calling agent
263
+ tool_time = state.get("time", {})
264
+ if tool_time and status == "completed":
265
+ event["idle_period"] = (tool_time.get("start"), tool_time.get("end"))
266
+ else:
267
+ event["description"] = f"Tool: {tool} ({status})"
268
+
269
+ # Extract timing if available
270
+ time_data = state.get("time", {})
271
+ if time_data:
272
+ start = time_data.get("start")
273
+ end = time_data.get("end")
274
+ if start and end:
275
+ event["duration_ms"] = end - start
276
+
277
+ events.append(event)
278
+
279
+ return events
280
+
281
+
282
+ def build_timeline(
283
+ logs_dir: Path,
284
+ known_agents: set[str] | None = None,
285
+ is_running: bool = False,
286
+ ) -> dict[str, Any]:
287
+ """
288
+ Build a complete timeline from all log files in a directory.
289
+
290
+ Parameters
291
+ ----------
292
+ logs_dir : Path
293
+ Directory containing log files (searched recursively).
294
+ known_agents : set[str] | None
295
+ Optional set of known agent names for task subagent detection.
296
+ is_running : bool
297
+ If True, the job is still running and some agents may not have finished.
298
+
299
+ Returns
300
+ -------
301
+ dict[str, Any]
302
+ Timeline data with events, file_summaries, total_events, and is_running.
303
+ """
304
+ all_events: list[dict[str, Any]] = []
305
+ file_summaries: dict[str, dict[str, Any]] = {}
306
+ task_subagents: list[dict[str, Any]] = [] # Collect task subagent info
307
+ running_sources: set[str] = set() # Track which sources are still running
308
+ idle_periods_by_source: dict[str, list[tuple[int, int]]] = {} # Track idle periods
309
+
310
+ # Current time for running agents (in ms)
311
+ now_ms: int = int(datetime.now().timestamp() * 1000)
312
+
313
+ # Find all log files recursively
314
+ log_files = sorted(logs_dir.rglob("*.log"))
315
+
316
+ if not log_files:
317
+ print(f"No .log files found in {logs_dir}", file=sys.stderr)
318
+ return {}
319
+
320
+ # Parse each log file
321
+ for log_file in log_files:
322
+ events = parse_log_file(log_file)
323
+
324
+ # Use relative path from logs_dir as source name
325
+ rel_path = log_file.relative_to(logs_dir)
326
+ # For nested files like "modeler-parallel-run-123/instance-1.log", use full path
327
+ # For top-level files like "main.log", use "main"
328
+ if len(rel_path.parts) > 1:
329
+ # Nested: prepend parent dir name for context
330
+ source_name = f"{rel_path.parent.name}/{rel_path.stem}"
331
+ else:
332
+ source_name = rel_path.stem
333
+
334
+ # Check if this log file is complete (only relevant if job is running)
335
+ log_complete: bool = True
336
+ if is_running:
337
+ log_complete = is_log_complete(log_file)
338
+ if not log_complete:
339
+ running_sources.add(source_name)
340
+
341
+ # Update source in all events and collect idle periods
342
+ for e in events:
343
+ e["source"] = source_name
344
+ # Collect idle periods (when waiting on task/parallel-agents)
345
+ if "idle_period" in e:
346
+ if source_name not in idle_periods_by_source:
347
+ idle_periods_by_source[source_name] = []
348
+ idle_periods_by_source[source_name].append(e["idle_period"])
349
+
350
+ all_events.extend(events)
351
+
352
+ if events:
353
+ start_time = min(e["timestamp"] for e in events)
354
+ end_time = max(e["timestamp"] for e in events)
355
+
356
+ # For running logs, use current time as end
357
+ if not log_complete:
358
+ end_time = now_ms
359
+
360
+ duration = end_time - start_time
361
+
362
+ # Count events by type
363
+ type_counts: dict[str, int] = {}
364
+ for e in events:
365
+ t = e["type"]
366
+ type_counts[t] = type_counts.get(t, 0) + 1
367
+
368
+ # Sum costs
369
+ total_cost = sum(e.get("cost", 0) for e in events)
370
+
371
+ file_summaries[source_name] = {
372
+ "start": ms_to_datetime(start_time),
373
+ "end": ms_to_datetime(end_time),
374
+ "start_ms": start_time,
375
+ "end_ms": end_time,
376
+ "duration_ms": duration,
377
+ "event_count": len(events),
378
+ "type_counts": type_counts,
379
+ "total_cost": total_cost,
380
+ "is_running": not log_complete,
381
+ "idle_periods": idle_periods_by_source.get(source_name, []),
382
+ }
383
+
384
+ # Sort all events by timestamp
385
+ all_events.sort(key=lambda e: e["timestamp"])
386
+
387
+ # Collect task subagent info from events
388
+ if known_agents:
389
+ for e in all_events:
390
+ if "task_subagent" in e:
391
+ subagent_name = e["task_subagent"]
392
+ if subagent_name in known_agents:
393
+ task_subagents.append({
394
+ "name": subagent_name,
395
+ "caller": e["source"],
396
+ "start_ts": e.get("task_start_ts"),
397
+ "end_ts": e.get("task_end_ts"),
398
+ "output": e.get("task_output", ""),
399
+ })
400
+
401
+ # Create virtual agent entries for task subagents
402
+ for task in task_subagents:
403
+ if not task["start_ts"] or not task["end_ts"]:
404
+ continue
405
+
406
+ source_name = f"{task['name']} (task)"
407
+ start_ts = task["start_ts"]
408
+ end_ts = task["end_ts"]
409
+ duration = end_ts - start_ts
410
+
411
+ # Create synthetic events for this task subagent
412
+ output_preview = task["output"]
413
+ if len(output_preview) > 100:
414
+ output_preview = output_preview[:100] + "..."
415
+ # Clean up newlines for display
416
+ output_preview = output_preview.replace("\n", " ").strip()
417
+
418
+ start_event: dict[str, Any] = {
419
+ "timestamp": start_ts,
420
+ "datetime": ms_to_datetime(start_ts),
421
+ "type": "task_start",
422
+ "source": source_name,
423
+ "description": f"Spawned by {task['caller']}",
424
+ }
425
+
426
+ end_event: dict[str, Any] = {
427
+ "timestamp": end_ts,
428
+ "datetime": ms_to_datetime(end_ts),
429
+ "type": "task_finish",
430
+ "source": source_name,
431
+ "description": f"Output: {output_preview}" if output_preview else "Completed",
432
+ "duration_ms": duration,
433
+ }
434
+
435
+ all_events.extend([start_event, end_event])
436
+
437
+ # Add file summary for task subagent
438
+ file_summaries[source_name] = {
439
+ "start": ms_to_datetime(start_ts),
440
+ "end": ms_to_datetime(end_ts),
441
+ "duration_ms": duration,
442
+ "event_count": 2,
443
+ "type_counts": {"task_start": 1, "task_finish": 1},
444
+ "total_cost": 0, # Cost is tracked in the calling agent
445
+ }
446
+
447
+ # Re-sort events after adding task subagent events
448
+ all_events.sort(key=lambda e: e["timestamp"])
449
+
450
+ return {
451
+ "events": all_events,
452
+ "file_summaries": file_summaries,
453
+ "total_events": len(all_events),
454
+ "is_running": is_running,
455
+ "running_sources": running_sources,
456
+ }
457
+
458
+
459
+ def print_timeline(timeline: dict[str, Any]) -> None:
460
+ """
461
+ Print a formatted timeline to stdout.
462
+
463
+ Parameters
464
+ ----------
465
+ timeline : dict[str, Any]
466
+ Timeline data from build_timeline().
467
+ """
468
+ if not timeline:
469
+ return
470
+
471
+ events = timeline["events"]
472
+ file_summaries = timeline["file_summaries"]
473
+ is_running: bool = timeline.get("is_running", False)
474
+
475
+ # Print header with status
476
+ print("=" * 80)
477
+ if is_running:
478
+ print("LOG FILE SUMMARIES [JOB RUNNING]")
479
+ else:
480
+ print("LOG FILE SUMMARIES")
481
+ print("=" * 80)
482
+
483
+ # Find global start time for relative timing
484
+ global_start = min(s["start"] for s in file_summaries.values())
485
+
486
+ # Sort by natural order (main first, instance-1, instance-2, ..., consolidator last)
487
+ sorted_files = sorted(
488
+ file_summaries.items(),
489
+ key=lambda x: natural_sort_key(x[0].split("/")[-1] if "/" in x[0] else x[0])
490
+ )
491
+
492
+ for name, summary in sorted_files:
493
+ rel_start = (summary["start"] - global_start).total_seconds()
494
+ duration = format_duration(summary["duration_ms"])
495
+ cost = summary["total_cost"]
496
+ source_running: bool = summary.get("is_running", False)
497
+
498
+ status_suffix = " [RUNNING]" if source_running else ""
499
+ print(f"\n{name}:{status_suffix}")
500
+ print(f" Started: +{rel_start:.1f}s from global start")
501
+ print(f" Duration: {duration}{'*' if source_running else ''}")
502
+ print(f" Events: {summary['event_count']}")
503
+ print(f" Cost: ${cost:.4f}")
504
+ print(f" Types: {summary['type_counts']}")
505
+
506
+ # Print timeline
507
+ print("\n" + "=" * 80)
508
+ print("TIMELINE (first and last 20 events per source)")
509
+ print("=" * 80)
510
+
511
+ # Group events by source
512
+ by_source: dict[str, list[dict[str, Any]]] = {}
513
+ for e in events:
514
+ source = e["source"]
515
+ if source not in by_source:
516
+ by_source[source] = []
517
+ by_source[source].append(e)
518
+
519
+ global_start_ts = min(e["timestamp"] for e in events)
520
+
521
+ # Sort sources by natural order
522
+ sorted_sources = sorted(
523
+ by_source.keys(),
524
+ key=lambda x: natural_sort_key(x.split("/")[-1] if "/" in x else x)
525
+ )
526
+
527
+ for source in sorted_sources:
528
+ source_events = by_source[source]
529
+ print(f"\n--- {source} ---")
530
+
531
+ # Show first 20 and last 20 events
532
+ if len(source_events) <= 40:
533
+ display_events: list[dict[str, Any] | None] = source_events
534
+ else:
535
+ display_events = source_events[:20] + [None] + source_events[-20:]
536
+
537
+ for e in display_events:
538
+ if e is None:
539
+ print(f" ... ({len(source_events) - 40} events omitted) ...")
540
+ continue
541
+
542
+ rel_time = (e["timestamp"] - global_start_ts) / 1000
543
+ time_str = f"+{rel_time:7.1f}s"
544
+
545
+ duration_str = ""
546
+ if "duration_ms" in e:
547
+ duration_str = f" [{format_duration(e['duration_ms'])}]"
548
+
549
+ print(f" {time_str} | {e['type']:12} | {e.get('description', '')}{duration_str}")
550
+
551
+ # Print overall summary
552
+ print("\n" + "=" * 80)
553
+ print("OVERALL SUMMARY")
554
+ print("=" * 80)
555
+
556
+ total_duration = max(e["timestamp"] for e in events) - min(e["timestamp"] for e in events)
557
+ total_cost = sum(s["total_cost"] for s in file_summaries.values())
558
+
559
+ print(f"Total duration: {format_duration(total_duration)}")
560
+ print(f"Total events: {timeline['total_events']}")
561
+ print(f"Total cost: ${total_cost:.4f}")
562
+ print(f"Log files: {len(file_summaries)}")
563
+
564
+ # Show parallel execution timeline
565
+ print("\n" + "=" * 80)
566
+ print("EXECUTION GANTT (visual)")
567
+ print("=" * 80)
568
+
569
+ # Normalize times to 0-50 character width
570
+ all_starts = [s["start"] for s in file_summaries.values()]
571
+ all_ends = [s["end"] for s in file_summaries.values()]
572
+ min_time = min(all_starts)
573
+ max_time = max(all_ends)
574
+ total_span = (max_time - min_time).total_seconds()
575
+
576
+ if total_span == 0:
577
+ total_span = 1
578
+
579
+ width = 50
580
+
581
+ # Calculate max name length for alignment
582
+ max_name_len = max(len(name) for name in file_summaries.keys())
583
+ name_width = min(max(max_name_len, 20), 45) # Between 20 and 45 chars
584
+
585
+ # Get global time range in ms for idle period calculations
586
+ min_time_ms = min(s["start_ms"] for s in file_summaries.values() if "start_ms" in s)
587
+ max_time_ms = max(s["end_ms"] for s in file_summaries.values() if "end_ms" in s)
588
+ total_span_ms = max_time_ms - min_time_ms
589
+ if total_span_ms == 0:
590
+ total_span_ms = 1
591
+
592
+ for name, summary in sorted_files:
593
+ rel_start = (summary["start"] - min_time).total_seconds()
594
+ rel_end = (summary["end"] - min_time).total_seconds()
595
+ source_running = summary.get("is_running", False)
596
+ idle_periods: list[tuple[int, int]] = summary.get("idle_periods", [])
597
+
598
+ start_pos = int((rel_start / total_span) * width)
599
+ end_pos = int((rel_end / total_span) * width)
600
+
601
+ # Build segmented bar with idle periods shown as grey
602
+ bar_chars: list[str] = [" "] * width
603
+ for i in range(start_pos, min(end_pos, width)):
604
+ bar_chars[i] = "█"
605
+
606
+ # Grey out idle periods
607
+ for idle_start, idle_end in idle_periods:
608
+ if idle_start is None or idle_end is None:
609
+ continue
610
+ # Convert idle period to bar positions
611
+ idle_start_rel = (idle_start - min_time_ms) / total_span_ms
612
+ idle_end_rel = (idle_end - min_time_ms) / total_span_ms
613
+ idle_start_pos = int(idle_start_rel * width)
614
+ idle_end_pos = int(idle_end_rel * width)
615
+ # Mark idle positions with grey
616
+ for i in range(max(idle_start_pos, start_pos), min(idle_end_pos + 1, end_pos, width)):
617
+ bar_chars[i] = "░"
618
+
619
+ # If source is still running, use different end character
620
+ if source_running and end_pos > 0 and end_pos <= width:
621
+ bar_chars[end_pos - 1] = "░"
622
+
623
+ bar = "".join(bar_chars)
624
+ duration = format_duration(summary["duration_ms"])
625
+ duration_suffix = "..." if source_running else ""
626
+
627
+ # Truncate long names
628
+ display_name = name if len(name) <= name_width else "..." + name[-(name_width - 3):]
629
+ print(f"{display_name:{name_width}} |{bar}| {duration}{duration_suffix}")
630
+
631
+ print(f"{'':{name_width}} |{'─' * width}|")
632
+ print(f"{'':{name_width}} 0s{' ' * (width - 10)}{format_duration(total_span * 1000):>8}")
633
+
634
+
635
+ def run_timeline(work_dir: Path | None) -> int:
636
+ """
637
+ Run timeline analysis on a work directory.
638
+
639
+ Parameters
640
+ ----------
641
+ work_dir : Path | None
642
+ Path to work directory. If None, checks cwd for _opencode_logs.
643
+
644
+ Returns
645
+ -------
646
+ int
647
+ Exit code (0 success, 1 error).
648
+ """
649
+ # Resolve logs directory and base directory
650
+ if work_dir is None:
651
+ # Check if _opencode_logs exists in cwd
652
+ cwd_logs = Path.cwd() / "_opencode_logs"
653
+ if cwd_logs.exists() and cwd_logs.is_dir():
654
+ logs_dir = cwd_logs
655
+ base_dir = Path.cwd()
656
+ else:
657
+ print(
658
+ "Error: No work directory specified and no _opencode_logs in current directory",
659
+ file=sys.stderr
660
+ )
661
+ return 1
662
+ else:
663
+ logs_dir = work_dir / "_opencode_logs"
664
+ base_dir = work_dir
665
+ if not logs_dir.exists():
666
+ print(f"Error: No _opencode_logs directory found in {work_dir}", file=sys.stderr)
667
+ return 1
668
+
669
+ # Try to find .opencode/agents for task subagent detection
670
+ opencode_dir = base_dir / ".opencode"
671
+ known_agents: set[str] = set()
672
+ if opencode_dir.exists():
673
+ known_agents = discover_agents(opencode_dir)
674
+
675
+ # Check if the job is still running by examining main.log
676
+ main_log = logs_dir / "main.log"
677
+ is_running: bool = main_log.exists() and not is_log_complete(main_log)
678
+
679
+ timeline = build_timeline(logs_dir, known_agents, is_running=is_running)
680
+ if not timeline:
681
+ return 1
682
+
683
+ print_timeline(timeline)
684
+ return 0
dlab/tui/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ TUI module for dlab connect command.
3
+
4
+ Provides a Textual-based terminal UI for monitoring running sessions.
5
+ """
6
+
7
+ from dlab.tui.app import ConnectApp
8
+
9
+ __all__ = ["ConnectApp"]