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

1"""Wiki drafts review screen: browse, diff, accept, or reject pending drafts. 

2 

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""" 

10 

11from __future__ import annotations 

12 

13import logging 

14from pathlib import Path 

15from typing import TYPE_CHECKING, ClassVar 

16 

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 

23 

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 

30 

31if TYPE_CHECKING: 

32 from lilbee.wiki.drafts import DraftInfo 

33 

34log = logging.getLogger(__name__) 

35 

36 

37def _wiki_root() -> Path: 

38 """Resolve the wiki root directory from config.""" 

39 return cfg.data_root / cfg.wiki_dir 

40 

41 

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 "-" 

45 

46 

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 "-" 

50 

51 

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 

55 

56 

57def _kind_label(pending_kind: str | None) -> str: 

58 """Map a pending_kind value to its display label. 

59 

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 

64 

65 

66class WikiDraftsScreen(Screen[None]): 

67 """Review-surface screen for pending wiki drafts.""" 

68 

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." 

72 

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 ] 

84 

85 def __init__(self) -> None: 

86 super().__init__() 

87 self._drafts: list[DraftInfo] = [] 

88 self._filter: str = "" 

89 

90 def compose(self) -> ComposeResult: 

91 from textual.widgets import Footer 

92 

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 

96 

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() 

122 

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() 

133 

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 

145 

146 visible = self._visible_drafts() 

147 if not visible: 

148 self._show_diff(msg.WIKI_DRAFTS_EMPTY) 

149 return 

150 

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) 

161 

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()] 

168 

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) 

172 

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) 

185 

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)) 

193 

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) 

206 

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() 

212 

213 def action_focus_search(self) -> None: 

214 """Focus the search input (``/`` keybinding).""" 

215 self.query_one("#wiki-drafts-search", Input).focus() 

216 

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() 

224 

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() 

228 

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) 

234 

235 def action_cursor_down(self) -> None: 

236 table = self._table_or_none() 

237 if table is not None: 

238 table.action_cursor_down() 

239 

240 def action_cursor_up(self) -> None: 

241 table = self._table_or_none() 

242 if table is not None: 

243 table.action_cursor_up() 

244 

245 def action_jump_top(self) -> None: 

246 table = self._table_or_none() 

247 if table is not None: 

248 table.scroll_home() 

249 

250 def action_jump_bottom(self) -> None: 

251 table = self._table_or_none() 

252 if table is not None: 

253 table.scroll_end() 

254 

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 

261 

262 def _on_confirm(confirmed: bool | None) -> None: 

263 if not confirmed: 

264 return 

265 self._do_accept(slug) 

266 

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 ) 

274 

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() 

288 

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 

295 

296 def _on_confirm(confirmed: bool | None) -> None: 

297 if not confirmed: 

298 return 

299 self._do_reject(slug) 

300 

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 ) 

308 

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()