From 38e4b42f06938e643e2e29ec7aa9051320b7c5b6 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Fri, 13 Mar 2026 12:29:33 -0300 Subject: [PATCH 1/2] feat(cli): draft F41 dashboard artifacts explorer --- .../REPORT.md | 20 +++ .../F41-dashboard-artifacts-explorer/SPEC.md | 32 ++++ src/aignt_os/cli/dashboard.py | 167 +++++++++++++++--- tests/unit/test_dashboard_artifacts.py | 105 +++++++++++ 4 files changed, 301 insertions(+), 23 deletions(-) create mode 100644 features/F41-dashboard-artifacts-explorer/REPORT.md create mode 100644 features/F41-dashboard-artifacts-explorer/SPEC.md create mode 100644 tests/unit/test_dashboard_artifacts.py diff --git a/features/F41-dashboard-artifacts-explorer/REPORT.md b/features/F41-dashboard-artifacts-explorer/REPORT.md new file mode 100644 index 0000000..414a785 --- /dev/null +++ b/features/F41-dashboard-artifacts-explorer/REPORT.md @@ -0,0 +1,20 @@ +# Relatório de Execução - F41 Dashboard Artifacts Explorer + +## Resumo +Implementação da aba "Artifacts" no Dashboard TUI, permitindo a visualização de arquivos gerados pela run sem sair do terminal. + +## Mudanças Realizadas +- **Refatoração do Dashboard**: Introduzido `TabbedContent` para separar "Steps" de "Artifacts". +- **Novo Widget**: `ArtifactExplorer` lista arquivos do diretório de artefatos da run. +- **Visualização**: + - Arquivos de texto (.txt, .md, .json, .yaml, .py, .log, etc) são exibidos diretamente. + - Arquivos binários ou não suportados exibem metadados (caminho e tamanho). +- **Testes**: Adicionados testes unitários em `tests/unit/test_dashboard_artifacts.py`. + +## Validação +- **Testes Unitários**: `tests/unit/test_dashboard_artifacts.py` passando (cobre inicialização, listagem e visualização). +- **Testes de Regressão**: `tests/unit/test_dashboard_logic.py` e suite completa passando. +- **Commit Check**: `scripts/commit-check.sh` aprovado. + +## Próximos Passos +- Avançar para F42 (Real-time Output Streaming). diff --git a/features/F41-dashboard-artifacts-explorer/SPEC.md b/features/F41-dashboard-artifacts-explorer/SPEC.md new file mode 100644 index 0000000..1ba0d37 --- /dev/null +++ b/features/F41-dashboard-artifacts-explorer/SPEC.md @@ -0,0 +1,32 @@ +--- +id: F41-dashboard-artifacts-explorer +type: feature +summary: "Navegação e visualização de artefatos gerados na TUI" +inputs: + - "Tecla 'a' ou navegação para aba Artifacts no Dashboard" + - "Seleção de artefato na lista" +outputs: + - "Painel com lista de arquivos em artifacts/" + - "Modal ou painel de preview do conteúdo (para texto)" + - "Exibição de metadados (para binários)" +acceptance_criteria: + - "Deve listar todos os arquivos presentes no diretório de artefatos da run atual" + - "Deve permitir selecionar um arquivo da lista" + - "Deve exibir o conteúdo de arquivos de texto (txt, md, json, py, yaml) no visualizador" + - "Deve exibir apenas metadados (nome, tamanho, path) para arquivos binários ou não suportados" + - "Deve permitir retornar à lista de steps ou fechar o visualizador de artefatos" + - "Deve atualizar a lista se novos artefatos forem gerados durante a execução (se a TUI suportar refresh)" +non_goals: + - "Edição de arquivos" + - "Renderização de imagens ou PDF na TUI" + - "Download/Upload de arquivos via TUI" + - "Delete de artefatos via TUI" +--- + +# Contexto + +O AIgnt OS gera diversos artefatos durante a execução (código, relatórios, diagramas) no diretório `artifacts/`. Atualmente, para visualizar esses arquivos, o operador precisa sair da TUI ou abrir um segundo terminal. A feature F17 trouxe o preview via CLI (`runs show --artifact`), mas a experiência na TUI (F33/F39) ainda carece dessa integração. + +# Objetivo + +Expandir o Dashboard TUI para incluir um explorador de artefatos. O usuário poderá alternar entre a visão de Steps e a visão de Artefatos, listar os arquivos gerados pela run atual e visualizar o conteúdo de arquivos de texto diretamente na interface, mantendo o fluxo de trabalho concentrado no terminal principal. diff --git a/src/aignt_os/cli/dashboard.py b/src/aignt_os/cli/dashboard.py index f81f4ad..f69da1d 100644 --- a/src/aignt_os/cli/dashboard.py +++ b/src/aignt_os/cli/dashboard.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from textual.app import App, ComposeResult from textual.containers import Horizontal, Vertical from textual.reactive import reactive @@ -12,10 +14,12 @@ ListView, RichLog, Static, + TabbedContent, + TabPane, ) from aignt_os.config import AppSettings -from aignt_os.persistence import RunRecord, RunRepository, RunStepRecord +from aignt_os.persistence import ArtifactStore, RunRecord, RunRepository, RunStepRecord class LogViewer(ModalScreen[None]): @@ -220,6 +224,94 @@ def watch_step(self, step: RunStepRecord | None) -> None: content.mount(Label("Timed Out: Yes", classes="detail_row error")) +class ArtifactExplorer(Static): + """Explorador de artefatos da run.""" + + def compose(self) -> ComposeResult: + with Horizontal(id="artifact_container"): + with Vertical(id="artifact_list_container"): + yield Label("Artifacts", classes="header_label") + yield ListView(id="artifact_list") + with Vertical(id="artifact_preview_container"): + yield Label("Preview", classes="header_label") + yield Static(id="artifact_content") + + def load_artifacts(self) -> None: + """Carrega a lista de artefatos.""" + try: + app: Any = self.app + if not hasattr(app, "artifact_store"): + return + + paths = app.artifact_store.list_artifact_paths(app.run_id) + list_view = self.query_one("#artifact_list", ListView) + list_view.clear() + + for path in paths: + list_view.append(ListItem(Label(path))) + + except Exception: + # Silently fail if not ready (e.g. during startup tests) + pass + + def on_list_view_selected(self, message: ListView.Selected) -> None: + """Exibe o conteúdo do artefato selecionado.""" + if message.list_view.id != "artifact_list": + return + + label = message.item.query_one(Label) + path_str = str(label.renderable) # type: ignore[attr-defined] + self.show_artifact(path_str) + + def show_artifact(self, path_str: str) -> None: + """Carrega e exibe o conteúdo do artefato.""" + try: + app: Any = self.app + if not hasattr(app, "settings"): + return + + full_path = app.settings.artifacts_dir_resolved / path_str + content_view = self.query_one("#artifact_content", Static) + + if not full_path.exists() or not full_path.is_file(): + content_view.update("File not found.") + return + + # Check if likely text + suffix = full_path.suffix.lower() + text_extensions = { + ".txt", + ".md", + ".json", + ".yaml", + ".yml", + ".py", + ".log", + ".csv", + ".xml", + ".html", + ".css", + ".js", + } + + if suffix in text_extensions: + try: + content = full_path.read_text(encoding="utf-8", errors="replace") + content_view.update(content) + except Exception as e: + content_view.update(f"Error reading text file: {e}") + else: + stat = full_path.stat() + content_view.update( + f"Binary file or unsupported format.\n\n" + f"Path: {full_path}\n" + f"Size: {stat.st_size} bytes" + ) + + except Exception as e: + self.query_one("#artifact_content", Static).update(f"Error: {e}") + + class RunDashboard(App[None]): """Dashboard TUI Moderno para AIgnt OS.""" @@ -260,22 +352,35 @@ class RunDashboard(App[None]): .status-running { color: $warning; } /* Main Layout */ - #main_container { + TabbedContent { + height: 1fr; + } + + #steps_container, #artifact_container { height: 1fr; width: 1fr; } - #sidebar { + + #sidebar, #artifact_list_container { width: 30%; height: 100%; border-right: solid $primary; background: $surface-darken-2; } - #content { + + #content, #artifact_preview_container { width: 70%; height: 100%; padding: 1 2; background: $surface; } + + #artifact_content { + height: 1fr; + overflow-y: scroll; + border: solid $secondary; + padding: 1; + } /* Steps List */ StepItem { @@ -326,19 +431,28 @@ class RunDashboard(App[None]): BINDINGS = [ ("q", "quit", "Quit"), ("enter", "show_logs", "Show Logs"), + ("a", "show_artifacts", "Artifacts"), ] def __init__(self, run_id: str, refresh_interval: float = 1.0) -> None: super().__init__() self.run_id = run_id self.refresh_interval = refresh_interval - settings = AppSettings() - self.repository = RunRepository(settings.runs_db_path_resolved) + self.settings = AppSettings() + self.repository = RunRepository(self.settings.runs_db_path_resolved) + self.artifact_store = ArtifactStore(self.settings.artifacts_dir_resolved) + self.run_header = RunHeader() self.step_list = ListView(id="step_list") self.step_detail = StepDetail() + self.artifact_explorer = ArtifactExplorer() + self.steps_count = 0 + def action_show_artifacts(self) -> None: + """Switch to artifacts tab.""" + self.query_one(TabbedContent).active = "tab_artifacts" + def action_show_logs(self) -> None: """Show logs for the selected step.""" if self.step_detail.step: @@ -362,24 +476,26 @@ def action_show_logs(self) -> None: except Exception as e: log_content = f"Error reading log file: {e}" - # If no file found but we have content in the mock (for testing) or older records? - # Persistence model relies on files. - - self.push_screen( - LogViewer(f"Logs: Step {step.step_id} ({step.tool_name})", log_content) - ) + self.push_screen(LogViewer(f"Logs: Step {step.step_id} ({step.tool_name})", log_content)) else: self.notify("Select a step first.", severity="warning") def compose(self) -> ComposeResult: yield Header(show_clock=True) yield self.run_header - with Horizontal(id="main_container"): - with Vertical(id="sidebar"): - yield Label("Steps", classes="header_label") - yield self.step_list - with Vertical(id="content"): - yield self.step_detail + + with TabbedContent(initial="tab_steps"): + with TabPane("Steps", id="tab_steps"): + with Horizontal(id="steps_container"): + with Vertical(id="sidebar"): + yield Label("Steps", classes="header_label") + yield self.step_list + with Vertical(id="content"): + yield self.step_detail + + with TabPane("Artifacts", id="tab_artifacts"): + yield self.artifact_explorer + yield Footer() def on_mount(self) -> None: @@ -388,17 +504,22 @@ def on_mount(self) -> None: self.refresh_data() def on_list_view_highlighted(self, message: ListView.Highlighted) -> None: - if isinstance(message.item, StepItem): - self.step_detail.step = message.item.step + if message.list_view.id == "step_list": + if isinstance(message.item, StepItem): + self.step_detail.step = message.item.step def on_list_view_selected(self, message: ListView.Selected) -> None: - if isinstance(message.item, StepItem): - self.step_detail.step = message.item.step - self.action_show_logs() + if message.list_view.id == "step_list": + if isinstance(message.item, StepItem): + self.step_detail.step = message.item.step + self.action_show_logs() def refresh_data(self) -> None: """Atualiza dados do banco.""" try: + # Refresh artifacts + self.artifact_explorer.load_artifacts() + try: run = self.repository.get_run(self.run_id) self.run_header.update_info(run) diff --git a/tests/unit/test_dashboard_artifacts.py b/tests/unit/test_dashboard_artifacts.py new file mode 100644 index 0000000..1bb34f5 --- /dev/null +++ b/tests/unit/test_dashboard_artifacts.py @@ -0,0 +1,105 @@ +from pathlib import Path +from unittest.mock import MagicMock, PropertyMock, patch + +import pytest +from textual.widgets import ListView, Static + +from aignt_os.cli.dashboard import ArtifactExplorer +from aignt_os.persistence import ArtifactStore + + +class TestArtifactExplorer: + @pytest.fixture + def store_mock(self): + return MagicMock(spec=ArtifactStore) + + @pytest.fixture + def app_mock(self, store_mock): + app = MagicMock() + app.run_id = "test-run" + app.artifact_store = store_mock + # Mocking settings + app.settings = MagicMock() + app.settings.artifacts_dir_resolved = Path("/tmp/artifacts") + return app + + def test_initialization(self): + """Test that ArtifactExplorer initializes correctly.""" + explorer = ArtifactExplorer() + assert isinstance(explorer, Static) + + def test_load_artifacts(self, app_mock, store_mock): + """Test loading artifacts list.""" + explorer = ArtifactExplorer() + + with patch.object(ArtifactExplorer, "app", new_callable=PropertyMock) as mock_app_prop: + mock_app_prop.return_value = app_mock + + # Mock artifact paths + store_mock.list_artifact_paths.return_value = ["file1.txt", "dir/file2.json"] + + # Mock UI elements + list_view = MagicMock(spec=ListView) + + # Mock query_one to return list_view when called with "#artifact_list" + def side_effect(selector, type=None): + if selector == "#artifact_list": + return list_view + return MagicMock() + + explorer.query_one = MagicMock(side_effect=side_effect) + + # Call the method + explorer.load_artifacts() + + store_mock.list_artifact_paths.assert_called_once_with("test-run") + assert list_view.clear.call_count == 1 + # It should append list items. + assert list_view.append.call_count == 2 + + def test_show_artifact_content_text(self, app_mock): + """Test showing content of a text file.""" + explorer = ArtifactExplorer() + + with patch.object(ArtifactExplorer, "app", new_callable=PropertyMock) as mock_app_prop: + mock_app_prop.return_value = app_mock + + content_view = MagicMock(spec=Static) + + def side_effect(selector, type=None): + if selector == "#artifact_content": + return content_view + return MagicMock() + + explorer.query_one = MagicMock(side_effect=side_effect) + + # Mock file reading + with patch("pathlib.Path.read_text", return_value="content"): + with patch("pathlib.Path.exists", return_value=True): + with patch("pathlib.Path.is_file", return_value=True): + # Call the method + explorer.show_artifact("file1.txt") + + content_view.update.assert_called_with("content") + + def test_show_artifact_content_binary(self, app_mock): + """Test showing metadata for binary file.""" + explorer = ArtifactExplorer() + + with patch.object(ArtifactExplorer, "app", new_callable=PropertyMock) as mock_app_prop: + mock_app_prop.return_value = app_mock + + content_view = MagicMock(spec=Static) + explorer.query_one = MagicMock(return_value=content_view) + + with patch("pathlib.Path.stat") as mock_stat: + mock_stat.return_value.st_size = 1024 + with patch("pathlib.Path.exists", return_value=True): + with patch("pathlib.Path.is_file", return_value=True): + # Use show_artifact, not show_artifact_content (typo in previous test code) + explorer.show_artifact("image.png") + + # Should verify it called update with some metadata string + args = content_view.update.call_args[0][0] + assert "Binary file" in args + assert "1024 bytes" in args From 066baba48ade2b68f7f65e18daef9b357465d8c7 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Fri, 13 Mar 2026 15:14:38 -0300 Subject: [PATCH 2/2] style(cli): format dashboard artifacts explorer --- src/aignt_os/cli/dashboard.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/aignt_os/cli/dashboard.py b/src/aignt_os/cli/dashboard.py index f69da1d..ede2c1a 100644 --- a/src/aignt_os/cli/dashboard.py +++ b/src/aignt_os/cli/dashboard.py @@ -476,7 +476,9 @@ def action_show_logs(self) -> None: except Exception as e: log_content = f"Error reading log file: {e}" - self.push_screen(LogViewer(f"Logs: Step {step.step_id} ({step.tool_name})", log_content)) + self.push_screen( + LogViewer(f"Logs: Step {step.step_id} ({step.tool_name})", log_content) + ) else: self.notify("Select a step first.", severity="warning")