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,618 @@
1
+ """
2
+ Artifact widgets for browsing and viewing agent output files.
3
+
4
+ Provides:
5
+ - ArtifactList: File list in left sidebar
6
+ - FileViewer: Scrollable file content viewer with image support
7
+ """
8
+
9
+ import csv
10
+ import io
11
+ import re
12
+ from pathlib import Path
13
+
14
+ from textual.widgets import ListView, ListItem, Static, DataTable
15
+ from textual.containers import VerticalScroll
16
+ from textual.reactive import reactive
17
+ from textual.message import Message
18
+ from rich.text import Text
19
+ from rich.markdown import Markdown
20
+ from rich.syntax import Syntax
21
+ from rich.console import Group
22
+
23
+
24
+ # File extensions to include as artifacts
25
+ ARTIFACT_EXTENSIONS = {".md", ".py", ".txt", ".csv", ".png", ".jpg", ".jpeg", ".pdf"}
26
+
27
+ # Directories to exclude from artifact discovery
28
+ EXCLUDE_DIRS = {".git", ".opencode", "_opencode_logs", "_docker", "_hooks", "node_modules", "__pycache__", "data"}
29
+
30
+ # Image extensions
31
+ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
32
+
33
+
34
+ def get_agent_directory(work_dir: Path, agent_name: str | None) -> Path | None:
35
+ """
36
+ Map agent display name to its artifact directory.
37
+
38
+ Agent names from logs use pattern: poet-parallel-run-TIMESTAMP/instance-N
39
+ But actual work files are in: parallel/run-TIMESTAMP/instance-N/
40
+
41
+ Parameters
42
+ ----------
43
+ work_dir : Path
44
+ Work directory path.
45
+ agent_name : str | None
46
+ Agent display name (may be shortened).
47
+
48
+ Returns
49
+ -------
50
+ Path | None
51
+ Directory containing agent's artifacts, or None for root.
52
+ """
53
+ if not agent_name:
54
+ return None
55
+
56
+ # Main agent: use root directory
57
+ if agent_name.startswith("main"):
58
+ return None
59
+
60
+ # Shortened parallel agent name: ⟝ poet …28/ inst-1
61
+ match = re.match(r"^⟝ (.+) …(\d+)/ (.+)$", agent_name)
62
+ if match:
63
+ number_suffix = match.group(2)
64
+ instance_part = match.group(3)
65
+
66
+ # Expand instance abbreviations
67
+ if instance_part.startswith("inst-"):
68
+ instance_part = "instance-" + instance_part[5:]
69
+ elif instance_part == "cnsldtr":
70
+ instance_part = "consolidator"
71
+ elif instance_part.startswith("cnsldtr-"):
72
+ instance_part = "consolidator-" + instance_part[8:]
73
+
74
+ # Find matching run directory in parallel/
75
+ parallel_dir = work_dir / "parallel"
76
+ if parallel_dir.exists():
77
+ for run_dir in parallel_dir.iterdir():
78
+ if run_dir.is_dir() and run_dir.name.endswith(number_suffix):
79
+ return run_dir / instance_part
80
+
81
+ # Full parallel agent name: poet-parallel-run-TIMESTAMP/instance-N
82
+ # Maps to: parallel/run-TIMESTAMP/instance-N
83
+ full_match = re.match(r"^.+-parallel-run-(\d+)/(.+)$", agent_name)
84
+ if full_match:
85
+ timestamp = full_match.group(1)
86
+ instance_part = full_match.group(2)
87
+ return work_dir / "parallel" / f"run-{timestamp}" / instance_part
88
+
89
+ return None
90
+
91
+
92
+ def is_parallel_run_dir(name: str) -> bool:
93
+ """Check if directory name matches parallel run pattern."""
94
+ # Matches both 'parallel' dir and 'run-TIMESTAMP' subdirs
95
+ return name == "parallel" or name.startswith("run-")
96
+
97
+
98
+ def discover_artifacts(
99
+ work_dir: Path, agent_dir: Path | None, is_main: bool = False
100
+ ) -> list[Path]:
101
+ """
102
+ Discover artifact files for an agent.
103
+
104
+ Parameters
105
+ ----------
106
+ work_dir : Path
107
+ Work directory path.
108
+ agent_dir : Path | None
109
+ Agent-specific directory, or None for root.
110
+ is_main : bool
111
+ Whether this is the main agent. If True, excludes parallel run dirs.
112
+
113
+ Returns
114
+ -------
115
+ list[Path]
116
+ List of artifact paths relative to work_dir.
117
+ """
118
+ artifacts: list[Path] = []
119
+ search_dir = agent_dir if agent_dir else work_dir
120
+
121
+ if not search_dir.exists():
122
+ return artifacts
123
+
124
+ for path in search_dir.rglob("*"):
125
+ if not path.is_file():
126
+ continue
127
+
128
+ # Skip excluded directories
129
+ if any(excluded in path.parts for excluded in EXCLUDE_DIRS):
130
+ continue
131
+
132
+ # For main agent, skip files inside parallel run directories
133
+ if is_main and any(is_parallel_run_dir(part) for part in path.parts):
134
+ continue
135
+
136
+ # Check extension
137
+ if path.suffix.lower() not in ARTIFACT_EXTENSIONS:
138
+ continue
139
+
140
+ # Store relative path (relative to agent_dir for subagents, work_dir for main)
141
+ base = agent_dir if agent_dir else work_dir
142
+ try:
143
+ rel_path = path.relative_to(base)
144
+ artifacts.append(rel_path)
145
+ except ValueError:
146
+ artifacts.append(path)
147
+
148
+ return sorted(artifacts)
149
+
150
+
151
+ def get_file_icon(path: Path) -> str:
152
+ """Get short text label for file type."""
153
+ suffix = path.suffix.lower()
154
+ labels: dict[str, str] = {
155
+ ".md": "md",
156
+ ".py": "py",
157
+ ".txt": "tx",
158
+ ".csv": "csv",
159
+ ".png": "img",
160
+ ".jpg": "img",
161
+ ".jpeg": "img",
162
+ ".pdf": "pdf",
163
+ }
164
+ return labels.get(suffix, " ")
165
+
166
+
167
+ class ArtifactItem(ListItem):
168
+ """List item for a single artifact file."""
169
+
170
+ def __init__(self, path: Path, **kwargs) -> None:
171
+ super().__init__(**kwargs)
172
+ self.file_path = path
173
+
174
+ def compose(self):
175
+ """Compose the widget."""
176
+ tag: str = get_file_icon(self.file_path)
177
+ if self.file_path.parent != Path("."):
178
+ parent = str(self.file_path.parent)
179
+ if len(parent) > 10:
180
+ parent = f"{parent[:5]}…{parent[-5:]}"
181
+ display_path = f"{parent}/{self.file_path.name}"
182
+ else:
183
+ display_path = self.file_path.name
184
+
185
+ # Sidebar is 28 wide - 2 padding = 26 cells. Tag is 3 chars + space = 4.
186
+ max_len: int = 22
187
+ if len(display_path) > max_len:
188
+ display_path = display_path[:max_len - 1] + "…"
189
+
190
+ text = Text()
191
+ text.append(f"{tag:>3}", style="dim")
192
+ text.append(f" {display_path}")
193
+ yield Static(text)
194
+
195
+
196
+ class ArtifactList(ListView):
197
+ """
198
+ File list in left sidebar.
199
+
200
+ Shows artifacts for selected agent with icons.
201
+ """
202
+
203
+ class FileSelected(Message):
204
+ """Message sent when a file is selected."""
205
+
206
+ def __init__(self, path: Path) -> None:
207
+ self.path = path
208
+ super().__init__()
209
+
210
+ def __init__(self, work_dir: Path, **kwargs) -> None:
211
+ super().__init__(**kwargs)
212
+ self._work_dir = work_dir
213
+ self._agent_dir: Path | None = None
214
+ self._artifacts: list[Path] = []
215
+ self._agent_name: str | None = None
216
+
217
+ def set_agent(self, agent_name: str | None) -> None:
218
+ """Update artifacts for selected agent."""
219
+ self._agent_name = agent_name
220
+
221
+ # Get agent directory
222
+ self._agent_dir = get_agent_directory(self._work_dir, agent_name)
223
+
224
+ # Check if this is the main agent
225
+ is_main = agent_name is not None and agent_name.startswith("main")
226
+
227
+ # Discover artifacts
228
+ self._artifacts = discover_artifacts(self._work_dir, self._agent_dir, is_main=is_main)
229
+
230
+ # Rebuild list
231
+ self.clear()
232
+
233
+ if not self._artifacts:
234
+ self.append(ListItem(Static(Text("No files", style="dim italic"))))
235
+ return
236
+
237
+ for path in self._artifacts:
238
+ self.append(ArtifactItem(path))
239
+
240
+ def refresh_if_changed(self) -> None:
241
+ """Re-discover artifacts and update list only if files changed."""
242
+ if self._agent_name is None:
243
+ return
244
+
245
+ self._agent_dir = get_agent_directory(self._work_dir, self._agent_name)
246
+ is_main = self._agent_name.startswith("main")
247
+ new_artifacts = discover_artifacts(self._work_dir, self._agent_dir, is_main=is_main)
248
+
249
+ if new_artifacts != self._artifacts:
250
+ self._artifacts = new_artifacts
251
+ self.clear()
252
+ if not self._artifacts:
253
+ self.append(ListItem(Static(Text("No files", style="dim italic"))))
254
+ return
255
+ for path in self._artifacts:
256
+ self.append(ArtifactItem(path))
257
+
258
+ def _resolve_path(self, rel_path: Path) -> Path:
259
+ """Resolve a relative artifact path to an absolute path."""
260
+ base = self._agent_dir if self._agent_dir else self._work_dir
261
+ return base / rel_path
262
+
263
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
264
+ """Handle selection (Enter key)."""
265
+ if isinstance(event.item, ArtifactItem):
266
+ self.post_message(self.FileSelected(self._resolve_path(event.item.file_path)))
267
+
268
+ def get_highlighted_path(self) -> Path | None:
269
+ """Get the path of the currently highlighted file."""
270
+ if self.highlighted_child is not None:
271
+ if isinstance(self.highlighted_child, ArtifactItem):
272
+ return self._resolve_path(self.highlighted_child.file_path)
273
+ return None
274
+
275
+ def open_highlighted(self) -> bool:
276
+ """
277
+ Open the highlighted file in the system's default viewer.
278
+
279
+ Returns
280
+ -------
281
+ bool
282
+ True if file was opened, False if no file highlighted.
283
+ """
284
+ import subprocess
285
+ import sys
286
+
287
+ path = self.get_highlighted_path()
288
+ if not path or not path.exists():
289
+ return False
290
+
291
+ if sys.platform == "darwin":
292
+ subprocess.Popen(["open", str(path)])
293
+ elif sys.platform == "win32":
294
+ subprocess.Popen(["start", str(path)], shell=True)
295
+ else:
296
+ subprocess.Popen(["xdg-open", str(path)])
297
+
298
+ return True
299
+
300
+
301
+ class FileViewer(VerticalScroll, can_focus=True):
302
+ """
303
+ Scrollable file content viewer with image support.
304
+
305
+ Displays:
306
+ - Markdown files: rendered as markdown
307
+ - Python files: syntax highlighted
308
+ - Images: inline rendering (iTerm2) or info display
309
+ - Other files: plain text
310
+ """
311
+
312
+ DEFAULT_CSS = """
313
+ FileViewer {
314
+ padding: 0 1;
315
+ }
316
+ """
317
+
318
+ BINDINGS = [
319
+ ("up", "scroll_up", "Up"),
320
+ ("down", "scroll_down", "Down"),
321
+ ("pageup", "page_up", "Page Up"),
322
+ ("pagedown", "page_down", "Page Down"),
323
+ ]
324
+
325
+ def __init__(self, **kwargs) -> None:
326
+ super().__init__(**kwargs)
327
+ self._file_path: Path | None = None
328
+
329
+ def show_file(self, path: Path) -> None:
330
+ """Display file content."""
331
+ self._file_path = path
332
+
333
+ # Clear existing content
334
+ self.remove_children()
335
+
336
+ if not path.exists():
337
+ self.mount(Static(Text(f"File not found: {path}", style="red")))
338
+ return
339
+
340
+ suffix = path.suffix.lower()
341
+
342
+ # Image files
343
+ if suffix in IMAGE_EXTENSIONS:
344
+ self.mount(ImageDisplay(path))
345
+ return
346
+
347
+ # PDF files
348
+ if suffix == ".pdf":
349
+ self.mount(PdfDisplay(path))
350
+ return
351
+
352
+ # Read text content
353
+ try:
354
+ content = path.read_text(encoding="utf-8", errors="replace")
355
+ except Exception as e:
356
+ self.mount(Static(Text(f"Error reading file: {e}", style="red")))
357
+ return
358
+
359
+ # Markdown files
360
+ if suffix == ".md":
361
+ self.mount(MarkdownDisplay(content))
362
+ return
363
+
364
+ # Python files
365
+ if suffix == ".py":
366
+ self.mount(CodeDisplay(content, "python"))
367
+ return
368
+
369
+ # CSV files
370
+ if suffix == ".csv":
371
+ self.mount(CsvDisplay(content))
372
+ return
373
+
374
+ # Default: plain text
375
+ self.mount(Static(Text(content)))
376
+
377
+ def show_placeholder(self) -> None:
378
+ """Show placeholder when no file selected."""
379
+ self.remove_children()
380
+ self.mount(Static(Text("Select a file to preview", style="dim italic")))
381
+
382
+ def action_scroll_up(self) -> None:
383
+ """Scroll up."""
384
+ self.scroll_up()
385
+
386
+ def action_scroll_down(self) -> None:
387
+ """Scroll down."""
388
+ self.scroll_down()
389
+
390
+ def action_page_up(self) -> None:
391
+ """Page up."""
392
+ self.scroll_page_up()
393
+
394
+ def action_page_down(self) -> None:
395
+ """Page down."""
396
+ self.scroll_page_down()
397
+
398
+ def get_current_file(self) -> Path | None:
399
+ """Get the currently displayed file path."""
400
+ return self._file_path
401
+
402
+ def open_external(self) -> bool:
403
+ """
404
+ Open the current file in the system's default viewer.
405
+
406
+ Returns
407
+ -------
408
+ bool
409
+ True if file was opened, False if no file selected.
410
+ """
411
+ import subprocess
412
+ import sys
413
+
414
+ if not self._file_path or not self._file_path.exists():
415
+ return False
416
+
417
+ # Open with system default viewer
418
+ if sys.platform == "darwin":
419
+ subprocess.Popen(["open", str(self._file_path)])
420
+ elif sys.platform == "win32":
421
+ subprocess.Popen(["start", str(self._file_path)], shell=True)
422
+ else:
423
+ subprocess.Popen(["xdg-open", str(self._file_path)])
424
+
425
+ return True
426
+
427
+
428
+ class ImageDisplay(Static):
429
+ """Display image info with clickable path to open externally.
430
+
431
+ Note: iTerm2 inline images don't work inside Textual TUIs because
432
+ Textual uses its own virtual screen buffer that doesn't pass through
433
+ terminal escape sequences.
434
+ """
435
+
436
+ def __init__(self, path: Path, **kwargs) -> None:
437
+ super().__init__(**kwargs)
438
+ self._path = path
439
+
440
+ def render(self) -> Text:
441
+ """Render image info with clickable path."""
442
+ if not self._path.exists():
443
+ return Text(f"Image not found: {self._path}", style="red")
444
+
445
+ size = self._path.stat().st_size
446
+ size_str = f"{size / 1024:.1f} KB" if size > 1024 else f"{size} bytes"
447
+
448
+ # Try to get image dimensions
449
+ dimensions = ""
450
+ try:
451
+ from PIL import Image
452
+
453
+ with Image.open(self._path) as img:
454
+ dimensions = f"{img.width} x {img.height} px"
455
+ except ImportError:
456
+ pass
457
+ except Exception:
458
+ pass
459
+
460
+ text = Text()
461
+ text.append(f"{self._path.name}\n\n", style="bold")
462
+ text.append("Path: ", style="")
463
+ text.append(f"{self._path}", style="underline cyan")
464
+ text.append("\n", style="")
465
+ text.append(f"Size: {size_str}\n", style="dim")
466
+ if dimensions:
467
+ text.append(f"Dimensions: {dimensions}\n", style="dim")
468
+ text.append("\n")
469
+ text.append("Click path or press ", style="dim")
470
+ text.append("o", style="bold cyan")
471
+ text.append(" to open", style="dim")
472
+
473
+ return text
474
+
475
+ def on_click(self) -> None:
476
+ """Handle click - open the file externally."""
477
+ self._open_file()
478
+
479
+ def _open_file(self) -> None:
480
+ """Open the file in system default viewer."""
481
+ import subprocess
482
+ import sys
483
+
484
+ if not self._path.exists():
485
+ return
486
+
487
+ if sys.platform == "darwin":
488
+ subprocess.Popen(["open", str(self._path)])
489
+ elif sys.platform == "win32":
490
+ subprocess.Popen(["start", str(self._path)], shell=True)
491
+ else:
492
+ subprocess.Popen(["xdg-open", str(self._path)])
493
+
494
+
495
+ class PdfDisplay(Static):
496
+ """Display PDF info with clickable path to open externally."""
497
+
498
+ def __init__(self, path: Path, **kwargs) -> None:
499
+ super().__init__(**kwargs)
500
+ self._path = path
501
+
502
+ def render(self) -> Text:
503
+ """Render PDF info with clickable path."""
504
+ if not self._path.exists():
505
+ return Text(f"PDF not found: {self._path}", style="red")
506
+
507
+ size = self._path.stat().st_size
508
+ size_str = f"{size / 1024:.1f} KB" if size > 1024 else f"{size} bytes"
509
+
510
+ text = Text()
511
+ text.append(f"{self._path.name}\n\n", style="bold")
512
+ text.append("Path: ", style="")
513
+ text.append(f"{self._path}", style="underline cyan")
514
+ text.append("\n", style="")
515
+ text.append(f"Size: {size_str}\n", style="dim")
516
+ text.append("\n")
517
+ text.append("Click path or press ", style="dim")
518
+ text.append("o", style="bold cyan")
519
+ text.append(" to open", style="dim")
520
+
521
+ return text
522
+
523
+ def on_click(self) -> None:
524
+ """Handle click - open the file externally."""
525
+ self._open_file()
526
+
527
+ def _open_file(self) -> None:
528
+ """Open the file in system default viewer."""
529
+ import subprocess
530
+ import sys
531
+
532
+ if not self._path.exists():
533
+ return
534
+
535
+ if sys.platform == "darwin":
536
+ subprocess.Popen(["open", str(self._path)])
537
+ elif sys.platform == "win32":
538
+ subprocess.Popen(["start", str(self._path)], shell=True)
539
+ else:
540
+ subprocess.Popen(["xdg-open", str(self._path)])
541
+
542
+
543
+ class MarkdownDisplay(Static):
544
+ """Display markdown content rendered."""
545
+
546
+ def __init__(self, content: str, **kwargs) -> None:
547
+ super().__init__(**kwargs)
548
+ self._content = content
549
+
550
+ def render(self):
551
+ """Render markdown."""
552
+ return Markdown(self._content)
553
+
554
+
555
+ class CodeDisplay(Static):
556
+ """Display code with syntax highlighting."""
557
+
558
+ def __init__(self, content: str, language: str = "python", **kwargs) -> None:
559
+ super().__init__(**kwargs)
560
+ self._content = content
561
+ self._language = language
562
+
563
+ def render(self):
564
+ """Render code with syntax highlighting."""
565
+ return Syntax(
566
+ self._content,
567
+ self._language,
568
+ theme="monokai",
569
+ line_numbers=True,
570
+ )
571
+
572
+
573
+ class CsvDisplay(DataTable):
574
+ """Display CSV content as a data table."""
575
+
576
+ DEFAULT_CSS = """
577
+ CsvDisplay {
578
+ height: auto;
579
+ max-height: 100%;
580
+ }
581
+ """
582
+
583
+ def __init__(self, content: str, max_rows: int = 500, **kwargs) -> None:
584
+ super().__init__(**kwargs)
585
+ self._content = content
586
+ self._max_rows = max_rows
587
+
588
+ def on_mount(self) -> None:
589
+ """Parse CSV and populate table on mount."""
590
+ try:
591
+ reader = csv.reader(io.StringIO(self._content))
592
+ rows = list(reader)
593
+
594
+ if not rows:
595
+ return
596
+
597
+ # First row as headers
598
+ headers = rows[0]
599
+ for col_idx, header in enumerate(headers):
600
+ self.add_column(header or f"col_{col_idx}", key=str(col_idx))
601
+
602
+ # Add data rows (limit to max_rows)
603
+ for row in rows[1 : self._max_rows + 1]:
604
+ # Pad row if needed
605
+ while len(row) < len(headers):
606
+ row.append("")
607
+ self.add_row(*row[: len(headers)])
608
+
609
+ # Show truncation message if needed
610
+ if len(rows) > self._max_rows + 1:
611
+ truncated = len(rows) - self._max_rows - 1
612
+ self.add_row(*[f"... {truncated} more rows ..." if i == 0 else "" for i in range(len(headers))])
613
+
614
+ except csv.Error:
615
+ # Fallback to plain text display if CSV parsing fails
616
+ self.add_column("Content")
617
+ for line in self._content.split("\n")[: self._max_rows]:
618
+ self.add_row(line)