Coverage for vibefpmgui/review_monitor.py: 96%

732 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""The *Review* tab: a timeline of everything the AI does (reviews, 

2*Explain with AI*, questions and edits from the box at the bottom), newest 

3at the bottom. Nothing replaces anything: each action adds an entry, older 

4ones fold to a one-line header (click to open), and everything in them 

5stays usable: findings jump to their line or get *Fix with AI…*, a review's 

6markers go back in the editor, edits can still be accepted. The timeline 

7lasts until the app quits (or *Clear*); *New Chat* draws a line: the model 

8forgets what's above it. 

9 

10The panel only shows `Entry` objects and says what the user did; 

11`vibefpmgui.window.ai` and `vibefpmgui.window.ai_chat` run the model and change files. 

12""" 

13 

14from __future__ import annotations 

15 

16import html 

17import re 

18import time 

19from dataclasses import dataclass, field 

20from pathlib import Path 

21from typing import Any 

22 

23from pygments.lexers import get_lexer_by_name 

24from pygments.styles import get_style_by_name 

25from pygments.util import ClassNotFound 

26from qtpy.QtCore import QEvent, Qt, QTimer, Signal 

27from qtpy.QtGui import ( 

28 QColor, 

29 QFont, 

30 QTextBlockFormat, 

31 QTextCharFormat, 

32 QTextCursor, 

33 QTextFormat, 

34) 

35from qtpy.QtWidgets import ( 

36 QApplication, 

37 QButtonGroup, 

38 QCheckBox, 

39 QFrame, 

40 QHBoxLayout, 

41 QLabel, 

42 QLineEdit, 

43 QPushButton, 

44 QScrollArea, 

45 QSizePolicy, 

46 QTextBrowser, 

47 QToolButton, 

48 QVBoxLayout, 

49 QWidget, 

50) 

51 

52from . import ai_edits as ed 

53from . import highlighter as hl 

54from .ai_edits import END_RE as EDIT_END_RE 

55from .ai_edits import START_RE as EDIT_START_RE 

56from .ai_review import Finding 

57from .build_monitor import ( 

58 PALETTES, 

59 ProgressLine, 

60 TitleIcon, 

61 app_is_light, 

62 panel_stylesheet, 

63) 

64from .edit_cards import EditList 

65from .editor import monospace_font 

66from .widgets import StatusLabel 

67 

68FINDING_LINE_RE = re.compile(r"\s*\**\s*LINES?\s*\d", re.IGNORECASE) # (a finding in a chat answer) 

69 

70CATEGORY_COLORS = {"bug": "#e03131", "warning": "#f08c00", "performance": "#0c8599", 

71 "style": "#7048e8", "suggestion": "#1c7ed6"} 

72CATEGORY_ICONS = {"bug": "✕", "warning": "⚠", "performance": "⚡", "style": "✎", "suggestion": "💡"} 

73KINDS = {"review": ("Review", "#7048e8"), "explain": ("Explain", "#0c8599"), "ask": ("Ask", "#1c7ed6"), 

74 "edit": ("Edit", "#2f9e44")} 

75FILTERS = {"all": "All", "review": "Reviews", "explain": "Explanations", "chat": "Chat", 

76 "pending": "Pending edits"} 

77RUNNING, DONE, FAILED = "running", "done", "failed" 

78 

79 

80def _esc(text: str) -> str: 

81 """Text for a label (quotes as they are: it's not an attribute).""" 

82 return html.escape(text, quote=False) 

83 

84 

85@dataclass 

86class Entry: 

87 """One AI action: a review, an explanation, a question (Ask) or a 

88 change (Edit); or a divider (New Chat: the model forgets what's above).""" 

89 kind: str # "review", "explain", "ask", "edit", "divider" 

90 question: str = "" # the question; the problem explained; a review's focus 

91 path: Path | None = None # the file it's about 

92 where: str = "" # shown in the header: "foo.f90", "foo.f90:12" 

93 model: str = "" 

94 answer: str = "" # as the model wrote it (Markdown) 

95 reasoning: str = "" # a thinking model's, before its answer (AI → Think Before Answering) 

96 findings: list[Finding] = field(default_factory=list) 

97 summary: str = "" # a review's 

98 edits: list[ed.Edit] = field(default_factory=list) 

99 status: str = RUNNING 

100 note: str = "" # "4.1 s · model", or what went wrong 

101 started: str = field(default_factory=lambda: time.strftime("%H:%M")) 

102 turn: Any = None # what the model was asked (an ai_context.Turn), for later questions 

103 

104 def pending_edits(self) -> int: 

105 return sum(e.status == ed.PENDING and e.located for e in self.edits) 

106 

107 def markdown(self) -> str: 

108 """The entry as text (Copy).""" 

109 if self.kind == "review": 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true

110 lines = [f"**Review of {self.where}** ({self.question})", ""] 

111 lines += [f"- LINE {f.line}{'' if f.end_line == f.line else f'-{f.end_line}'}: {f.category}: " 

112 f"{f.comment}" for f in self.findings] 

113 return "\n".join(lines + ([f"\n**Summary:** {self.summary}"] if self.summary else [])) 

114 if self.kind == "explain": 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true

115 return f"**{self.where}:** {self.question}\n\n{self.answer}" 

116 return f"**You:** {self.question}\n\n{self.answer}" 

117 

118 

119def answer_display(answer: str) -> str: 

120 """A chat answer as shown: its "LINE n: …" lines each on their own 

121 (Markdown would run them together), as a list; its edit blocks as a 

122 pointer to their card.""" 

123 shown: list[str] = [] 

124 fenced = in_block = drop_fence = False 

125 n = 0 

126 for line in answer.split("\n"): 

127 fence = line.lstrip().startswith("```") 

128 if in_block: 

129 if EDIT_END_RE.match(line): 

130 in_block = False 

131 shown[-1] = f"*✎ Edit {n}: see its card below.*" 

132 continue 

133 if EDIT_START_RE.match(line): 

134 in_block, n = True, n + 1 

135 if fenced and shown and shown[-1].lstrip().startswith("```"): # (a fence just around it) 

136 shown.pop() 

137 fenced, drop_fence = False, True 

138 shown.append(f"*✎ Writing edit {n}…*") 

139 continue 

140 if drop_fence and fence: # (that fence's end) 

141 drop_fence = False 

142 continue 

143 if line.strip(): 

144 drop_fence = False 

145 if fence: 

146 fenced = not fenced 

147 elif not fenced and FINDING_LINE_RE.match(line): 

148 line = "- " + line.strip() 

149 shown.append(line) 

150 return "\n".join(shown) 

151 

152 

153def style_code(document, family: str, style_name: str | None = None): 

154 """Code in a Markdown document in a fixed-width font (Qt's Markdown asks 

155 only for "monospace", which macOS turns into a serif font, or ignores), 

156 and code blocks labeled with a language (```fortran, ```toml…) 

157 highlighted as the editor would, in its colors (``style_name``: a 

158 Pygments style; by default the one the editor uses).""" 

159 fixed = QTextCharFormat() 

160 fixed.setFontFamilies([family]) 

161 cursor = QTextCursor(document) 

162 block = document.begin() 

163 while block.isValid(): 

164 if block.blockFormat().hasProperty(QTextFormat.BlockCodeFence): 

165 language = str(block.blockFormat().property(QTextFormat.BlockCodeLanguage) or "") 

166 lines = [] 

167 while block.isValid() and block.blockFormat().hasProperty(QTextFormat.BlockCodeFence) \ 

168 and str(block.blockFormat().property(QTextFormat.BlockCodeLanguage) or "") == language: 

169 lines.append(block) 

170 block = block.next() 

171 cursor.setPosition(lines[0].position()) 

172 cursor.setPosition(lines[-1].position() + lines[-1].length() - 1, QTextCursor.KeepAnchor) 

173 cursor.mergeCharFormat(fixed) 

174 _highlight(cursor, lines, language, style_name) 

175 continue 

176 it = block.begin() 

177 while not it.atEnd(): 

178 fragment = it.fragment() 

179 if fragment.charFormat().fontFixedPitch(): 

180 cursor.setPosition(fragment.position()) 

181 cursor.setPosition(fragment.position() + fragment.length(), QTextCursor.KeepAnchor) 

182 cursor.mergeCharFormat(fixed) 

183 it += 1 

184 block = block.next() 

185 

186 

187def _highlight(cursor: QTextCursor, lines: list, language: str, style_name: str | None): 

188 """One code block's lines, colored by the lexer for ``language``.""" 

189 if not language: 

190 return 

191 try: 

192 lexer = hl.keep_positions(get_lexer_by_name(language)) 

193 style = get_style_by_name(style_name or hl.style_name(QApplication.palette())) 

194 except ClassNotFound: 

195 return # (a language Pygments doesn't know: plain) 

196 background = QTextBlockFormat() 

197 background.setBackground(QColor(style.background_color)) 

198 plain = QTextCharFormat() 

199 plain.setForeground(hl.style_text_color(style)) 

200 first = lines[0].position() 

201 cursor.setPosition(first) 

202 cursor.setPosition(lines[-1].position() + lines[-1].length() - 1, QTextCursor.KeepAnchor) 

203 cursor.mergeBlockFormat(background) 

204 cursor.mergeCharFormat(plain) 

205 text = "\n".join(line.text() for line in lines) # (a line break is one position, as between blocks) 

206 for index, ttype, value in lexer.get_tokens_unprocessed(text): 

207 if value and not value.isspace(): 

208 cursor.setPosition(first + index) 

209 cursor.setPosition(first + index + len(value), QTextCursor.KeepAnchor) 

210 cursor.mergeCharFormat(hl.token_format(style, ttype)) 

211 

212 

213class MarkdownView(QTextBrowser): 

214 """Markdown as tall as its text (the timeline scrolls, not this).""" 

215 

216 def __init__(self, parent=None): 

217 super().__init__(parent) 

218 self.setOpenExternalLinks(True) 

219 self.setFrameShape(QFrame.NoFrame) 

220 self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 

221 self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) 

