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
« 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.
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"""
14from __future__ import annotations
16import html
17import re
18import time
19from dataclasses import dataclass, field
20from pathlib import Path
21from typing import Any
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)
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
68FINDING_LINE_RE = re.compile(r"\s*\**\s*LINES?\s*\d", re.IGNORECASE) # (a finding in a chat answer)
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"
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)
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
104 def pending_edits(self) -> int:
105 return sum(e.status == ed.PENDING and e.located for e in self.edits)
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}"
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)
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()
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))
213class MarkdownView(QTextBrowser):
214 """Markdown as tall as its text (the timeline scrolls, not this)."""
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
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())
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)
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."""
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] = {}
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)
256 def _forget(self):
257 self._heights.clear()
258 self._hints.clear()
260 def changeEvent(self, event):
261 if event.type() in (QEvent.FontChange, QEvent.StyleChange):
262 self._forget()
263 super().changeEvent(event)
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
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"]
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"]
282class FindingRow(QWidget):
283 """One finding: its line, category and comment; click to go to the
284 line; Fix with AI…"""
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
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)
309class FindingsView(QWidget):
310 """Findings, one row each: click to go to the line; Fix with AI…
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."""
317 clicked = Signal(object)
318 fix = Signal(object)
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] = []
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" <b style='color:{color}'>{CATEGORY_ICONS.get(f.category, '')} "
344 f"{_esc(f.category)}</b> {_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))
358 @staticmethod
359 def _key(f: Finding | None) -> tuple:
360 return (f.line, f.end_line, f.category) if f is not None else ()
363class EntryWidget(QFrame):
364 """One entry of the timeline: a header (click to fold or open it) and
365 what it holds."""
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)
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()
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)
447 def set_expanded(self, on: bool):
448 self.expanded = on
449 self.refresh()
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))
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 "")
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)
538 def refresh(self, _file_text=None):
539 self.setStyleSheet(f"color: {PALETTES['light' if app_is_light() else 'dark']['muted']};")
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."""
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)
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)
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)
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)
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))
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)
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)
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()
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()
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()
731 # ------------------------------------------------------------ entries
732 @property
733 def running(self) -> bool:
734 return self.current is not None
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
754 def add_divider(self):
755 """New Chat: a line across the timeline."""
756 divider = Divider()
757 self._add_widget(divider, divider.entry)
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)
768 def widget_for(self, entry: Entry) -> EntryWidget | None:
769 return next((w for w in self.widgets if w.entry is entry), None)
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)
778 def _refresh(self, widget, file_text: str | None = None):
779 widget.refresh(file_text)
780 self._apply_filter(widget)
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()
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()
805 RENDER_INTERVAL = 0.15 # seconds, at least, between showings of the answer being written
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)
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)))
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
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()
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()
864 def set_stoppable(self, on: bool):
865 """The model is working (Stop can end it), or not."""
866 self.stop_button.setEnabled(on)
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()
883 def reset(self):
884 """Back to the empty state (e.g. when another project is opened)."""
885 self.clear()
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)
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)
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")
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)
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("")
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))
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
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)
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)