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:
+159
-31
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user