222 self.document().documentLayout().documentSizeChanged.connect( 

223 lambda size: self.setFixedHeight(int(size.height()) + 6)) 

224 self._text = None 

225 

226 def set_markdown(self, text: str): 

227 if text != self._text: 

228 self._text = text 

229 self.setMarkdown(text) 

230 style_code(self.document(), monospace_font(editor=True).family()) 

231 

232 def restyle(self): 

233 """Again, in the editor's colors as they are now.""" 

234 text, self._text = self._text, None 

235 if text is not None: 

236 self.set_markdown(text) 

237 

238 

239class WrappedLabel(QLabel): 

240 """A word-wrapped label that remembers its size for its text: Qt asks a 

241 wrapped label its height again on every layout pass, laying out its text 

242 each time, and with the timeline's findings (hundreds of rows) and an 

243 answer streaming in, that kept the window busy.""" 

244 

245 def __init__(self, text: str = ""): 

246 super().__init__(text) 

247 self.setWordWrap(True) 

248 self._heights: dict[int, int] = {} 

249 self._hints: dict[str, object] = {} 

250 

251 def setText(self, text: str): 

252 if text != self.text(): 252 ↛ 254line 252 didn't jump to line 254 because the condition on line 252 was always true

253 self._forget() 

254 super().setText(text) 

255 

256 def _forget(self): 

257 self._heights.clear() 

258 self._hints.clear() 

259 

260 def changeEvent(self, event): 

