Skip to content

Commit 5646f8d

Browse files
authored
feat(tui): terminal dashboard with Textual (#313)
## Summary - New `cf dashboard` command with live Textual TUI - Task board, event log, blocker panel, status bar - Auto-refresh, keyboard shortcuts (q/r/tab) - SSH-friendly, minimal deps (textual builds on rich) - 11 tests, 1935 existing tests still pass ## Validation - Review feedback: 5 CodeRabbit items, 1 fixed (error notification) - Demo: All 5 acceptance criteria verified - CI: All checks green Closes #313
1 parent e87c4a1 commit 5646f8d

7 files changed

Lines changed: 498 additions & 0 deletions

File tree

codeframe/cli/app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5162,6 +5162,10 @@ def templates_apply(
51625162

51635163
app.add_typer(proof_app, name="proof")
51645164

5165+
from codeframe.cli.dashboard_commands import dashboard_app # noqa: E402
5166+
5167+
app.add_typer(dashboard_app, name="dashboard")
5168+
51655169

51665170
# =============================================================================
51675171
# Version command
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""CLI command for launching the TUI dashboard.
2+
3+
Provides `cf dashboard` to launch the Textual-based terminal dashboard.
4+
"""
5+
6+
from pathlib import Path
7+
from typing import Optional
8+
9+
import typer
10+
from rich.console import Console
11+
12+
console = Console()
13+
14+
dashboard_app = typer.Typer(
15+
name="dashboard",
16+
help="Terminal dashboard for monitoring workspace state",
17+
invoke_without_command=True,
18+
)
19+
20+
21+
@dashboard_app.callback(invoke_without_command=True)
22+
def dashboard(
23+
repo_path: Optional[Path] = typer.Option(
24+
None,
25+
"--workspace", "-w",
26+
help="Workspace path (defaults to current directory)",
27+
),
28+
refresh_interval: int = typer.Option(
29+
2,
30+
"--refresh-interval",
31+
min=1,
32+
max=60,
33+
help="Seconds between data refreshes (default: 2)",
34+
),
35+
) -> None:
36+
"""Launch the TUI dashboard.
37+
38+
Shows a live terminal dashboard with task board, event log,
39+
and blocker notifications. Updates automatically.
40+
41+
Keyboard shortcuts:
42+
q Quit
43+
r Force refresh
44+
Tab Switch panels
45+
Up/Down Navigate rows
46+
47+
Example:
48+
codeframe dashboard
49+
codeframe dashboard --refresh-interval 5
50+
"""
51+
from codeframe.core.workspace import get_workspace
52+
from codeframe.tui.app import DashboardApp
53+
54+
workspace_path = repo_path or Path.cwd()
55+
56+
try:
57+
workspace = get_workspace(workspace_path)
58+
except Exception as e:
59+
console.print(f"[red]Error:[/red] {e}")
60+
console.print("[dim]Run 'codeframe init' to create a workspace first.[/dim]")
61+
raise typer.Exit(1)
62+
63+
app = DashboardApp(
64+
workspace=workspace,
65+
refresh_interval=refresh_interval,
66+
)
67+
app.run()

codeframe/tui/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""TUI dashboard for CodeFRAME.
2+
3+
Terminal-based dashboard using Textual for power users.
4+
Read-only view of workspace state — tasks, events, blockers.
5+
"""

codeframe/tui/app.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
"""CodeFRAME TUI Dashboard — live terminal dashboard.
2+
3+
A Textual application showing tasks, events, and blockers
4+
with auto-refresh and keyboard navigation.
5+
"""
6+
7+
from typing import Optional
8+
9+
from textual.app import App, ComposeResult
10+
from textual.binding import Binding
11+
from textual.containers import Horizontal, Vertical
12+
from textual.reactive import reactive
13+
from textual.widgets import DataTable, Footer, Header, RichLog, Static
14+
15+
from codeframe.core.workspace import Workspace
16+
from codeframe.tui.data_service import DashboardData, load_dashboard_data
17+
18+
19+
# Status → color mapping for task rows
20+
_STATUS_COLORS: dict[str, str] = {
21+
"DONE": "green",
22+
"IN_PROGRESS": "cyan",
23+
"READY": "yellow",
24+
"BACKLOG": "dim",
25+
"BLOCKED": "red",
26+
"FAILED": "red bold",
27+
"MERGED": "green dim",
28+
}
29+
30+
31+
class StatusBar(Static):
32+
"""Top status bar showing task counts and workspace info."""
33+
34+
def update_from_data(self, data: DashboardData) -> None:
35+
counts = data.task_counts
36+
total = sum(counts.values())
37+
done = counts.get("DONE", 0) + counts.get("MERGED", 0)
38+
active = counts.get("IN_PROGRESS", 0)
39+
blocked = counts.get("BLOCKED", 0) + counts.get("FAILED", 0)
40+
ready = counts.get("READY", 0) + counts.get("BACKLOG", 0)
41+
42+
parts = [
43+
f"[bold]{data.workspace_name}[/bold]",
44+
f"Tasks: {total}",
45+
f"[green]{done} done[/green]",
46+
f"[cyan]{active} active[/cyan]",
47+
f"[yellow]{ready} ready[/yellow]",
48+
]
49+
if blocked > 0:
50+
parts.append(f"[red]{blocked} blocked/failed[/red]")
51+
if data.blocker_count > 0:
52+
parts.append(f"[red bold]{data.blocker_count} blockers[/red bold]")
53+
54+
self.update(" | ".join(parts))
55+
56+
57+
class DashboardApp(App):
58+
"""CodeFRAME TUI Dashboard."""
59+
60+
CSS = """
61+
Screen {
62+
layout: vertical;
63+
}
64+
#status-bar {
65+
height: 1;
66+
background: $surface;
67+
padding: 0 1;
68+
}
69+
#main-content {
70+
height: 1fr;
71+
}
72+
#task-panel {
73+
width: 2fr;
74+
border: solid $primary;
75+
}
76+
#right-panel {
77+
width: 1fr;
78+
}
79+
#event-log {
80+
height: 2fr;
81+
border: solid $secondary;
82+
}
83+
#blocker-panel {
84+
height: 1fr;
85+
border: solid $error;
86+
}
87+
DataTable {
88+
height: 1fr;
89+
}
90+
RichLog {
91+
height: 1fr;
92+
}
93+
.panel-title {
94+
background: $surface;
95+
padding: 0 1;
96+
text-style: bold;
97+
}
98+
"""
99+
100+
TITLE = "CodeFRAME Dashboard"
101+
BINDINGS = [
102+
Binding("q", "quit", "Quit"),
103+
Binding("r", "refresh", "Refresh"),
104+
Binding("tab", "focus_next", "Next Panel"),
105+
Binding("shift+tab", "focus_previous", "Prev Panel"),
106+
]
107+
108+
workspace: Optional[Workspace] = None
109+
refresh_interval: int = 2
110+
data: reactive[Optional[DashboardData]] = reactive(None)
111+
112+
def __init__(
113+
self,
114+
workspace: Workspace,
115+
refresh_interval: int = 2,
116+
**kwargs,
117+
):
118+
super().__init__(**kwargs)
119+
self.workspace = workspace
120+
self.refresh_interval = refresh_interval
121+
122+
def compose(self) -> ComposeResult:
123+
yield Header()
124+
yield StatusBar(id="status-bar")
125+
with Horizontal(id="main-content"):
126+
with Vertical(id="task-panel"):
127+
yield Static("Tasks", classes="panel-title")
128+
yield DataTable(id="task-table")
129+
with Vertical(id="right-panel"):
130+
with Vertical(id="event-log"):
131+
yield Static("Recent Events", classes="panel-title")
132+
yield RichLog(id="event-log-content", highlight=True, markup=True)
133+
with Vertical(id="blocker-panel"):
134+
yield Static("Open Blockers", classes="panel-title")
135+
yield RichLog(id="blocker-log", highlight=True, markup=True)
136+
yield Footer()
137+
138+
def on_mount(self) -> None:
139+
# Set up task table columns
140+
table = self.query_one("#task-table", DataTable)
141+
table.add_columns("ID", "Title", "Status", "Priority")
142+
table.cursor_type = "row"
143+
144+
# Initial data load
145+
self._refresh_data()
146+
147+
# Auto-refresh
148+
self.set_interval(self.refresh_interval, self._refresh_data)
149+
150+
def _refresh_data(self) -> None:
151+
"""Load fresh data from the workspace and update all widgets."""
152+
if not self.workspace:
153+
return
154+
155+
data = load_dashboard_data(self.workspace)
156+
self.data = data
157+
158+
self._update_status_bar(data)
159+
self._update_task_table(data)
160+
self._update_event_log(data)
161+
self._update_blocker_panel(data)
162+
163+
if data.error:
164+
self.notify(f"Data loading error: {data.error}", severity="warning")
165+
166+
def _update_status_bar(self, data: DashboardData) -> None:
167+
status_bar = self.query_one("#status-bar", StatusBar)
168+
status_bar.update_from_data(data)
169+
170+
def _update_task_table(self, data: DashboardData) -> None:
171+
table = self.query_one("#task-table", DataTable)
172+
table.clear()
173+
174+
for task in data.tasks:
175+
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
176+
color = _STATUS_COLORS.get(status_val, "white")
177+
table.add_row(
178+
task.id[:8],
179+
task.title[:50],
180+
f"[{color}]{status_val}[/{color}]",
181+
str(task.priority),
182+
)
183+
184+
def _update_event_log(self, data: DashboardData) -> None:
185+
log = self.query_one("#event-log-content", RichLog)
186+
log.clear()
187+
188+
for event in reversed(data.events): # oldest first
189+
ts = event.created_at.strftime("%H:%M:%S") if hasattr(event.created_at, "strftime") else str(event.created_at)[:8]
190+
log.write(f"[dim]{ts}[/dim] {event.event_type}")
191+
192+
def _update_blocker_panel(self, data: DashboardData) -> None:
193+
log = self.query_one("#blocker-log", RichLog)
194+
log.clear()
195+
196+
if not data.blockers:
197+
log.write("[dim]No open blockers[/dim]")
198+
return
199+
200+
for blocker in data.blockers:
201+
log.write(f"[red bold]{blocker.id[:8]}[/red bold]: {blocker.question[:60]}")
202+
203+
def action_refresh(self) -> None:
204+
"""Manual refresh via 'r' key."""
205+
self._refresh_data()
206+
self.notify("Refreshed")

codeframe/tui/data_service.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Dashboard data service — thin wrapper over core modules.
2+
3+
Loads all dashboard data in a single call to minimize DB access.
4+
"""
5+
6+
from dataclasses import dataclass, field
7+
from typing import Optional
8+
9+
from codeframe.core.workspace import Workspace
10+
11+
12+
@dataclass
13+
class DashboardData:
14+
"""Snapshot of workspace state for display."""
15+
16+
workspace_name: str = ""
17+
workspace_path: str = ""
18+
tech_stack: str = ""
19+
20+
# Task counts by status
21+
task_counts: dict[str, int] = field(default_factory=dict)
22+
tasks: list = field(default_factory=list)
23+
24+
# Open blockers
25+
blockers: list = field(default_factory=list)
26+
blocker_count: int = 0
27+
28+
# Recent events
29+
events: list = field(default_factory=list)
30+
31+
# Error (if data loading failed)
32+
error: Optional[str] = None
33+
34+
35+
def load_dashboard_data(
36+
workspace: Workspace, event_limit: int = 50
37+
) -> DashboardData:
38+
"""Load all dashboard data from a workspace.
39+
40+
Queries tasks, blockers, and events in one shot.
41+
Returns a DashboardData snapshot for rendering.
42+
"""
43+
data = DashboardData(
44+
workspace_name=workspace.repo_path.name,
45+
workspace_path=str(workspace.repo_path),
46+
tech_stack=workspace.tech_stack or "",
47+
)
48+
49+
try:
50+
from codeframe.core import tasks as task_module
51+
52+
all_tasks = task_module.list_tasks(workspace)
53+
data.tasks = all_tasks
54+
55+
counts: dict[str, int] = {}
56+
for t in all_tasks:
57+
status_name = t.status.value if hasattr(t.status, "value") else str(t.status)
58+
counts[status_name] = counts.get(status_name, 0) + 1
59+
data.task_counts = counts
60+
except Exception as exc:
61+
data.error = f"Tasks: {exc}"
62+
63+
try:
64+
from codeframe.core import blockers
65+
data.blockers = blockers.list_open(workspace)
66+
data.blocker_count = len(data.blockers)
67+
except Exception as exc:
68+
if not data.error:
69+
data.error = f"Blockers: {exc}"
70+
71+
try:
72+
from codeframe.core.events import list_recent
73+
data.events = list_recent(workspace, limit=event_limit)
74+
except Exception as exc:
75+
if not data.error:
76+
data.error = f"Events: {exc}"
77+
78+
return data

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ dependencies = [
3737
"aiohttp>=3.9.0",
3838
"typer>=0.9.0",
3939
"rich>=13.7.0",
40+
"textual>=0.86.0",
4041
"requests>=2.31.0",
4142
"gitpython>=3.1.40",
4243
"pyyaml>=6.0.0",

0 commit comments

Comments
 (0)