Files
dl-organizer/src/vlm/review_tui.py
T

389 lines
12 KiB
Python
Raw Normal View History

2026-04-07 08:07:18 +08:00
"""Textual TUI for reviewing high-risk plan operations."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from textual import on
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
from textual.geometry import Size
from textual.screen import ModalScreen, Screen
from textual.widgets import DataTable, Footer, Static
from vlm.plan_review import save_review_csv
from vlm.review_display import (
build_csv_rows,
format_paths_for_detail,
review_row_status_symbol,
risk_flags_to_labels,
)
def _index_from_row_key(row_key) -> int: # noqa: ANN001 - RowKey | str
if isinstance(row_key, str):
return int(row_key)
val = getattr(row_key, "value", None)
if val is None:
return int(row_key)
return int(val)
@dataclass(frozen=True)
class ReviewTUIContext:
"""Inputs for the plan review TUI."""
rows: list[dict[str, str]]
counters: dict[str, int]
library_root: Path
output_csv: Path
plan_input: Path
summary_text: str
class SummaryScreen(Screen):
"""Migration summary; Enter continues, q aborts."""
BINDINGS = [
Binding("enter", "continue_", "继续", show=True),
Binding("q", "quit", "退出", show=True),
]
def __init__(self, ctx: ReviewTUIContext) -> None:
super().__init__()
self._ctx = ctx
def compose(self) -> ComposeResult:
stats_lines = [
"---",
"计划统计",
f" 总操作: {self._ctx.counters['total_operations']}",
f" 高危: {self._ctx.counters['high_risk_operations']}",
f" manual_review: {self._ctx.counters['manual_review']}",
f" sample_source: {self._ctx.counters['sample_source']}",
f" high_season: {self._ctx.counters['high_season']}",
f" high_episode: {self._ctx.counters['high_episode']}",
f" conflicts: {self._ctx.counters['conflicts']}",
"",
f"将要写入: {self._ctx.output_csv}",
"",
"默认仅审核标记为高危的操作(与 CSV 行一致)。",
"",
"Enter 进入审核 · q 退出",
]
body = self._ctx.summary_text + "\n\n" + "\n".join(stats_lines)
yield ScrollableContainer(Static(body, id="summary_body"))
yield Footer()
def action_continue_(self) -> None:
self.dismiss(True)
def action_quit(self) -> None:
self.dismiss(False)
class ConfirmDiscardScreen(ModalScreen[bool]):
"""Confirm discarding unsaved edits."""
BINDINGS = [
Binding("y", "yes", show=False),
Binding("n", "no", show=False),
]
def compose(self) -> ComposeResult:
yield Container(
Static("未保存的修改将丢失。放弃? (y / n)", id="confirm_text"),
id="confirm_box",
)
def action_yes(self) -> None:
self.dismiss(True)
def action_no(self) -> None:
self.dismiss(False)
DEFAULT_CSS = """
ConfirmDiscardScreen {
align: center middle;
}
#confirm_box {
width: auto;
height: auto;
padding: 1 2;
border: thick $primary;
background: $surface;
}
"""
class ReviewMainScreen(Screen):
"""High-risk table + detail pane."""
BINDINGS = [
Binding("up", "cursor_up", show=False),
Binding("down", "cursor_down", show=False),
Binding("k", "cursor_up", show=False),
Binding("j", "cursor_down", show=False),
Binding("a", "keep_row", "保留", show=True),
Binding("r", "reject_row", "驳回", show=True),
Binding("u", "undo_row", "撤销", show=True),
Binding("s", "save", "保存", show=True),
Binding("q", "request_quit", "退出", show=True),
]
def __init__(self, ctx: ReviewTUIContext) -> None:
super().__init__()
self.ctx = ctx
self._by_index: dict[int, dict[str, str]] = {
int(r["index"]): r for r in ctx.rows
}
self.initial_op_by_index: dict[int, str] = {
int(r["index"]): r["operation_type"] for r in ctx.rows
}
self.op_by_index: dict[int, str] = dict(self.initial_op_by_index)
def compose(self) -> ComposeResult:
plan_s = str(self.ctx.plan_input)
if len(plan_s) > 72:
plan_s = plan_s[:35] + "…" + plan_s[-34:]
hdr = (
f"{plan_s} · 高危 {self.ctx.counters['high_risk_operations']}"
f" / {self.ctx.counters['total_operations']}"
)
yield Static(hdr, id="header_line")
with Horizontal(id="body"):
yield DataTable(id="review_table", cursor_type="row", zebra_stripes=True)
with ScrollableContainer(id="detail_scroll"):
yield Static("", id="detail_text")
yield Static(
"↑↓ j/k 移动 · a 保留 · r 驳回(no-op) · u 撤销本条 · s 保存退出 · q 退出",
id="footer_line",
)
yield Footer()
DEFAULT_CSS = """
#header_line {
dock: top;
padding: 0 1;
background: $primary-darken-2;
color: $text;
}
#footer_line {
dock: bottom;
padding: 0 1;
background: $panel;
color: $text-muted;
}
#body {
layout: horizontal;
height: 1fr;
}
#body.vertical-split {
layout: vertical;
height: 1fr;
}
#review_table {
width: 1fr;
min-height: 5;
}
#body.vertical-split #review_table {
height: 40%;
}
#detail_scroll {
width: 1fr;
min-height: 5;
border-left: solid $primary-darken-3;
padding: 0 1;
}
#body.vertical-split #detail_scroll {
border-left: none;
border-top: solid $primary-darken-3;
height: 1fr;
}
"""
def on_mount(self) -> None:
table = self.query_one("#review_table", DataTable)
table.cursor_type = "row"
table.add_column(" ", key="sym", width=3)
table.add_column("#", key="idx", width=4)
table.add_column("类型", key="op", width=11)
table.add_column("风险", key="risk", width=18)
table.add_column("文件", key="file")
for r in self.ctx.rows:
idx = int(r["index"])
sym = self._symbol_for(idx)
table.add_row(
sym,
str(idx),
self.op_by_index[idx],
risk_flags_to_labels(r["risk_flags"]),
Path(r["source_path"]).name,
key=str(idx),
)
self._apply_body_layout(self.app.size)
if table.row_count > 0:
table.focus()
self._refresh_detail(_index_from_row_key(table.ordered_rows[0].key))
else:
self.query_one("#detail_text", Static).update(
"无高危项。按 s 保存仅含表头的 CSV(与无 --tui 行为一致)。"
)
def _table(self) -> DataTable:
return self.query_one("#review_table", DataTable)
def _symbol_for(self, index: int) -> str:
return review_row_status_symbol(
self.op_by_index,
self.initial_op_by_index,
index,
)
def _apply_body_layout(self, app_size: Size) -> None:
body = self.query_one("#body", Horizontal)
if app_size.width < 100:
body.add_class("vertical-split")
else:
body.remove_class("vertical-split")
def on_resize(self, event) -> None: # noqa: ANN001 - textual Resize
self._apply_body_layout(self.app.size)
def _current_index(self) -> int | None:
table = self._table()
if table.row_count == 0:
return None
row_index = table.cursor_coordinate.row
row = table.ordered_rows[row_index]
return _index_from_row_key(row.key)
@on(DataTable.RowHighlighted) # type: ignore[misc]
def on_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
if event.data_table.id != "review_table":
return
idx = _index_from_row_key(event.row_key)
self._refresh_detail(idx)
def _refresh_detail(self, index: int) -> None:
r = self._by_index[index]
op = self.op_by_index[index]
paths = format_paths_for_detail(
r["source_path"],
r.get("destination_path", ""),
self.ctx.library_root,
)
risk_cn = risk_flags_to_labels(r["risk_flags"], max_len=120)
summary = (
f"#{index} · {op} · {Path(r['source_path']).name}"
+ (f" · {risk_cn}" if risk_cn else "")
)
text = (
f"{summary}\n\n"
f"变更\n{paths}\n\n"
f"依据\n{r['reason']}\n\n"
f"标记\n{r['risk_flags']}"
)
self.query_one("#detail_text", Static).update(text)
def _refresh_row_cells(self, index: int) -> None:
table = self._table()
key = str(index)
sym = self._symbol_for(index)
table.update_cell(key, "sym", sym)
table.update_cell(key, "op", self.op_by_index[index])
def _update_dirty_header(self) -> None:
dirty = self._is_dirty()
hdr = self.query_one("#header_line", Static)
plan_s = str(self.ctx.plan_input)
if len(plan_s) > 72:
plan_s = plan_s[:35] + "…" + plan_s[-34:]
star = " *" if dirty else ""
hdr.update(
f"{plan_s}{star} · 高危 {self.ctx.counters['high_risk_operations']}"
f" / {self.ctx.counters['total_operations']}"
)
def _is_dirty(self) -> bool:
return self.op_by_index != self.initial_op_by_index
def action_cursor_up(self) -> None:
if self._table().row_count:
self._table().action_cursor_up()
def action_cursor_down(self) -> None:
if self._table().row_count:
self._table().action_cursor_down()
def action_keep_row(self) -> None:
idx = self._current_index()
if idx is None:
return
self.op_by_index[idx] = self.initial_op_by_index[idx]
self._refresh_row_cells(idx)
self._refresh_detail(idx)
self._update_dirty_header()
def action_reject_row(self) -> None:
idx = self._current_index()
if idx is None:
return
self.op_by_index[idx] = "no-op"
self._refresh_row_cells(idx)
self._refresh_detail(idx)
self._update_dirty_header()
def action_undo_row(self) -> None:
self.action_keep_row()
def action_save(self) -> None:
out_rows = build_csv_rows(self.ctx.rows, self.op_by_index)
save_review_csv(out_rows, self.ctx.output_csv)
self.dismiss("saved")
def action_request_quit(self) -> None:
if not self._is_dirty():
self.dismiss("aborted")
return
def after_confirm(confirmed: bool | None) -> None:
if confirmed:
self.dismiss("aborted")
self.app.push_screen(ConfirmDiscardScreen(), callback=after_confirm)
class PlanReviewApp(App):
"""Application shell: summary screen then review screen."""
def __init__(self, ctx: ReviewTUIContext) -> None:
super().__init__()
self.ctx = ctx
def on_mount(self) -> None:
self.push_screen(SummaryScreen(self.ctx), self._after_summary)
def _after_summary(self, result: bool | None) -> None:
if not result:
self.exit(return_code=1)
return
self.push_screen(ReviewMainScreen(self.ctx), self._after_main)
def _after_main(self, result: str | None) -> None:
if result == "saved":
self.exit(return_code=0)
else:
self.exit(return_code=1)
def run_plan_review_tui(ctx: ReviewTUIContext) -> int:
"""Block until the user finishes the TUI. Returns process exit code."""
app = PlanReviewApp(ctx)
app.run()
code = app.return_code
return 0 if code is None else code