261 if event.type() in (QEvent.FontChange, QEvent.StyleChange): 

262 self._forget() 

263 super().changeEvent(event) 

264 

265 def heightForWidth(self, width: int) -> int: 

266 height = self._heights.get(width) 

267 if height is None: 

268 height = self._heights[width] = super().heightForWidth(width) 

269 return height 

270 

271 def sizeHint(self): 

272 if "size" not in self._hints: 272 ↛ 274line 272 didn't jump to line 274 because the condition on line 272 was always true

273 self._hints["size"] = super().sizeHint() 

274 return self._hints["size"] 

275 

276 def minimumSizeHint(self): 

277 if "minimum" not in self._hints: 277 ↛ 279line 277 didn't jump to line 279 because the condition on line 277 was always true

278 self._hints["minimum"] = super().minimumSizeHint() 

279 return self._hints["minimum"] 

280 

281 

282class FindingRow(QWidget): 

283 """One finding: its line, category and comment; click to go to the 

284 line; Fix with AI…""" 

285 

286 def __init__(self, view: FindingsView): 

287 super().__init__() 

288 self.finding: Finding | None = None 

289 self.setObjectName("FindingRow") 

290 self.setCursor(Qt.PointingHandCursor) 

291 self.label = WrappedLabel() 

292 self.label.setToolTip("Go to the line") 

293 fix = QToolButton() 

294 fix.setText("Fix with AI…") 

295 fix.setToolTip("Ask the AI for an edit that addresses this finding") 

296 fix.clicked.connect(lambda: self.finding is not None and view.fix.emit(self.finding)) 

297 box = QHBoxLayout(self) 

298 box.setContentsMargins(6, 3, 6, 3) 

299 box.addWidget(self.label, 1) 

300 box.addWidget(fix, 0, Qt.AlignTop) 

301 self._view = view 

302 

303 def mousePressEvent(self, event): 

304 if event.button() == Qt.LeftButton and self.finding is not None: 304 ↛ 306line 304 didn't jump to line 306 because the condition on line 304 was always true

305 self._view.clicked.emit(self.finding) 

306 super().mousePressEvent(event) 

307 

308 

309class FindingsView(QWidget): 

310 """Findings, one row each: click to go to the line; Fix with AI… 

311 

312 The rows are kept and updated in place (only a row whose finding changed 

313 gets new text; new findings add rows at the end): rebuilding every row 

314 for each finding a review streamed in took ever longer, and with a few 

315 dozen findings the window crawled.""" 

316 

317 clicked = Signal(object) 

318 fix = Signal(object) 

319 

320 def __init__(self, parent=None): 

321 super().__init__(parent) 

322 self.layout_ = QVBoxLayout(self) 

323 self.layout_.setContentsMargins(0, 0, 0, 0) 

324 self.layout_.setSpacing(2) 

325 self.rows: list[FindingRow] = [] 

326 

327 def show_findings(self, findings: list[Finding], pal: dict, is_light: bool): 

328 family = monospace_font(editor=True).family() 

329 # Each row stays with its finding (by line and category): a finding 

330 # coming in between others gets a new row there, rather than every 

331 # row below taking the next one's text (and being measured again). 

332 spare: dict[tuple, list[FindingRow]] = {} 

333 for row in self.rows: 

334 spare.setdefault(self._key(row.finding), []).append(row) 

335 rows = [] 

336 for f in sorted(findings, key=lambda f: f.line): 

337 same = spare.get(self._key(f)) 

338 row = same.pop(0) if same else FindingRow(self) 

339 row.finding = f 

340 color = CATEGORY_COLORS.get(f.category, pal["muted"]) 

341 lines = str(f.line) if f.end_line == f.line else f"{f.line}–{f.end_line}" 

342 text = (f"<span style='font-family:\"{family}\"'>{lines}</span>" 

343 f"&nbsp;&nbsp;<b style='color:{color}'>{CATEGORY_ICONS.get(f.category, '')} " 

344 f"{_esc(f.category)}</b>&nbsp;&nbsp;{_esc(f.comment)}") 

345 if row.label.text() != text: # (only what changed is measured again) 

346 row.label.setText(text) 

347 rows.append(row) 

348 for left in spare.values(): 

349 for row in left: 

350 self.layout_.removeWidget(row) 

351 row.deleteLater() 

352 for index, row in enumerate(rows): # (new rows into their places; the others keep theirs) 

353 if self.layout_.indexOf(row) != index: 

354 self.layout_.insertWidget(index, row) 

355 self.rows = rows 

356 self.setVisible(bool(findings)) 

357 

358 @staticmethod 

359 def _key(f: Finding | None) -> tuple: 

360 return (f.line, f.end_line, f.category) if f is not None else () 

361 

362 

363class EntryWidget(QFrame): 

364 """One entry of the timeline: a header (click to fold or open it) and 

365 what it holds.""" 

366 

367 def __init__(self, entry: Entry, monitor: ReviewMonitor): 

368 super().__init__() 

369 self.entry, self.monitor = entry, monitor 

370 self.expanded = True 

371 self.setObjectName("Entry") 

372 self.header = QWidget() 

373 self.header.setCursor(Qt.PointingHandCursor) 

374 self.header.mousePressEvent = lambda event: self.set_expanded(not self.expanded) \ 

375 if event.button() == Qt.LeftButton else None 

376 self.kind = QLabel() 

377 self.title = QLabel() 

378 self.title.setTextFormat(Qt.RichText) 

379 self.title.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) 

380 self.when = QLabel() 

381 self.show_button = QToolButton() 

382 self.show_button.setText("Show in Editor") 

383 self.show_button.setToolTip("Mark these findings in the editor again") 

384 self.show_button.clicked.connect(lambda: monitor.show_in_editor_requested.emit(entry)) 

385 self.copy_button = QToolButton() 

