Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions features/F41-dashboard-artifacts-explorer/REPORT.md
Original file line number Diff line number Diff line change
@@ -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).
32 changes: 32 additions & 0 deletions features/F41-dashboard-artifacts-explorer/SPEC.md
Original file line number Diff line number Diff line change
@@ -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.
163 changes: 143 additions & 20 deletions src/aignt_os/cli/dashboard.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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]):
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand All @@ -362,9 +476,6 @@ 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)
)
Expand All @@ -374,12 +485,19 @@ def action_show_logs(self) -> None:
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:
Expand All @@ -388,17 +506,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)
Expand Down
Loading
Loading