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

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

29 

30if TYPE_CHECKING: 

31 from lilbee.wiki.drafts import DraftInfo 

32 

33log = logging.getLogger(__name__) 

34 

35 

36def _wiki_root() -> Path: 

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

38 return cfg.data_root / cfg.wiki_dir 

39 

40 

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

44 

45 

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

49 

50 

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 

54 

55 

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

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

58 

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 

63 

64 

65class WikiDraftsScreen(Screen[None]): 

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

67 

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

71 

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 ] 

83 

84 def __init__(self) -> None: 

85 super().__init__() 

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

87 self._filter: str = "" 

88 

89 def compose(self) -> ComposeResult: 

90 from textual.widgets import Footer 

91 

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 

95 

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

121 

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

132 

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 

144 

145 visible = self._visible_drafts() 

146 if not visible: 

147 self._show_diff(msg.WIKI_DRAFTS_EMPTY) 

148 return 

149 

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) 

160 

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

167 

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) 

171 

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) 

184 

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

192 

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) 

205 

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

211 

212 def action_focus_search(self) -> None: 

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

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

215 

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

223 

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

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