386 self.copy_button.setText("Copy") 

387 self.copy_button.setToolTip("Copy this entry (Markdown)") 

388 self.copy_button.clicked.connect(lambda: QApplication.clipboard().setText(entry.markdown())) 

389 self.chevron = QLabel() 

390 top = QHBoxLayout(self.header) 

391 top.setContentsMargins(0, 0, 0, 0) 

392 top.setSpacing(8) 

393 top.addWidget(self.chevron) 

394 top.addWidget(self.kind) 

395 top.addWidget(self.title, 1) 

396 top.addWidget(self.show_button) 

397 top.addWidget(self.copy_button) 

398 top.addWidget(self.when) 

399 

400 self.body = QWidget() 

401 self.question = QLabel() 

402 self.question.setWordWrap(True) 

403 self.question.setTextInteractionFlags(Qt.TextSelectableByMouse) 

404 self.reasoning_button = QToolButton() 

405 self.reasoning_button.setCheckable(True) 

406 self.reasoning_button.setText("Show reasoning") 

407 self.reasoning_button.toggled.connect(self._show_reasoning) 

408 self.reasoning_button.setToolTip("What the model thought before it answered") 

409 self.reasoning = QLabel() 

410 self.reasoning.setWordWrap(True) 

411 self.reasoning.setTextInteractionFlags(Qt.TextSelectableByMouse) 

412 self.reasoning.setTextFormat(Qt.PlainText) 

413 self.reasoning.hide() 

414 self.answer = MarkdownView() 

415 self.findings = FindingsView() 

416 self.findings.clicked.connect(lambda f: monitor.finding_selected.emit(entry, f)) 

417 self.findings.fix.connect(lambda f: monitor.fix_requested.emit(entry, f)) 

418 self.summary = QLabel() 

419 self.summary.setWordWrap(True) 

420 self.summary.setTextInteractionFlags(Qt.TextSelectableByMouse) 

421 self.edit_list = EditList(scrolling=False) 

422 self.edit_list.accept_requested.connect(lambda i: monitor.accept_requested.emit(entry, i)) 

423 self.edit_list.reject_requested.connect(lambda i: monitor.reject_requested.emit(entry, i)) 

424 self.edit_list.accept_all_requested.connect(lambda: monitor.accept_all_requested.emit(entry)) 

425 self.edit_list.reject_all_requested.connect(lambda: monitor.reject_all_requested.emit(entry)) 

426 self.edit_list.edit_clicked.connect(lambda i: monitor.edit_clicked.emit(entry, i)) 

427 self.edit_list.hide() 

428 self.footer = QLabel() 

429 body = QVBoxLayout(self.body) 

430 body.setContentsMargins(22, 4, 0, 0) 

431 body.setSpacing(6) 

432 for widget in (self.question, self.reasoning_button, self.reasoning, self.answer, self.findings, 

433 self.summary, self.edit_list, self.footer): 

434 body.addWidget(widget) 

435 layout = QVBoxLayout(self) 

436 layout.setContentsMargins(10, 8, 10, 8) 

437 layout.setSpacing(4) 

438 layout.addWidget(self.header) 

439 layout.addWidget(self.body) 

440 self.refresh() 

441 

442 def _show_reasoning(self, on: bool): 

443 self.reasoning_button.setText("Hide reasoning" if on else "Show reasoning") 

444 self.reasoning.setText(self.entry.reasoning.strip()) 

445 self.reasoning.setVisible(on) 

446 

447 def set_expanded(self, on: bool): 

448 self.expanded = on 

449 self.refresh() 

450 

451 def refresh(self, file_text: str | None = None): 

452 """Show the entry as it is now (``file_text``: its file's text, for 

453 the edit cards' diffs).""" 

454 e, pal = self.entry, self.monitor.palette_colors 

455 muted = pal["muted"] 

456 label, color = KINDS.get(e.kind, (e.kind, muted)) 

457 self.kind.setText(f"<b style='color:{color}'>{label}</b>") 

458 self.chevron.setText("▾" if self.expanded else "▸") 

459 self.title.setText(self._title(muted)) 

460 self.when.setText(f"<span style='color:{muted}'>{e.started}</span>") 

461 self.show_button.setVisible(bool(e.findings) and e.path is not None) 

462 self.copy_button.setVisible(self.expanded and (bool(e.answer) or bool(e.findings))) 

463 self.body.setVisible(self.expanded) 

464 if not self.expanded: 

465 return 

466 if e.kind == "review": 

467 question = f"<span style='color:{muted}'>{_esc(e.question)}</span>" 

468 if e.status == RUNNING and not e.findings: 

469 question += f"<br><i style='color:{muted}'>Findings appear here as the model writes them.</i>" 

470 elif e.status == DONE and not e.findings: 470 ↛ 471line 470 didn't jump to line 471 because the condition on line 470 was never true

471 question += "<br><i>No specific findings.</i>" 

472 elif e.kind == "explain": 

473 question = f"<b>{_esc(e.question)}</b>" 

474 else: 

475 question = f"<b>You:</b> {_esc(e.question)}" 

476 self.question.setText(question) 

477 self.reasoning_button.setVisible(bool(e.reasoning)) 

478 if e.reasoning and self.reasoning.isVisibleTo(self.body): 478 ↛ 479line 478 didn't jump to line 479 because the condition on line 478 was never true

479 self.reasoning.setText(e.reasoning.strip()) 

480 self.reasoning.setStyleSheet(f"color: {muted};") 

481 if e.kind == "review": 

482 text = "" 

483 elif e.kind == "explain": 

484 text = e.answer 

485 else: 

486 text = answer_display(e.answer) 

487 if e.status == FAILED and not e.answer and e.kind != "review": 

488 text = f"*{e.note}*" 

489 self.answer.setVisible(bool(text)) 

490 self.answer.set_markdown(text) 

