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
54 changes: 52 additions & 2 deletions framework/tui/palette.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@
constants are for the Rich Text styles built in Python (DataTable cells, detail
panes, etc.) which don't resolve Textual CSS $variables. Use the semantic roles
(OK/WARN/FAIL/ACCENT/INFO/MUTED) in screen code so intent stays readable.

Status cells in tables should go through pill()/status_pill() rather than
hand-picked colors, so every screen renders the same pass/warn/fail language.
"""

from rich.style import Style
from rich.text import Text

# raw palette
BG = "#1A1B26"
FG = "#C0CAF5"
Expand All @@ -21,8 +27,52 @@

# semantic roles
ACCENT = PURPLE # keys, cursors, primary accents
INFO = CYAN # identifiers, breadcrumb
MUTED = SUBTLE # dim / secondary text
INFO = CYAN # identifiers, breadcrumb
MUTED = SUBTLE # dim / secondary text
OK = GREEN
WARN = YELLOW
FAIL = RED

# Pill pairs: readable text on a muted swatch of the same hue. These are the
# text-*/*-muted values Textual derives for tokyo-night, frozen here for the
# same reason as the raw palette above (Rich can't resolve $variables).
OK_TEXT, OK_MUTED = "#BEDE9C", "#41503A"
WARN_TEXT, WARN_MUTED = "#EACA9B", "#554739"
FAIL_TEXT, FAIL_MUTED = "#F9A4B4", "#5C3645"

_TONES = {
"ok": (OK_TEXT, OK_MUTED),
"warn": (WARN_TEXT, WARN_MUTED),
"fail": (FAIL_TEXT, FAIL_MUTED),
}

# Every status word the screens emit, mapped to a tone. None means "recede":
# plain muted text instead of a pill, so pending rows don't compete with results.
STATUS_TONE = {
"queued": None,
"running": "warn",
"ok": "ok",
"failed": "fail",
"partial": "warn",
"timeout": "fail",
"skipped": "warn",
"error": "fail",
}


def pill(label: str, tone: str) -> Text:
"""A status pill for table cells. Unknown tones fall back to muted text so
a new status word can never crash a repaint."""
spec = _TONES.get(tone)
if spec is None:
return Text(label, style=MUTED)
fg, bg = spec
return Text(f" {label} ", style=Style(color=fg, bgcolor=bg, bold=True))


def status_pill(status: str) -> Text:
"""The shared renderer for run/entry status cells across all screens."""
tone = STATUS_TONE.get(status)
if tone is None:
return Text(status.upper(), style=MUTED)
return pill(status.upper(), tone)
40 changes: 21 additions & 19 deletions framework/tui/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from rich.table import Table
from rich.text import Text

from framework.tui import palette


def _fmt_default(value: Any) -> str:
if value is None:
Expand All @@ -27,16 +29,16 @@ def _field_table(title: str, fields: List[dict]) -> RenderableType:
return Group(heading, Text(" (none)", style="dim"))

table = Table(box=None, pad_edge=False, expand=True, show_edge=False)
table.add_column("name", style="cyan", no_wrap=True)
table.add_column("name", style=palette.INFO, no_wrap=True)
table.add_column("type", style="dim")
table.add_column("req", justify="center")
table.add_column("default")
table.add_column("env var", style="green")
table.add_column("env var", style=palette.OK)
table.add_column("description", style="dim", overflow="fold")

for f in fields:
required = f.get("required")
req_cell = Text("yes", style="yellow") if required else Text("no", style="dim")
req_cell = Text("yes", style=palette.WARN) if required else Text("no", style="dim")
per_target = " ·per-target" if f.get("per_target") else ""
table.add_row(
f.get("name", ""),
Expand All @@ -52,15 +54,15 @@ def _field_table(title: str, fields: List[dict]) -> RenderableType:
def fetcher_detail(f: dict) -> RenderableType:
"""Full detail view for one fetcher descriptor."""
title = Text()
title.append(f.get("name", ""), style="bold white")
title.append(f.get("name", ""), style=f"bold {palette.FG}")
if f.get("version"):
title.append(f" v{f['version']}", style="dim")

meta = Text()
meta.append("category: ", style="dim")
meta.append(f.get("category") or "—", style="white")
meta.append(f.get("category") or "—", style=palette.FG)
meta.append(" targets: ", style="dim")
meta.append("yes" if f.get("supports_targets") else "no", style="white")
meta.append("yes" if f.get("supports_targets") else "no", style=palette.FG)

description = Text(f.get("description") or "(no description)", style="italic")

Expand Down Expand Up @@ -96,7 +98,7 @@ def _env_name(ref: Any) -> str:

def _kv_table(rows: List[tuple]) -> Table:
table = Table(box=None, pad_edge=False, expand=True, show_edge=False, show_header=False)
table.add_column(style="cyan", no_wrap=True)
table.add_column(style=palette.INFO, no_wrap=True)
table.add_column()
for name, value in rows:
table.add_row(name, value)
Expand All @@ -105,22 +107,22 @@ def _kv_table(rows: List[tuple]) -> Table:

def _status(set_: bool, required: bool) -> Text:
if set_:
return Text("set", style="green")
return Text("required — unset", style="yellow") if required else Text("unset", style="dim")
return Text("set", style=palette.OK)
return Text("required — unset", style=palette.WARN) if required else Text("unset", style="dim")


def entry_detail(descriptor: Optional[dict], entry: dict, errors: Optional[List[str]] = None) -> RenderableType:
"""Render one manifest entry: its current config/secrets/targets vs the contract."""
use = entry.get("use", "?")
if descriptor is None:
return Group(
Text(use, style="bold white"),
Text("unknown fetcher — not discovered in the catalog", style="yellow"),
Text(use, style=f"bold {palette.FG}"),
Text("unknown fetcher — not discovered in the catalog", style=palette.WARN),
)

fanout = descriptor.get("supports_targets")
header = Text()
header.append(use, style="bold white")
header.append(use, style=f"bold {palette.FG}")
header.append(" [fanout]" if fanout else " [single]", style="dim")

cfg = entry.get("config") or {}
Expand All @@ -133,7 +135,7 @@ def entry_detail(descriptor: Optional[dict], entry: dict, errors: Optional[List[
rows = []
for s in top_secrets:
current = _env_name(secs.get(s["name"]))
value = Text(f"${{env:{current}}}", style="green") if current else _status(False, True)
value = Text(f"${{env:{current}}}", style=palette.OK) if current else _status(False, True)
rows.append((s["name"], value))
parts += [Text("secrets", style="bold"), _kv_table(rows), Text()]

Expand All @@ -143,7 +145,7 @@ def entry_detail(descriptor: Optional[dict], entry: dict, errors: Optional[List[
rows = []
for c in config_fields:
if c["name"] in cfg:
rows.append((c["name"], Text(str(cfg[c["name"]]), style="white")))
rows.append((c["name"], Text(str(cfg[c["name"]]), style=palette.FG)))
elif c.get("default") is not None:
rows.append((c["name"], Text(f"{c['default']} (default)", style="dim")))
else:
Expand All @@ -155,21 +157,21 @@ def entry_detail(descriptor: Optional[dict], entry: dict, errors: Optional[List[
targets = entry.get("targets") or []
parts.append(Text(f"targets ({len(targets)})", style="bold"))
if not targets:
parts.append(Text(" none — press 't' to add", style="yellow"))
parts.append(Text(" none — press 't' to add", style=palette.WARN))
for i, t in enumerate(targets):
values = {k: v for k, v in t.items() if k != "secrets"}
summary = " ".join(f"{k}={v}" for k, v in values.items()) or "(empty)"
line = Text(f" [{i}] ", style="dim")
line.append(summary, style="white")
line.append(summary, style=palette.FG)
tsec = t.get("secrets") or {}
if tsec:
line.append(" " + ", ".join(f"{k}→{_env_name(v)}" for k, v in tsec.items()), style="green")
line.append(" " + ", ".join(f"{k}→{_env_name(v)}" for k, v in tsec.items()), style=palette.OK)
parts.append(line)
parts.append(Text())

if errors:
parts.append(Text("issues", style="bold red"))
parts.append(Text("issues", style=f"bold {palette.FAIL}"))
for e in errors:
parts.append(Text(f" ✗ {e}", style="yellow"))
parts.append(Text(f" ✗ {e}", style=palette.WARN))

return Group(*parts)
12 changes: 6 additions & 6 deletions framework/tui/screens/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,16 @@ class CatalogPage(Horizontal):

def compose(self) -> ComposeResult:
with Vertical(id="catalog-left", classes="panel"):
yield Static("fetchers", id="catalog-left-title", classes="panel-title")
yield Input(placeholder="/ filter fetchers…", id="catalog-search")
yield Tree("fetchers", id="catalog-tree")
with VerticalScroll(id="catalog-detail-scroll", classes="panel"):
yield Static("contract", id="catalog-detail-title", classes="panel-title")
yield Static(render.empty_detail(), id="catalog-detail")

def on_mount(self) -> None:
self.query_one("#catalog-tree", Tree).show_root = False
self.query_one("#catalog-detail-scroll", VerticalScroll).can_focus = True
detail = self.query_one("#catalog-detail-scroll", VerticalScroll)
detail.can_focus = True
detail.border_title = "contract"
self.rebuild()

# -- data ------------------------------------------------------------- #
Expand All @@ -50,11 +50,11 @@ def rebuild(self) -> None:
"""Repopulate the tree from the App's cached catalog, applying the filter."""
data = getattr(self.app, "catalog_data", None)
tree = self.query_one("#catalog-tree", Tree)
title = self.query_one("#catalog-left-title", Static)
panel = self.query_one("#catalog-left", Vertical)
tree.clear()

