forked from MakazhanAlpamys/Soup
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtui_app.py
More file actions
137 lines (112 loc) · 5.01 KB
/
Copy pathtui_app.py
File metadata and controls
137 lines (112 loc) · 5.01 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
"""Textual application for ``soup tui`` (v0.34.0 Part G).
Kept in its own module so the heavy ``textual`` import only fires when the
TUI is actually launched. The class derives from ``textual.App`` if textual
is available; otherwise importing this module raises ImportError, which
``commands/tui.py`` catches and turns into a friendly install hint.
"""
from __future__ import annotations
from typing import Any, List
try:
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import DataTable, Footer, Header, Static
except ImportError as exc: # pragma: no cover - guarded by commands/tui.py
raise ImportError(
"textual is required for `soup tui`; install with `pip install textual`"
) from exc
from rich.markup import escape as markup_escape
from soup_cli.utils.replay import summarise
from soup_cli.utils.run_cost import format_cost_usd
def _safe(value, fallback: str = "-") -> str:
"""Stringify a DB value with markup escaping (defends against [...] in names)."""
if value is None or value == "":
return fallback
return markup_escape(str(value))
def build_runs_table_rows(runs: List[dict]) -> List[List[str]]:
"""Build the rows shown in the Runs table. Pure for testability."""
rows: List[List[str]] = []
for run in runs:
cost = run.get("cost_usd")
rows.append([
_safe(run.get("run_id"))[:32],
_safe(run.get("experiment_name"))[:24],
_safe(run.get("base_model"))[:32],
_safe(run.get("task")),
_safe(run.get("status")),
f"{run['final_loss']:.4f}" if run.get("final_loss") is not None else "-",
str(run.get("total_steps") or "-"),
format_cost_usd(cost),
])
return rows
def build_run_detail(run: dict, metrics: List[dict]) -> str:
"""Render the right-hand detail panel for a selected run."""
summary = summarise(metrics)
lines = [
f"Run ID: {_safe(run.get('run_id'))}",
f"Status: {_safe(run.get('status'))}",
f"Model: {_safe(run.get('base_model'))}",
f"Task: {_safe(run.get('task'))}",
]
if summary.initial_loss is not None and summary.final_loss is not None:
lines.append(f"Loss: {summary.initial_loss:.4f} → {summary.final_loss:.4f}")
if summary.min_loss is not None and summary.min_loss_step is not None:
lines.append(f"Best: {summary.min_loss:.4f} @ step {summary.min_loss_step}")
cost = run.get("cost_usd")
if cost is not None:
lines.append(f"Cost: {format_cost_usd(cost)}")
if run.get("duration_secs"):
lines.append(f"Time: {run['duration_secs']:.0f}s")
return "\n".join(lines)
class SoupTuiApp(App): # type: ignore[misc]
"""Two-pane dashboard: list of runs (left) + detail (right)."""
BINDINGS = [
("q", "quit", "Quit"),
("r", "refresh", "Refresh"),
]
CSS = """
Screen {
layout: vertical;
}
#runs-table { height: 1fr; }
#detail { width: 40%; padding: 1 2; }
"""
_COLUMNS = ("Run", "Name", "Model", "Task", "Status", "Loss", "Steps", "Cost")
def __init__(self, *, refresh_secs: float = 1.0, run_limit: int = 50) -> None:
super().__init__()
self._refresh_secs = max(0.25, min(refresh_secs, 10.0))
self._run_limit = max(1, min(run_limit, 1000))
# ExperimentTracker is imported lazily — opens a SQLite connection,
# and Textual is sometimes imported in environments without write
# access to the default `~/.soup` dir.
from soup_cli.experiment.tracker import ExperimentTracker
self._tracker = ExperimentTracker()
self._runs: List[dict] = []
def compose(self) -> ComposeResult: # pragma: no cover - UI plumbing
yield Header()
with Horizontal():
with Vertical():
yield DataTable(id="runs-table")
yield Static("Select a run", id="detail")
yield Footer()
def on_mount(self) -> None: # pragma: no cover - UI plumbing
table = self.query_one("#runs-table", DataTable)
table.cursor_type = "row"
table.add_columns(*self._COLUMNS)
self._reload()
self.set_interval(self._refresh_secs, self._reload)
def action_refresh(self) -> None: # pragma: no cover - UI plumbing
self._reload()
def _reload(self) -> None: # pragma: no cover - thin SQLite wrapper
self._runs = self._tracker.list_runs(limit=self._run_limit)
table = self.query_one("#runs-table", DataTable)
table.clear()
for row in build_runs_table_rows(self._runs):
table.add_row(*row)
def on_data_table_row_selected(self, event: Any) -> None: # pragma: no cover
index = getattr(event, "cursor_row", 0)
if not (0 <= index < len(self._runs)):
return
run = self._runs[index]
metrics = self._tracker.get_metrics(run["run_id"])
detail = self.query_one("#detail", Static)
detail.update(build_run_detail(run, metrics))