491 self.findings.show_findings(e.findings, pal, self.monitor.is_light) 

492 self.summary.setText(f"<b>Summary:</b> {_esc(e.summary)}" if e.summary else "") 

493 self.summary.setVisible(bool(e.summary)) 

494 if e.edits and file_text is not None: 

495 self.edit_list.set_palette(pal, self.monitor.is_light) 

496 self.edit_list.show_edits(e.edits, file_text, e.where, monospace_font(editor=True).family()) 

497 self.edit_list.setVisible(bool(e.edits)) 

498 note = e.note if e.status != RUNNING else "" 

499 if e.status == FAILED and e.kind != "review" and not e.answer: 

500 note = "" # (said above) 

501 self.footer.setText(f"<span style='color:{'#e03131' if e.status == FAILED else muted}'>" 

502 f"{_esc(note)}</span>" if note else "") 

503 self.footer.setVisible(bool(note)) 

504 

505 def _title(self, muted: str) -> str: 

506 e = self.entry 

507 parts = [] 

508 if e.kind in ("ask", "edit", "explain"): 

509 question = e.question if len(e.question) <= 80 else e.question[:77] + "…" 

510 parts.append(f"“{_esc(question)}”") 

511 if e.where: 

512 parts.append(_esc(e.where)) 

513 if e.kind == "review" and e.question: 

514 parts.append(_esc(e.question.lower())) 

515 status = "" 

516 if e.status == RUNNING: 

517 status = "running…" 

518 elif e.status == FAILED: 

519 status = "failed" if "stopped" not in e.note.lower() else "stopped" 

520 elif e.kind == "review" or e.findings: 

521 status = f"{len(e.findings)} finding{'s' * (len(e.findings) != 1)}" 

522 if e.edits: 

523 applied = sum(x.status == ed.APPLIED for x in e.edits) 

524 pending = e.pending_edits() 

525 status = ", ".join(s for s in (status if e.status != DONE else "", 

526 f"{applied} of {len(e.edits)} applied", 

527 f"{pending} pending" if pending else "") if s) 

528 text = " · ".join(parts) 

529 return text + (f" <span style='color:{muted}'>· {status}</span>" if status else "") 

530 

531 

532class Divider(QLabel): 

533 def __init__(self): 

534 super().__init__("— New chat: the AI forgets everything above —") 

535 self.setAlignment(Qt.AlignCenter) 

536 self.entry = Entry("divider", status=DONE) 

537 

538 def refresh(self, _file_text=None): 

539 self.setStyleSheet(f"color: {PALETTES['light' if app_is_light() else 'dark']['muted']};") 

540 

541 

542class ReviewMonitor(QWidget): 

543 """The timeline, the progress of what's running (and Stop), and the box 

544 for questions (Ask) and changes (Edit). It emits what the user does.""" 

545 

546 stop_requested = Signal() 

547 chat_requested = Signal(str, bool, str) # question, send the open file along, "ask" or "edit" 

548 new_chat_requested = Signal() 

549 clear_requested = Signal() 

550 selection_cleared = Signal() 

551 finding_selected = Signal(object, object) # entry, finding 

552 fix_requested = Signal(object, object) # entry, finding 

553 show_in_editor_requested = Signal(object) # entry 

554 accept_requested = Signal(object, int) # entry, edit's index 

555 reject_requested = Signal(object, int) 

556 accept_all_requested = Signal(object) 

557 reject_all_requested = Signal(object) 

558 edit_clicked = Signal(object, int) 

559 

560 def __init__(self, parent=None): 

561 super().__init__(parent) 

562 self.entries: list[Entry] = [] 

563 self.widgets: list[EntryWidget | Divider] = [] 

564 self.current: Entry | None = None # the one running 

565 self.filter = "all" 

566 self.context_note = "" # how full the model's context is ("context ~14k of 32k") 

567 self.is_light = True 

568 self.palette_colors = PALETTES["light"] 

569 self._started = 0.0 

570 self._tokens = 0 

571 self._status = "" 

572 self._follow = True # (keep the newest in view, unless scrolled up) 

573 

574 self.setObjectName("ReviewMonitor") 

575 self.setAttribute(Qt.WA_StyledBackground, True) 

576 self.title_icon = TitleIcon("robot") 

577 self.title = QLabel("AI") 

578 self.subtitle = StatusLabel() 

579 header = QHBoxLayout() 

580 header.setSpacing(6) 

581 header.addWidget(self.title_icon) 

582 header.addWidget(self.title) 

583 header.addSpacing(4) 

584 header.addWidget(self.subtitle) 

585 header.addStretch(1) 

586 self.filter_buttons: dict[str, QPushButton] = {} 

587 group = QButtonGroup(self) 

588 for key, label in FILTERS.items(): 

589 button = QPushButton(label) 

590 button.setCheckable(True) 

591 button.setObjectName("Filter") 

592 button.clicked.connect(lambda _=False, k=key: self.set_filter(k)) 

593 group.addButton(button) 

594 header.addWidget(button) 

595 self.filter_buttons[key] = button 

596 self.filter_buttons["all"].setChecked(True) 

597 self.clear_button = QPushButton("Clear") 

598 self.clear_button.setToolTip("Empty the timeline (the AI forgets it too)") 

599 self.clear_button.clicked.connect(self.clear_requested) 

600 header.addSpacing(6) 

601 header.addWidget(self.clear_button) 

602 

603 self.progress = ProgressLine(self) 

604 self.progress_label = StatusLabel() 

605 self.stop_button = QPushButton("Stop") 

606 self.stop_button.setToolTip("Stop the model") 

607 self.stop_button.clicked.connect(self.stop_requested) 

608 self.stop_button.hide() 

609 progress_row = QHBoxLayout() 

610 progress_row.setSpacing(10) 