if not data:
title.update("fetchers (none discovered)")
panel.border_title = "fetchers (none discovered)"
return

flt = self._filter.strip().lower()
Expand All @@ -69,7 +69,7 @@ def rebuild(self) -> None:
total += 1

tree.root.expand()
title.update(f"fetchers ({total})")
panel.border_title = f"fetchers ({total})"

# -- events ----------------------------------------------------------- #

Expand Down
36 changes: 23 additions & 13 deletions framework/tui/screens/evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from textual.widgets import Button, DataTable, Static

from framework import api
from framework.tui import palette
from framework.tui.modals import PreviewModal


Expand All @@ -35,14 +36,19 @@ def compose(self) -> ComposeResult:
yield Button("Refresh", id="evidence-refresh")
with Horizontal(id="evidence-body"):
with Vertical(id="evidence-left", classes="panel"):
yield Static("runs", classes="panel-title")
yield DataTable(id="evidence-runs")
yield Static(
f"no runs yet — execute the manifest on tab [bold {palette.ACCENT}]3[/]",
classes="empty-hint",
)
with Vertical(id="evidence-right", classes="panel"):
yield Static("", id="evidence-run-header", classes="panel-title")
yield DataTable(id="evidence-files")
yield Static("evidence files from the selected run appear here", classes="empty-hint")

