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,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)
|