611 progress_row.addWidget(self.progress, 1) 

612 progress_row.addWidget(self.progress_label) 

613 progress_row.addWidget(self.stop_button) 

614 

615 self.timeline = QWidget() 

616 self.timeline.setObjectName("Timeline") 

617 self.timeline_layout = QVBoxLayout(self.timeline) 

618 self.timeline_layout.setContentsMargins(0, 0, 4, 0) 

619 self.timeline_layout.setSpacing(6) 

620 self.empty = QLabel("Everything the AI does shows here: reviews (AI → Review Current File), " 

621 "explanations (Explain with AI), and questions and edits from the box below.") 

622 self.empty.setAlignment(Qt.AlignCenter) 

623 self.empty.setWordWrap(True) 

624 self.timeline_layout.addWidget(self.empty) 

625 self.timeline_layout.addStretch(1) 

626 self.scroll = QScrollArea() 

627 self.scroll.setWidgetResizable(True) 

628 self.scroll.setFrameShape(QFrame.NoFrame) 

629 self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 

630 self.scroll.setWidget(self.timeline) 

631 bar = self.scroll.verticalScrollBar() 

632 bar.rangeChanged.connect(lambda _lo, hi: bar.setValue(hi) if self._follow else None) 

633 bar.valueChanged.connect(lambda value: setattr(self, "_follow", value >= bar.maximum() - 4)) 

634 

635 # The box for questions and changes. 

636 self.chat_kind = "ask" 

637 self.ask_button = QPushButton("Ask") 

638 self.edit_button = QPushButton("Edit") 

639 self.ask_button.setToolTip("Ask about Fortran, fpm, the open file, or anything above") 

640 self.edit_button.setToolTip("Ask for a change to the open file: you accept or reject each edit") 

641 kinds = QButtonGroup(self) 

642 for button, kind in ((self.ask_button, "ask"), (self.edit_button, "edit")): 

643 button.setCheckable(True) 

644 button.setObjectName("ChatKind") 

645 kinds.addButton(button) 

646 button.clicked.connect(lambda _=False, k=kind: self.set_chat_kind(k)) 

647 self.ask_button.setChecked(True) 

648 self.selection_note = QPushButton() # (Edit Selection with AI: which lines; click to drop) 

649 self.selection_note.setToolTip("Only these lines are to change (click to drop the selection)") 

650 self.selection_note.clicked.connect(lambda: (self.set_selection_note(""), self.selection_cleared.emit())) 

651 self.selection_note.hide() 

652 self.chat_edit = QLineEdit() 

653 self.chat_edit.setPlaceholderText("Ask the AI about Fortran, fpm or the open file… (Return to send)") 

654 self.chat_edit.setClearButtonEnabled(True) 

655 self.chat_edit.returnPressed.connect(self._send_chat) 

656 self.chat_edit.installEventFilter(self) # (Up/Down: earlier questions) 

657 self.asked: list[str] = [] # questions asked, oldest first (this run of the app only) 

658 self._recall = 0 # where Up/Down are in them (len(asked): the text being typed) 

659 self._draft = "" # what was being typed before Up 

660 self.chat_file_check = QCheckBox("Send the open file") 

661 self.chat_file_check.setChecked(True) 

662 self.chat_file_check.setToolTip("Send the file open in the editor along with the question, so the " 

663 "answer can be about it (lines it points to are listed and marked " 

664 "in the editor)") 

665 self.send_button = QPushButton("Send") 

666 self.send_button.clicked.connect(self._send_chat) 

667 self.new_chat_button = QPushButton("New Chat") 

668 self.new_chat_button.setToolTip("The AI forgets everything so far (it stays in the timeline)") 

669 self.new_chat_button.clicked.connect(self.new_chat_requested) 

670 chat_row = QHBoxLayout() 

671 chat_row.setSpacing(8) 

672 chat_row.addWidget(self.ask_button) 

673 chat_row.addWidget(self.edit_button) 

674 chat_row.addSpacing(4) 

675 chat_row.addWidget(self.selection_note) 

676 chat_row.addWidget(self.chat_edit, 1) 

677 chat_row.addWidget(self.chat_file_check) 

678 chat_row.addWidget(self.send_button) 

679 chat_row.addWidget(self.new_chat_button) 

680 

681 layout = QVBoxLayout(self) 

682 layout.setContentsMargins(14, 10, 14, 6) 

683 layout.setSpacing(8) 

684 layout.addLayout(header) 

685 layout.addLayout(progress_row) 

686 layout.addWidget(self.scroll, 1) 

687 layout.addLayout(chat_row) 

688 

689 # While the model writes, the running entry is shown again at most 

690 # this often (each time is the whole answer, as Markdown, its code 

691 # highlighted: on every piece, a long answer froze the window). 

692 self._render_timer = QTimer(self) 

693 self._render_timer.setSingleShot(True) 

694 self._render_timer.timeout.connect(self._render_current) 

695 self._render_cost = 0.0 # seconds the last showing took 

696 self._timer = QTimer(self) 

697 self._timer.setInterval(50) 

698 self._timer.timeout.connect(self._update_label) 

699 self.refresh_fonts() 

700 

701 # ------------------------------------------------------------- styling 

702 def refresh_palette(self): 

703 self.is_light = app_is_light() 

704 self.palette_colors = pal = PALETTES["light" if self.is_light else "dark"] 

705 accent = "#1f6feb" if self.is_light else "#388bfd" 

706 self.setStyleSheet( 

707 panel_stylesheet(self.objectName(), pal) 

708 + f""" 

709 #ReviewMonitor QScrollArea, #Timeline {{ background: {pal['bg']}; border: none; }} 

710 #Entry {{ border: 1px solid {pal['line']}; border-radius: 7px; background: {pal['bg']}; }} 

711 #FindingRow:hover {{ background: {pal['hover']}; border-radius: 4px; }} 

712 QPushButton#ChatKind:checked, QPushButton#Filter:checked {{ background: {accent}; color: white; 

713 border: 1px solid {accent}; border-radius: 5px; padding: 3px 10px; }} 

714 """) 