def on_mount(self) -> None:
self._runs: List[dict] = []
self.query_one("#evidence-left", Vertical).border_title = "runs"
self.query_one("#evidence-right", Vertical).border_title = "files"
runs = self.query_one("#evidence-runs", DataTable)
runs.cursor_type = "row"
runs.zebra_stripes = True
Expand Down Expand Up @@ -77,24 +83,25 @@ def rebuild_runs(self) -> None:
dt.clear()
for r in self._runs:
if not r.get("complete", True):
when = Text("incomplete", style="yellow") # no _run_metadata.json (aborted run)
when = Text("incomplete", style=palette.WARN) # no _run_metadata.json (aborted run)
else:
when = r.get("completed_at") or r.get("started_at") or ""
fail_style = "red" if r["fail"] else "dim"
fail_style = palette.FAIL if r["fail"] else "dim"
dt.add_row(
r["run_id"],
when,
Text(str(r["ok"]), style="green"),
Text(str(r["ok"]), style=palette.OK),
Text(str(r["fail"]), style=fail_style),
key=r["dir"],
)

self.query_one("#evidence-left", Vertical).set_class(not self._runs, "empty")
if self._runs:
self._show_run(self._runs[0]["dir"])
else:
self.query_one("#evidence-run-header", Static).update(
Text(f"no runs found under {out}", style="dim")
)
right = self.query_one("#evidence-right", Vertical)
right.border_title = "files"
right.set_class(True, "empty")
self.query_one("#evidence-files", DataTable).clear()

def _show_run(self, run_dir: str) -> None:
Expand All @@ -103,19 +110,22 @@ def _show_run(self, run_dir: str) -> None:
files.clear()
if run is None:
return
header = Text(f"{run['run_id']} ", style="bold")
header.append(f"✓ {run['ok']} ✗ {run['fail']} {run.get('completed_at') or ''}", style="dim")
self.query_one("#evidence-run-header", Static).update(header)
# The run's scoreboard lives in the panel border, Posting-style.
right = self.query_one("#evidence-right", Vertical)
right.border_title = (
f"{run['run_id']} [{palette.OK}]✓ {run['ok']}[/] [{palette.FAIL}]✗ {run['fail']}[/]"
)
right.set_class(not run["files"], "empty")
for f in run["files"]:
tgt = f.get("target")
tlabel = " ".join(f"{k}={v}" for k, v in tgt.items()) if isinstance(tgt, dict) else ""
code = f.get("exit_code")
if code is None:
ecell = Text("—", style="dim")
elif code == 0:
ecell = Text("0", style="green")
ecell = Text("0", style=palette.OK)
else:
ecell = Text(str(code), style="red")
ecell = palette.pill(str(code), "fail")
files.add_row(f["name"], f.get("fetcher") or "—", tlabel or "—", ecell, key=f["path"])

# -- events ----------------------------------------------------------- #
Expand Down
Loading
Loading