"""Textual TUI for reviewing high-risk plan operations.""" from __future__ import annotations from dataclasses import dataclass from pathlib import Path 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, ) try: from textual import on from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Container, Horizontal, ScrollableContainer from textual.geometry import Size from textual.screen import ModalScreen, Screen from textual.widgets import DataTable, Footer, Static except ImportError as exc: # pragma: no cover - exercised via runtime fallback TEXTUAL_IMPORT_ERROR: ImportError | None = exc else: TEXTUAL_IMPORT_ERROR = None MISSING_TEXTUAL_MESSAGE = 'Textual is not installed. Install with: uv pip install -e ".[tui]"' @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 if TEXTUAL_IMPORT_ERROR is None: 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) 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 else: def run_plan_review_tui(ctx: ReviewTUIContext) -> int: """Raise a friendly error when the optional Textual dependency is missing.""" raise RuntimeError(MISSING_TEXTUAL_MESSAGE) from TEXTUAL_IMPORT_ERROR