715 self.title_icon.draw(pal["text"], self.title) 

716 self.subtitle.setStyleSheet(f"color: {pal['muted']};") 

717 self.empty.setStyleSheet(f"color: {pal['muted']};") 

718 for widget in self.widgets: 718 ↛ 719line 718 didn't jump to line 719 because the loop on line 718 never started

719 if isinstance(widget, EntryWidget): 

720 widget.answer.restyle() # (code in the editor's colors, which may have changed) 

721 self._refresh(widget) 

722 self.progress.update() 

723 

724 def refresh_fonts(self): 

725 title_font = QFont(QApplication.font()) 

726 title_font.setPointSizeF(title_font.pointSizeF() + 2) 

727 title_font.setWeight(QFont.Bold) 

728 self.title.setFont(title_font) 

729 self.refresh_palette() 

730 

731 # ------------------------------------------------------------ entries 

732 @property 

733 def running(self) -> bool: 

734 return self.current is not None 

735 

736 def add_entry(self, entry: Entry) -> Entry: 

737 """A new action, at the bottom (the older ones fold); it's running.""" 

738 for widget in self.widgets: 

739 if isinstance(widget, EntryWidget) and widget.expanded: 

740 widget.set_expanded(False) 

741 self._add_widget(EntryWidget(entry, self), entry) 

742 self.current = entry 

743 self.subtitle.setText(" · ".join(s for s in (entry.where, entry.model) if s)) 

744 self._status = "Starting" 

745 self._started = time.monotonic() 

746 self._tokens = 0 

747 self.progress.set_busy(True) 

748 self.stop_button.setEnabled(False) # (until there's a model to stop: see set_stoppable) 

749 self.stop_button.show() 

750 self._timer.start() 

751 self._update_label() 

752 return entry 

753 

754 def add_divider(self): 

755 """New Chat: a line across the timeline.""" 

756 divider = Divider() 

757 self._add_widget(divider, divider.entry) 

758 

759 def _add_widget(self, widget, entry: Entry): 

760 self._follow = True 

761 self.entries.append(entry) 

762 self.widgets.append(widget) 

763 self.timeline_layout.insertWidget(self.timeline_layout.count() - 1, widget) 

764 self.empty.hide() 

765 self._apply_filter(widget) 

766 self._refresh(widget) 

767 

768 def widget_for(self, entry: Entry) -> EntryWidget | None: 

769 return next((w for w in self.widgets if w.entry is entry), None) 

770 

771 def refresh(self, entry: Entry, file_text: str | None = None): 

772 """Show an entry as it is now (``file_text``: its file's text, for 

773 its edit cards).""" 

774 widget = self.widget_for(entry) 

775 if widget is not None: 775 ↛ exitline 775 didn't return from function 'refresh' because the condition on line 775 was always true

776 self._refresh(widget, file_text) 

777 

778 def _refresh(self, widget, file_text: str | None = None): 

779 widget.refresh(file_text) 

780 self._apply_filter(widget) 

781 

782 def set_status(self, status: str, fraction: float = -1.0): 

783 """What the running entry is doing: a status text, or a download's 

784 progress (fraction 0..1).""" 

785 self._status = status 

786 if fraction >= 0: 

787 self.progress.set(fraction, "running") 

788 else: 

789 self.progress.set_busy(True) 

790 self._update_label() 

791 

792 def add_answer(self, text: str): 

793 """More of the running entry's answer, as the model writes it.""" 

794 entry = self.current 

795 if entry is None: 795 ↛ 796line 795 didn't jump to line 796 because the condition on line 795 was never true

796 return 

797 self._tokens += 1 

798 word = {"review": "Reviewing", "explain": "Explaining"}.get(entry.kind, "Answering") 

799 if self._status != word: 

800 self._status = word 

801 self._update_label() 

802 entry.answer += text 

803 self._render_soon() 

804 

805 RENDER_INTERVAL = 0.15 # seconds, at least, between showings of the answer being written 

806 

807 def refresh_soon(self, entry: Entry): 

808 """Show an entry again: the running one with the next showing (see 

809 _render_soon: what the model writes comes in many small pieces), 

810 any other one now.""" 

811 if entry is self.current: 811 ↛ 814line 811 didn't jump to line 814 because the condition on line 811 was always true

812 self._render_soon() 

813 else: 

814 self.refresh(entry) 

815 

816 def _render_soon(self): 

817 if not self._render_timer.isActive(): 

818 # (Twice what the last showing took, if more: the window stays responsive.) 

819 self._render_timer.start(int(1000 * max(self.RENDER_INTERVAL, 2 * self._render_cost))) 

820 

821 def _render_current(self): 

822 if self.current is not None: 822 ↛ exitline 822 didn't return from function '_render_current' because the condition on line 822 was always true

823 started = time.monotonic() 

824 self.refresh(self.current) 

825 # Laid out now, not later: the time it takes counts, so a costly 

826 # showing makes the next one wait longer and the window stays free. 

827 # Only this panel's timeline: laying out the whole app here (all 

828 # posted layout requests) cut into other windows' changes, a panel 

829 # docked again by double-clicking its title left half placed. 

830 widget = self.widget_for(self.current) 

831 if widget is not None: 831 ↛ 833line 831 didn't jump to line 833 because the condition on line 831 was always true

832 widget.layout().activate() 

833 self.timeline_layout.activate() 

834 self._render_cost = time.monotonic() - started 

835 

836 def add_reasoning(self, text: str): 

837 """More of a thinking model's reasoning (before its answer).""" 

838 entry = self.current 

