Coverage for src / lilbee / cli / tui / screens / wiki_drafts.py: 100%
183 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
1"""Wiki drafts review screen: browse, diff, accept, or reject pending drafts.
3The screen pairs a left-hand :class:`DataTable` of drafts with a
4right-hand scrollable :class:`Static` that renders the unified diff of
5the highlighted draft against its published counterpart. Accept and
6reject are confirmed through the shared :class:`ConfirmDialog` modal.
7Keybindings follow the rest of the TUI: vim j/k to navigate, ``/`` to
8search, ``a`` / ``r`` for accept / reject, ``q`` / Esc to back out.
9"""
11from __future__ import annotations
13import logging
14from pathlib import Path
15from typing import TYPE_CHECKING, ClassVar
17from textual import on
18from textual.app import ComposeResult
19from textual.binding import Binding, BindingType
20from textual.containers import Horizontal, Vertical, VerticalScroll
21from textual.screen import Screen
22from textual.widgets import DataTable, Input, Static
24from lilbee.cli.tui import messages as msg
25from lilbee.cli.tui.widgets.nav_aware_input import NavAwareInput
26from lilbee.cli.tui.widgets.task_bar import TaskBar
27from lilbee.config import cfg
28from lilbee.services import get_services
29from lilbee.wiki.drafts import accept_draft, diff_draft, list_drafts, reject_draft
31if TYPE_CHECKING:
32 from lilbee.wiki.drafts import DraftInfo
34log = logging.getLogger(__name__)
37def _wiki_root() -> Path:
38 """Resolve the wiki root directory from config."""
39 return cfg.data_root / cfg.wiki_dir
42def _format_drift(drift: float | None) -> str:
43 """Render a drift ratio as a percentage, or ``-`` when absent."""
44 return f"{drift:.0%}" if drift is not None else "-"
47def _format_faithfulness(score: float | None) -> str:
48 """Render a faithfulness score with two decimals, or ``-`` when absent."""
49 return f"{score:.2f}" if score is not None else "-"
52def _format_published(exists: bool) -> str:
53 """Render the published-counterpart flag as a human yes/no."""
54 return msg.WIKI_DRAFTS_PUBLISHED_YES if exists else msg.WIKI_DRAFTS_PUBLISHED_NO
57def _kind_label(pending_kind: str | None) -> str:
58 """Map a pending_kind value to its display label.
60 ``None`` surfaces as "drift" because drift is the default review
61 reason when no PENDING marker is present.
62 """
63 return pending_kind or msg.WIKI_DRAFTS_KIND_DRIFT
66class WikiDraftsScreen(Screen[None]):
67 """Review-surface screen for pending wiki drafts."""
69 CSS_PATH = "wiki_drafts.tcss"
70 AUTO_FOCUS = "#wiki-drafts-table"
71 HELP = "Review pending wiki drafts. j/k navigate, a accept, r reject, / search, q back."
73 BINDINGS: ClassVar[list[BindingType]] = [
74 Binding("q", "go_back", "Back", show=True),
75 Binding("escape", "dismiss_or_back", "Back", show=False),
76 Binding("a", "accept", "Accept", show=True),
77 Binding("r", "reject", "Reject", show=True),
78 Binding("slash", "focus_search", "Search", show=True),
79 Binding("j", "cursor_down", "Nav", show=False),
80 Binding("k", "cursor_up", "Nav", show=False),
81 Binding("g", "jump_top", "Top", show=False),
82 Binding("G", "jump_bottom", "End", show=False),
83 ]
85 def __init__(self) -> None:
86 super().__init__()
87 self._drafts: list[DraftInfo] = []
88 self._filter: str = ""
90 def compose(self) -> ComposeResult:
91 from textual.widgets import Footer
93 from lilbee.cli.tui.widgets.bottom_bars import BottomBars
94 from lilbee.cli.tui.widgets.status_bar import ViewTabs
95 from lilbee.cli.tui.widgets.top_bars import TopBars
97 with TopBars():
98 yield ViewTabs()
99 table: DataTable[str] = DataTable(id="wiki-drafts-table")
100 table.cursor_type = "row"
101 yield Horizontal(
102 Vertical(
103 NavAwareInput(
104 placeholder=msg.WIKI_DRAFTS_SEARCH_PLACEHOLDER,
105 id="wiki-drafts-search",
106 ),
107 table,
108 id="wiki-drafts-sidebar",
109 ),
110 Vertical(
111 VerticalScroll(
112 Static(msg.WIKI_DRAFTS_DIFF_EMPTY, id="wiki-drafts-diff"),
113 id="wiki-drafts-diff-scroll",
114 ),
115 id="wiki-drafts-main",
116 ),
117 id="wiki-drafts-layout",
118 )
119 with BottomBars():
120 yield TaskBar()
121 yield Footer()
123 def on_mount(self) -> None:
124 table = self.query_one("#wiki-drafts-table", DataTable)
125 table.add_columns(
126 msg.WIKI_DRAFTS_COLUMN_SLUG,
127 msg.WIKI_DRAFTS_COLUMN_KIND,
128 msg.WIKI_DRAFTS_COLUMN_DRIFT,
129 msg.WIKI_DRAFTS_COLUMN_FAITHFULNESS,
130 msg.WIKI_DRAFTS_COLUMN_PUBLISHED,
131 )
132 self._load_drafts()
134 def _load_drafts(self) -> None:
135 """Fetch drafts from disk and populate the table."""
136 table = self.query_one("#wiki-drafts-table", DataTable)
137 table.clear()
138 try:
139 self._drafts = list_drafts(_wiki_root())
140 except Exception as exc:
141 log.debug("Failed to list wiki drafts", exc_info=True)
142 self._drafts = []
143 self._show_diff(msg.WIKI_DRAFTS_LOAD_FAILED.format(error=exc))
144 return
146 visible = self._visible_drafts()
147 if not visible:
148 self._show_diff(msg.WIKI_DRAFTS_EMPTY)
149 return
151 for d in visible:
152 table.add_row(
153 d.slug,
154 _kind_label(d.pending_kind),
155 _format_drift(d.drift_ratio),
156 _format_faithfulness(d.faithfulness_score),
157 _format_published(d.published_exists),
158 key=d.slug,
159 )
160 self._show_diff(msg.WIKI_DRAFTS_DIFF_EMPTY)
162 def _visible_drafts(self) -> list[DraftInfo]:
163 """Apply the current filter to the loaded draft list."""
164 if not self._filter:
165 return self._drafts
166 needle = self._filter.lower()
167 return [d for d in self._drafts if needle in d.slug.lower()]
169 def _show_diff(self, text: str) -> None:
170 """Update the diff pane with *text*."""
171 self.query_one("#wiki-drafts-diff", Static).update(text)
173 def _highlighted_slug(self) -> str | None:
174 """Return the slug of the highlighted row, or ``None`` when empty."""
175 table = self.query_one("#wiki-drafts-table", DataTable)
176 if table.row_count == 0:
177 return None
178 try:
179 row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
180 except Exception:
181 return None
182 if row_key is None or row_key.value is None:
183 return None
184 return str(row_key.value)
186 @on(DataTable.RowHighlighted, "#wiki-drafts-table")
187 def _on_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
188 """Load the diff for the newly highlighted row."""
189 key = event.row_key.value if event.row_key is not None else None
190 if key is None:
191 return
192 self._display_diff(str(key))
194 def _display_diff(self, slug: str) -> None:
195 """Compute and render the unified diff for *slug*."""
196 try:
197 diff = diff_draft(slug, _wiki_root())
198 except FileNotFoundError:
199 self._show_diff(msg.WIKI_DRAFTS_DIFF_EMPTY)
200 return
201 except Exception as exc:
202 log.debug("Failed to compute diff for %s", slug, exc_info=True)
203 self._show_diff(msg.WIKI_DRAFTS_DIFF_FAILED.format(error=exc))
204 return
205 self._show_diff(diff or msg.WIKI_DRAFTS_DIFF_NONE)
207 @on(Input.Changed, "#wiki-drafts-search")
208 def _on_search_changed(self, event: Input.Changed) -> None:
209 """Filter drafts as the user types."""
210 self._filter = event.value.strip()
211 self._load_drafts()
213 def action_focus_search(self) -> None:
214 """Focus the search input (``/`` keybinding)."""
215 self.query_one("#wiki-drafts-search", Input).focus()
217 def action_dismiss_or_back(self) -> None:
218 """Clear the search if active, otherwise back out to the wiki screen."""
219 search = self.query_one("#wiki-drafts-search", Input)
220 if search.value:
221 search.value = ""
222 return
223 self.action_go_back()
225 def action_go_back(self) -> None:
226 """Pop back to the wiki screen (or the previous screen in tests)."""
227 self.app.pop_screen()
229 def _table_or_none(self) -> DataTable[str] | None:
230 """Return the drafts table unless an Input is focused."""
231 if isinstance(self.focused, Input):
232 return None
233 return self.query_one("#wiki-drafts-table", DataTable)
235 def action_cursor_down(self) -> None:
236 table = self._table_or_none()
237 if table is not None:
238 table.action_cursor_down()
240 def action_cursor_up(self) -> None:
241 table = self._table_or_none()
242 if table is not None:
243 table.action_cursor_up()
245 def action_jump_top(self) -> None:
246 table = self._table_or_none()
247 if table is not None:
248 table.scroll_home()
250 def action_jump_bottom(self) -> None:
251 table = self._table_or_none()
252 if table is not None:
253 table.scroll_end()
255 def action_accept(self) -> None:
256 """Prompt for confirmation, then accept the highlighted draft."""
257 slug = self._highlighted_slug()
258 if slug is None:
259 return
260 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
262 def _on_confirm(confirmed: bool | None) -> None:
263 if not confirmed:
264 return
265 self._do_accept(slug)
267 self.app.push_screen(
268 ConfirmDialog(
269 msg.WIKI_DRAFTS_ACCEPT_CONFIRM_TITLE,
270 msg.WIKI_DRAFTS_ACCEPT_CONFIRM_MESSAGE.format(slug=slug),
271 ),
272 _on_confirm,
273 )
275 def _do_accept(self, slug: str) -> None:
276 """Execute the accept call and refresh the list."""
277 try:
278 accept_draft(slug, _wiki_root(), get_services().store)
279 except FileNotFoundError:
280 self.notify(msg.WIKI_DRAFTS_ACCEPT_FAILED.format(error=f"missing: {slug}"))
281 return
282 except Exception as exc:
283 log.debug("Accept failed for %s", slug, exc_info=True)
284 self.notify(msg.WIKI_DRAFTS_ACCEPT_FAILED.format(error=exc))
285 return
286 self.notify(msg.WIKI_DRAFTS_ACCEPTED.format(slug=slug))
287 self._load_drafts()
289 def action_reject(self) -> None:
290 """Prompt for confirmation, then reject the highlighted draft."""
291 slug = self._highlighted_slug()
292 if slug is None:
293 return
294 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
296 def _on_confirm(confirmed: bool | None) -> None:
297 if not confirmed:
298 return
299 self._do_reject(slug)
301 self.app.push_screen(
302 ConfirmDialog(
303 msg.WIKI_DRAFTS_REJECT_CONFIRM_TITLE,
304 msg.WIKI_DRAFTS_REJECT_CONFIRM_MESSAGE.format(slug=slug),
305 ),
306 _on_confirm,
307 )
309 def _do_reject(self, slug: str) -> None:
310 """Execute the reject call and refresh the list."""
311 try:
312 reject_draft(slug, _wiki_root())
313 except FileNotFoundError:
314 self.notify(msg.WIKI_DRAFTS_REJECT_FAILED.format(error=f"missing: {slug}"))
315 return
316 except Exception as exc:
317 log.debug("Reject failed for %s", slug, exc_info=True)
318 self.notify(msg.WIKI_DRAFTS_REJECT_FAILED.format(error=exc))
319 return
320 self.notify(msg.WIKI_DRAFTS_REJECTED.format(slug=slug))
321 self._load_drafts()