Improve plan review UX with enriched rows, TUI filters, and execute gate.

Make review-plan easier to act on: Chinese risk labels, relative paths,
verdict/next-step footer, optional identity/analysis enrichment, grouping,
spot-check sampling, and structure preview. Extend the TUI with filters,
duplicate-group reject, and quality context. Persist review_context on
plan operations and add --require-review for confirmed execute.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
windyboy
2026-05-21 09:27:12 +08:00
co-authored by Cursor
parent c9d22d5136
commit 5f0b531269
13 changed files with 1050 additions and 61 deletions
+159 -31
View File
@@ -2,9 +2,10 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from vlm.plan_review import save_review_csv
from vlm.review_display import (
build_csv_rows,
@@ -12,22 +13,28 @@ from vlm.review_display import (
review_row_status_symbol,
risk_flags_to_labels,
)
from vlm.utils import format_size
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
FILTER_CHOICES = ("all", "manual_review", "sample_source", "conflict", "duplicate")
MISSING_TEXTUAL_MESSAGE = 'Textual is not installed. Install with: uv pip install -e ".[tui]"'
def _row_matches_filter(row: dict[str, str], filter_key: str) -> bool:
if filter_key == "all":
return True
flags = row.get("risk_flags", "")
if filter_key in flags.split("|"):
return True
if filter_key == "duplicate" and row.get("duplicate_group_id"):
return True
return False
def _change_label(row: dict[str, str]) -> str:
src = row.get("source_name") or Path(row.get("source_path", "")).name
dst = row.get("dest_name") or ""
if dst:
return f"{src}{dst}"
return src
@dataclass(frozen=True)
@@ -40,8 +47,24 @@ class ReviewTUIContext:
output_csv: Path
plan_input: Path
summary_text: str
path_to_quality: dict[str, dict] = field(default_factory=dict)
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
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]"'
if TEXTUAL_IMPORT_ERROR is None:
def _index_from_row_key(row_key) -> int: # noqa: ANN001 - RowKey | str
@@ -52,7 +75,6 @@ if TEXTUAL_IMPORT_ERROR is None:
return int(row_key)
return int(val)
class SummaryScreen(Screen):
"""Migration summary; Enter continues, q aborts."""
@@ -80,6 +102,7 @@ if TEXTUAL_IMPORT_ERROR is None:
f"将要写入: {self._ctx.output_csv}",
"",
"默认仅审核标记为高危的操作(与 CSV 行一致)。",
"筛选: 1全部 2需人工 3样片 4冲突 5重复 · g 驳回整组重复",
"",
"Enter 进入审核 · q 退出",
]
@@ -139,8 +162,14 @@ if TEXTUAL_IMPORT_ERROR is None:
Binding("a", "keep_row", "保留", show=True),
Binding("r", "reject_row", "驳回", show=True),
Binding("u", "undo_row", "撤销", show=True),
Binding("g", "reject_group", "驳回组", show=True),
Binding("s", "save", "保存", show=True),
Binding("q", "request_quit", "退出", show=True),
Binding("1", "filter_all", show=False),
Binding("2", "filter_manual", show=False),
Binding("3", "filter_sample", show=False),
Binding("4", "filter_conflict", show=False),
Binding("5", "filter_duplicate", show=False),
]
def __init__(self, ctx: ReviewTUIContext) -> None:
@@ -153,6 +182,7 @@ if TEXTUAL_IMPORT_ERROR is None:
int(r["index"]): r["operation_type"] for r in ctx.rows
}
self.op_by_index: dict[int, str] = dict(self.initial_op_by_index)
self._filter = "all"
def compose(self) -> ComposeResult:
plan_s = str(self.ctx.plan_input)
@@ -163,12 +193,13 @@ if TEXTUAL_IMPORT_ERROR is None:
f" / {self.ctx.counters['total_operations']}"
)
yield Static(hdr, id="header_line")
yield Static("", id="filter_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 退出",
"1-5 筛选 · ↑↓ j/k · a 保留 · r 驳回 · g 驳回重复组 · u 撤销 · s 保存 · q 退出",
id="footer_line",
)
yield Footer()
@@ -180,6 +211,12 @@ if TEXTUAL_IMPORT_ERROR is None:
background: $primary-darken-2;
color: $text;
}
#filter_line {
dock: top;
padding: 0 1;
background: $panel;
color: $text-muted;
}
#footer_line {
dock: bottom;
padding: 0 1;
@@ -214,35 +251,58 @@ if TEXTUAL_IMPORT_ERROR is None:
}
"""
def on_mount(self) -> None:
table = self.query_one("#review_table", DataTable)
table.cursor_type = "row"
def _filtered_rows(self) -> list[dict[str, str]]:
return [
r
for r in self.ctx.rows
if _row_matches_filter(r, self._filter)
]
def _update_filter_line(self) -> None:
labels = {
"all": "全部",
"manual_review": "需人工",
"sample_source": "样片",
"conflict": "冲突",
"duplicate": "重复",
}
visible = len(self._filtered_rows())
self.query_one("#filter_line", Static).update(
f"筛选: {labels.get(self._filter, self._filter)} · 显示 {visible}/{len(self.ctx.rows)}"
)
def _rebuild_table(self) -> None:
table = self._table()
table.clear(columns=True)
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")
table.add_column("类型", key="op", width=10)
table.add_column("风险", key="risk", width=14)
table.add_column("变更", key="change")
for r in self.ctx.rows:
for r in self._filtered_rows():
idx = int(r["index"])
sym = self._symbol_for(idx)
table.add_row(
sym,
self._symbol_for(idx),
str(idx),
self.op_by_index[idx],
risk_flags_to_labels(r["risk_flags"]),
Path(r["source_path"]).name,
_change_label(r),
key=str(idx),
)
self._apply_body_layout(self.app.size)
self._update_filter_line()
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 行为一致)"
"当前筛选无条目。按 1 显示全部,或 s 保存 CSV"
)
def on_mount(self) -> None:
self._rebuild_table()
self._apply_body_layout(self.app.size)
def _table(self) -> DataTable:
return self.query_one("#review_table", DataTable)
@@ -278,6 +338,33 @@ if TEXTUAL_IMPORT_ERROR is None:
idx = _index_from_row_key(event.row_key)
self._refresh_detail(idx)
def _format_quality_block(self, index: int) -> str:
r = self._by_index[index]
gid = r.get("duplicate_group_id", "")
if not gid:
return ""
lines = [f"重复组: {gid}"]
group_paths = [
other
for other in self.ctx.rows
if other.get("duplicate_group_id") == gid
]
for other in group_paths:
oidx = int(other["index"])
path = other.get("source_path", "")
qc = self.ctx.path_to_quality.get(path, {})
if qc:
res = qc.get("resolution", "?")
size = format_size(int(qc.get("size_bytes", 0) or 0))
mark = "" if self.op_by_index.get(oidx) != "no-op" else " "
lines.append(f" {mark} [{oidx}] {Path(path).name}: {res} {size}")
else:
hint = other.get("quality_hint", "")
mark = "" if self.op_by_index.get(oidx) != "no-op" else " "
lines.append(f" {mark} [{oidx}] {Path(path).name}: {hint or '(no metadata)'}")
return "\n".join(lines)
def _refresh_detail(self, index: int) -> None:
r = self._by_index[index]
op = self.op_by_index[index]
@@ -287,23 +374,29 @@ if TEXTUAL_IMPORT_ERROR is None:
self.ctx.library_root,
)
risk_cn = risk_flags_to_labels(r["risk_flags"], max_len=120)
title = r.get("title", "")
summary = (
f"#{index} · {op} · {Path(r['source_path']).name}"
f"#{index} · {op} · {_change_label(r)}"
+ (f" · {title}" if title else "")
+ (f" · {risk_cn}" if risk_cn else "")
)
quality_block = self._format_quality_block(index)
text = (
f"{summary}\n\n"
f"变更\n{paths}\n\n"
f"依据\n{r['reason']}\n\n"
f"标记\n{r['risk_flags']}"
)
if quality_block:
text += f"\n\n画质对比\n{quality_block}"
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)
if key not in [str(_index_from_row_key(r.key)) for r in table.ordered_rows]:
return
table.update_cell(key, "sym", self._symbol_for(index))
table.update_cell(key, "op", self.op_by_index[index])
def _update_dirty_header(self) -> None:
@@ -347,9 +440,43 @@ if TEXTUAL_IMPORT_ERROR is None:
self._refresh_detail(idx)
self._update_dirty_header()
def action_reject_group(self) -> None:
idx = self._current_index()
if idx is None:
return
gid = self._by_index[idx].get("duplicate_group_id", "")
if not gid:
self.action_reject_row()
return
for r in self.ctx.rows:
if r.get("duplicate_group_id") == gid:
self.op_by_index[int(r["index"])] = "no-op"
self._rebuild_table()
self._update_dirty_header()
def action_undo_row(self) -> None:
self.action_keep_row()
def action_filter_all(self) -> None:
self._filter = "all"
self._rebuild_table()
def action_filter_manual(self) -> None:
self._filter = "manual_review"
self._rebuild_table()
def action_filter_sample(self) -> None:
self._filter = "sample_source"
self._rebuild_table()
def action_filter_conflict(self) -> None:
self._filter = "conflict"
self._rebuild_table()
def action_filter_duplicate(self) -> None:
self._filter = "duplicate"
self._rebuild_table()
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)
@@ -402,3 +529,4 @@ 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