839 if entry is None: 839 ↛ 840line 839 didn't jump to line 840 because the condition on line 839 was never true

840 return 

841 self._tokens += 1 

842 if self._status != "Reasoning": 

843 self._status = "Reasoning" 

844 self._update_label() 

845 entry.reasoning += text 

846 self._render_soon() 

847 

848 def finish(self, ok: bool, note: str = ""): 

849 """The running entry is done (or failed: ``note`` says why).""" 

850 entry = self.current 

851 self.current = None 

852 self._render_timer.stop() # (shown in full just below) 

853 self._render_cost = 0.0 

854 self._timer.stop() 

855 self.stop_button.hide() 

856 self.progress.set(1.0, "ok" if ok else "failed") 

857 self._status = note 

858 if entry is not None: 

859 entry.status = DONE if ok else FAILED 

860 entry.note = note 

861 self.refresh(entry) 

862 self._update_label() 

863 

864 def set_stoppable(self, on: bool): 

865 """The model is working (Stop can end it), or not.""" 

866 self.stop_button.setEnabled(on) 

867 

868 def clear(self): 

869 """An empty timeline.""" 

870 for widget in self.widgets: 

871 widget.deleteLater() 

872 self.entries, self.widgets, self.current = [], [], None 

873 self._render_timer.stop() 

874 self._timer.stop() 

875 self.stop_button.hide() 

876 self.progress.set(0, "running") 

877 self._status = "" 

878 self.context_note = "" 

879 self.subtitle.clear() 

880 self.empty.show() 

881 self._update_label() 

882 

883 def reset(self): 

884 """Back to the empty state (e.g. when another project is opened).""" 

885 self.clear() 

886 

887 # ------------------------------------------------------------- filter 

888 def set_filter(self, key: str): 

889 self.filter = key 

890 self.filter_buttons[key].setChecked(True) 

891 for widget in self.widgets: 

892 self._apply_filter(widget) 

893 

894 def _apply_filter(self, widget): 

895 e, key = widget.entry, self.filter 

896 shown = (key == "all" or (key == "chat" and e.kind in ("ask", "edit")) 

897 or (key == "pending" and e.pending_edits() > 0) or e.kind == key) 

898 widget.setVisible(shown) 

899 

900 # --------------------------------------------------------------- chat 

901 def set_sending(self, on: bool): 

902 """Questions can be sent (not while the model is busy: the question 

903 stays in the box until it's done).""" 

904 self.send_button.setEnabled(on) 

905 self.send_button.setToolTip("" if on else "The AI is busy: wait for its answer, or Stop it") 

906 

907 def _send_chat(self): 

908 question = self.chat_edit.text().strip() 

909 if question and self.send_button.isEnabled(): 

910 if not self.asked or self.asked[-1] != question: 

911 self.asked.append(question) 

912 self._recall, self._draft = len(self.asked), "" 

913 self.chat_requested.emit(question, self.chat_file_check.isChecked(), self.chat_kind) 

914 

915 def set_chat_kind(self, kind: str): 

916 """"ask" or "edit" (which always sends the open file).""" 

917 self.chat_kind = kind 

918 editing = kind == "edit" 

919 (self.edit_button if editing else self.ask_button).setChecked(True) 

920 if editing: 

921 self._file_wanted = self.chat_file_check.isChecked() 

922 self.chat_file_check.setChecked(True) 

923 elif not self.chat_file_check.isEnabled(): 923 ↛ 925line 923 didn't jump to line 925 because the condition on line 923 was always true

924 self.chat_file_check.setChecked(getattr(self, "_file_wanted", True)) 

925 self.chat_file_check.setEnabled(not editing) 

926 self.chat_edit.setPlaceholderText( 

927 "Describe a change to the open file… (Return to send)" if editing 

928 else "Ask the AI about Fortran, fpm or the open file… (Return to send)") 

929 if not editing: 

930 self.set_selection_note("") 

931 

932 def set_selection_note(self, text: str): 

933 self.selection_note.setText(f"{text} ✕" if text else "") 

934 self.selection_note.setVisible(bool(text)) 

935 

936 def _recall_question(self, step: int) -> bool: 

937 """Up (-1) / Down (+1): the previous / next question asked, as in a 

938 shell; past the newest, what was being typed. False at either end.""" 

939 position = self._recall + step 

940 if not 0 <= position <= len(self.asked): 

941 return False 

942 if self._recall == len(self.asked): 

943 self._draft = self.chat_edit.text() 

944 self._recall = position 

945 self.chat_edit.setText(self.asked[position] if position < len(self.asked) else self._draft) 

946 return True 

947 

948 def eventFilter(self, obj, event): 

949 # (macOS marks arrow keys with KeypadModifier: only real modifiers count.) 

950 if obj is self.chat_edit and event.type() == QEvent.KeyPress \ 

951 and not event.modifiers() & ~Qt.KeypadModifier and event.key() in (Qt.Key_Up, Qt.Key_Down): 

952 self._recall_question(-1 if event.key() == Qt.Key_Up else 1) 

953 return True 

954 return super().eventFilter(obj, event) 

955 

956 # ------------------------------------------------------------- display 

957 def _update_label(self): 

958 pal = self.palette_colors 

959 muted = f"<span style='color:{pal['muted']}'>" 

960 context = f" · {self.context_note}" if self.context_note else "" 

961 if self.running: 

962 seconds = time.monotonic() - self._started 

963 extra = f" · {self._tokens} token{'s' * (self._tokens != 1)}" if self._tokens else "" 

964 text = f"<b>{self._status or 'Working'}…</b> {muted}{seconds:.0f} s{extra}{context}</span>" 

965 elif self._status: 

966 text = f"{muted}{_esc(self._status)}{context}</span>" 

967 else: 

968 text = "" 

969 self.progress_label.setText(text)