Coverage for src / lilbee / cli / tui / screens / wiki_drafts.py: 100%
183 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +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.app.services import get_services
25from lilbee.cli.tui import messages as msg
26from lilbee.cli.tui.widgets.task_bar import TaskBar
27from lilbee.core.config import cfg
28from lilbee.wiki.drafts import accept_draft, diff_draft, list_drafts, reject_draft
30if TYPE_CHECKING:
31 from lilbee.wiki.drafts import DraftInfo
33log = logging.getLogger(__name__)
36def _wiki_root() -> Path:
37 """Resolve the wiki root directory from config."""
38 return cfg.data_root / cfg.wiki_dir
41def _format_drift(drift: float | None) -> str:
42 """Render a drift ratio as a percentage, or ``-`` when absent."""
43 return f"{drift:.0%}" if drift is not None else "-"
46def _format_faithfulness(score: float | None) -> str:
47 """Render a faithfulness score with two decimals, or ``-`` when absent."""
48 return f"{score:.2f}" if score is not None else "-"
51def _format_published(exists: bool) -> str:
52 """Render the published-counterpart flag as a human yes/no."""
53 return msg.WIKI_DRAFTS_PUBLISHED_YES if exists else msg.WIKI_DRAFTS_PUBLISHED_NO
56def _kind_label(pending_kind: str | None) -> str:
57 """Map a pending_kind value to its display label.
59 ``None`` surfaces as "drift" because drift is the default review
60 reason when no PENDING marker is present.
61 """
62 return pending_kind or msg.WIKI_DRAFTS_KIND_DRIFT
65class WikiDraftsScreen(Screen[None]):
66 """Review-surface screen for pending wiki drafts."""
68 CSS_PATH = "wiki_drafts.tcss"
69 AUTO_FOCUS = "#wiki-drafts-table"
70 HELP = "Review pending wiki drafts. j/k navigate, a accept, r reject, / search, q back."
72 BINDINGS: ClassVar[list[BindingType]] = [
73 Binding("q", "go_back", "Back", show=True),
74 Binding("escape", "dismiss_or_back", "Back", show=False),
75 Binding("a", "accept", "Accept", show=True),
76 Binding("r", "reject", "Reject", show=True),
77 Binding("slash", "focus_search", "Search", show=True),
78 Binding("j", "cursor_down", "Nav", show=False),
79 Binding("k", "cursor_up", "Nav", show=False),
80 Binding("g", "jump_top", "Top", show=False),
81 Binding("G", "jump_bottom", "End", show=False),
82 ]
84 def __init__(self) -> None:
85 super().__init__()
86 self._drafts: list[DraftInfo] = []
87 self._filter: str = ""
89 def compose(self) -> ComposeResult:
90 from textual.widgets import Footer
92 from lilbee.cli.tui.widgets.bottom_bars import BottomBars
93 from lilbee.cli.tui.widgets.status_bar import ViewTabs
94 from lilbee.cli.tui.widgets.top_bars import TopBars
96 with TopBars():
97 yield ViewTabs()
98 table: DataTable[str] = DataTable(id="wiki-drafts-table")
99 table.cursor_type = "row"
100 yield Horizontal(
101 Vertical(
102 Input(
103 placeholder=msg.WIKI_DRAFTS_SEARCH_PLACEHOLDER,
104 id="wiki-drafts-search",
105 ),
106 table,
107 id="wiki-drafts-sidebar",
108 ),
109 Vertical(
110 VerticalScroll(
111 Static(msg.WIKI_DRAFTS_DIFF_EMPTY, id="wiki-drafts-diff"),
112 id="wiki-drafts-diff-scroll",
113 ),
114 id="wiki-drafts-main",
115 ),
116 id="wiki-drafts-layout",
117 )
118 with BottomBars():
119 yield TaskBar()
120 yield Footer()
122 def on_mount(self) -> None:
123 table = self.query_one("#wiki-drafts-table", DataTable)
124 table.add_columns(
125 msg.WIKI_DRAFTS_COLUMN_SLUG,
126 msg.WIKI_DRAFTS_COLUMN_KIND,
127 msg.WIKI_DRAFTS_COLUMN_DRIFT,
128 msg.WIKI_DRAFTS_COLUMN_FAITHFULNESS,
129 msg.WIKI_DRAFTS_COLUMN_PUBLISHED,
130 )
131 self._load_drafts()
133 def _load_drafts(self) -> None:
134 """Fetch drafts from disk and populate the table."""
135 table = self.query_one("#wiki-drafts-table", DataTable)
136 table.clear()
137 try:
138 self._drafts = list_drafts(_wiki_root())
139 except Exception as exc:
140 log.debug("Failed to list wiki drafts", exc_info=True)
141 self._drafts = []
142 self._show_diff(msg.WIKI_DRAFTS_LOAD_FAILED.format(error=exc))
143 return
145 visible = self._visible_drafts()
146 if not visible:
147 self._show_diff(msg.WIKI_DRAFTS_EMPTY)
148 return
150 for d in visible:
151 table.add_row(
152 d.slug,
153 _kind_label(d.pending_kind),
154 _format_drift(d.drift_ratio),
155 _format_faithfulness(d.faithfulness_score),
156 _format_published(d.published_exists),
157 key=d.slug,
158 )
159 self._show_diff(msg.WIKI_DRAFTS_DIFF_EMPTY)
161 def _visible_drafts(self) -> list[DraftInfo]:
162 """Apply the current filter to the loaded draft list."""
163 if not self._filter:
164 return self._drafts
165 needle = self._filter.lower()
166 return [d for d in self._drafts if needle in d.slug.lower()]
168 def _show_diff(self, text: str) -> None:
169 """Update the diff pane with *text*."""
170 self.query_one("#wiki-drafts-diff", Static).update(text)
172 def _highlighted_slug(self) -> str | None:
173 """Return the slug of the highlighted row, or ``None`` when empty."""
174 table = self.query_one("#wiki-drafts-table", DataTable)
175 if table.row_count == 0:
176 return None
177 try:
178 row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
179 except Exception:
180 return None
181 if row_key is None or row_key.value is None:
182 return None
183 return str(row_key.value)
185 @on(DataTable.RowHighlighted, "#wiki-drafts-table")
186 def _on_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
187 """Load the diff for the newly highlighted row."""
188 key = event.row_key.value if event.row_key is not None else None
189 if key is None:
190 return
191 self._display_diff(str(key))
193 def _display_diff(self, slug: str) -> None:
194 """Compute and render the unified diff for *slug*."""
195 try:
196 diff = diff_draft(slug, _wiki_root())
197 except FileNotFoundError:
198 self._show_diff(msg.WIKI_DRAFTS_DIFF_EMPTY)
199 return
200 except Exception as exc:
201 log.debug("Failed to compute diff for %s", slug, exc_info=True)
202 self._show_diff(msg.WIKI_DRAFTS_DIFF_FAILED.format(error=exc))
203 return
204 self._show_diff(diff or msg.WIKI_DRAFTS_DIFF_NONE)
206 @on(Input.Changed, "#wiki-drafts-search")
207 def _on_search_changed(self, event: Input.Changed) -> None:
208 """Filter drafts as the user types."""
209 self._filter = event.value.strip()
210 self._load_drafts()
212 def action_focus_search(self) -> None:
213 """Focus the search input (``/`` keybinding)."""
214 self.query_one("#wiki-drafts-search", Input).focus()
216 def action_dismiss_or_back(self) -> None:
217 """Clear the search if active, otherwise back out to the wiki screen."""
218 search = self.query_one("#wiki-drafts-search", Input)
219 if search.value:
220 search.value = ""
221 return
222 self.action_go_back()
224 def action_go_back(self) -> None:
225 """Pop back to the wiki screen, unless this is the only screen on the stack."""
226 if len(self.app.screen_stack) > 1:
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()