vibefpmgui.review_monitor

The Review tab: a timeline of everything the AI does (reviews, Explain with AI, questions and edits from the box at the bottom), newest at the bottom. Nothing replaces anything: each action adds an entry, older ones fold to a one-line header (click to open), and everything in them stays usable: findings jump to their line or get Fix with AI…, a review's markers go back in the editor, edits can still be accepted. The timeline lasts until the app quits (or Clear); New Chat draws a line: the model forgets what's above it.

The panel only shows Entry objects and says what the user did; vibefpmgui.window.ai and vibefpmgui.window.ai_chat run the model and change files.

  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":
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":
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():
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:
273            self._hints["size"] = super().sizeHint()
274        return self._hints["size"]
275
276    def minimumSizeHint(self):
277        if "minimum" not in self._hints:
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:
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:
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):
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:
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:
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:
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:
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:
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:
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:
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():
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)
FINDING_LINE_RE = re.compile('\\s*\\**\\s*LINES?\\s*\\d', re.IGNORECASE)
CATEGORY_COLORS = {'bug': '#e03131', 'warning': '#f08c00', 'performance': '#0c8599', 'style': '#7048e8', 'suggestion': '#1c7ed6'}
CATEGORY_ICONS = {'bug': '✕', 'warning': '⚠', 'performance': '⚡', 'style': '✎', 'suggestion': '💡'}
KINDS = {'review': ('Review', '#7048e8'), 'explain': ('Explain', '#0c8599'), 'ask': ('Ask', '#1c7ed6'), 'edit': ('Edit', '#2f9e44')}
FILTERS = {'all': 'All', 'review': 'Reviews', 'explain': 'Explanations', 'chat': 'Chat', 'pending': 'Pending edits'}
@dataclass
class Entry:
 86@dataclass
 87class Entry:
 88    """One AI action: a review, an explanation, a question (Ask) or a
 89    change (Edit); or a divider (New Chat: the model forgets what's above)."""
 90    kind: str                     # "review", "explain", "ask", "edit", "divider"
 91    question: str = ""            # the question; the problem explained; a review's focus
 92    path: Path | None = None      # the file it's about
 93    where: str = ""               # shown in the header: "foo.f90", "foo.f90:12"
 94    model: str = ""
 95    answer: str = ""              # as the model wrote it (Markdown)
 96    reasoning: str = ""           # a thinking model's, before its answer (AI → Think Before Answering)
 97    findings: list[Finding] = field(default_factory=list)
 98    summary: str = ""             # a review's
 99    edits: list[ed.Edit] = field(default_factory=list)
100    status: str = RUNNING
101    note: str = ""                # "4.1 s · model", or what went wrong
102    started: str = field(default_factory=lambda: time.strftime("%H:%M"))
103    turn: Any = None              # what the model was asked (an ai_context.Turn), for later questions
104
105    def pending_edits(self) -> int:
106        return sum(e.status == ed.PENDING and e.located for e in self.edits)
107
108    def markdown(self) -> str:
109        """The entry as text (Copy)."""
110        if self.kind == "review":
111            lines = [f"**Review of {self.where}** ({self.question})", ""]
112            lines += [f"- LINE {f.line}{'' if f.end_line == f.line else f'-{f.end_line}'}: {f.category}: "
113                      f"{f.comment}" for f in self.findings]
114            return "\n".join(lines + ([f"\n**Summary:** {self.summary}"] if self.summary else []))
115        if self.kind == "explain":
116            return f"**{self.where}:** {self.question}\n\n{self.answer}"
117        return f"**You:** {self.question}\n\n{self.answer}"

One AI action: a review, an explanation, a question (Ask) or a change (Edit); or a divider (New Chat: the model forgets what's above).

Entry( kind: str, question: str = '', path: pathlib.Path | None = None, where: str = '', model: str = '', answer: str = '', reasoning: str = '', findings: list[vibefpmgui.ai_review.Finding] = <factory>, summary: str = '', edits: list[vibefpmgui.ai_edits.Edit] = <factory>, status: str = 'running', note: str = '', started: str = <factory>, turn: Any = None)
kind: str
question: str = ''
path: pathlib.Path | None = None
where: str = ''
model: str = ''
answer: str = ''
reasoning: str = ''
summary: str = ''
status: str = 'running'
note: str = ''
started: str
turn: Any = None
def pending_edits(self) -> int:
105    def pending_edits(self) -> int:
106        return sum(e.status == ed.PENDING and e.located for e in self.edits)
def markdown(self) -> str:
108    def markdown(self) -> str:
109        """The entry as text (Copy)."""
110        if self.kind == "review":
111            lines = [f"**Review of {self.where}** ({self.question})", ""]
112            lines += [f"- LINE {f.line}{'' if f.end_line == f.line else f'-{f.end_line}'}: {f.category}: "
113                      f"{f.comment}" for f in self.findings]
114            return "\n".join(lines + ([f"\n**Summary:** {self.summary}"] if self.summary else []))
115        if self.kind == "explain":
116            return f"**{self.where}:** {self.question}\n\n{self.answer}"
117        return f"**You:** {self.question}\n\n{self.answer}"

The entry as text (Copy).

def answer_display(answer: str) -> str:
120def answer_display(answer: str) -> str:
121    """A chat answer as shown: its "LINE n: …" lines each on their own
122    (Markdown would run them together), as a list; its edit blocks as a
123    pointer to their card."""
124    shown: list[str] = []
125    fenced = in_block = drop_fence = False
126    n = 0
127    for line in answer.split("\n"):
128        fence = line.lstrip().startswith("```")
129        if in_block:
130            if EDIT_END_RE.match(line):
131                in_block = False
132                shown[-1] = f"*✎ Edit {n}: see its card below.*"
133            continue
134        if EDIT_START_RE.match(line):
135            in_block, n = True, n + 1
136            if fenced and shown and shown[-1].lstrip().startswith("```"):  # (a fence just around it)
137                shown.pop()
138                fenced, drop_fence = False, True
139            shown.append(f"*✎ Writing edit {n}…*")
140            continue
141        if drop_fence and fence:  # (that fence's end)
142            drop_fence = False
143            continue
144        if line.strip():
145            drop_fence = False
146        if fence:
147            fenced = not fenced
148        elif not fenced and FINDING_LINE_RE.match(line):
149            line = "- " + line.strip()
150        shown.append(line)
151    return "\n".join(shown)

A chat answer as shown: its "LINE n: …" lines each on their own (Markdown would run them together), as a list; its edit blocks as a pointer to their card.

def style_code(document, family: str, style_name: str | None = None):
154def style_code(document, family: str, style_name: str | None = None):
155    """Code in a Markdown document in a fixed-width font (Qt's Markdown asks
156    only for "monospace", which macOS turns into a serif font, or ignores),
157    and code blocks labeled with a language (```fortran, ```toml…)
158    highlighted as the editor would, in its colors (``style_name``: a
159    Pygments style; by default the one the editor uses)."""
160    fixed = QTextCharFormat()
161    fixed.setFontFamilies([family])
162    cursor = QTextCursor(document)
163    block = document.begin()
164    while block.isValid():
165        if block.blockFormat().hasProperty(QTextFormat.BlockCodeFence):
166            language = str(block.blockFormat().property(QTextFormat.BlockCodeLanguage) or "")
167            lines = []
168            while block.isValid() and block.blockFormat().hasProperty(QTextFormat.BlockCodeFence) \
169                    and str(block.blockFormat().property(QTextFormat.BlockCodeLanguage) or "") == language:
170                lines.append(block)
171                block = block.next()
172            cursor.setPosition(lines[0].position())
173            cursor.setPosition(lines[-1].position() + lines[-1].length() - 1, QTextCursor.KeepAnchor)
174            cursor.mergeCharFormat(fixed)
175            _highlight(cursor, lines, language, style_name)
176            continue
177        it = block.begin()
178        while not it.atEnd():
179            fragment = it.fragment()
180            if fragment.charFormat().fontFixedPitch():
181                cursor.setPosition(fragment.position())
182                cursor.setPosition(fragment.position() + fragment.length(), QTextCursor.KeepAnchor)
183                cursor.mergeCharFormat(fixed)
184            it += 1
185        block = block.next()

Code in a Markdown document in a fixed-width font (Qt's Markdown asks only for "monospace", which macOS turns into a serif font, or ignores), and code blocks labeled with a language (fortran,toml…) highlighted as the editor would, in its colors (style_name: a Pygments style; by default the one the editor uses).

class MarkdownView(PySide6.QtWidgets.QTextBrowser):
214class MarkdownView(QTextBrowser):
215    """Markdown as tall as its text (the timeline scrolls, not this)."""
216
217    def __init__(self, parent=None):
218        super().__init__(parent)
219        self.setOpenExternalLinks(True)
220        self.setFrameShape(QFrame.NoFrame)
221        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
222        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
223        self.document().documentLayout().documentSizeChanged.connect(
224            lambda size: self.setFixedHeight(int(size.height()) + 6))
225        self._text = None
226
227    def set_markdown(self, text: str):
228        if text != self._text:
229            self._text = text
230            self.setMarkdown(text)
231            style_code(self.document(), monospace_font(editor=True).family())
232
233    def restyle(self):
234        """Again, in the editor's colors as they are now."""
235        text, self._text = self._text, None
236        if text is not None:
237            self.set_markdown(text)

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

MarkdownView(parent=None)
217    def __init__(self, parent=None):
218        super().__init__(parent)
219        self.setOpenExternalLinks(True)
220        self.setFrameShape(QFrame.NoFrame)
221        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
222        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
223        self.document().documentLayout().documentSizeChanged.connect(
224            lambda size: self.setFixedHeight(int(size.height()) + 6))
225        self._text = None

__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, *, source: PySide6.QtCore.QUrl | None = None, sourceType: PySide6.QtGui.QTextDocument.ResourceType | None = None, searchPaths: collections.abc.Sequence[str] | None = None, openExternalLinks: bool | None = None, openLinks: bool | None = None) -> None

Initialize self. See help(type(self)) for accurate signature.

def set_markdown(self, text: str):
227    def set_markdown(self, text: str):
228        if text != self._text:
229            self._text = text
230            self.setMarkdown(text)
231            style_code(self.document(), monospace_font(editor=True).family())
def restyle(self):
233    def restyle(self):
234        """Again, in the editor's colors as they are now."""
235        text, self._text = self._text, None
236        if text is not None:
237            self.set_markdown(text)

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

staticMetaObject = PySide6.QtCore.QMetaObject("MarkdownView" inherits "QTextBrowser": )
class WrappedLabel(PySide6.QtWidgets.QLabel):
240class WrappedLabel(QLabel):
241    """A word-wrapped label that remembers its size for its text: Qt asks a
242    wrapped label its height again on every layout pass, laying out its text
243    each time, and with the timeline's findings (hundreds of rows) and an
244    answer streaming in, that kept the window busy."""
245
246    def __init__(self, text: str = ""):
247        super().__init__(text)
248        self.setWordWrap(True)
249        self._heights: dict[int, int] = {}
250        self._hints: dict[str, object] = {}
251
252    def setText(self, text: str):
253        if text != self.text():
254            self._forget()
255        super().setText(text)
256
257    def _forget(self):
258        self._heights.clear()
259        self._hints.clear()
260
261    def changeEvent(self, event):
262        if event.type() in (QEvent.FontChange, QEvent.StyleChange):
263            self._forget()
264        super().changeEvent(event)
265
266    def heightForWidth(self, width: int) -> int:
267        height = self._heights.get(width)
268        if height is None:
269            height = self._heights[width] = super().heightForWidth(width)
270        return height
271
272    def sizeHint(self):
273        if "size" not in self._hints:
274            self._hints["size"] = super().sizeHint()
275        return self._hints["size"]
276
277    def minimumSizeHint(self):
278        if "minimum" not in self._hints:
279            self._hints["minimum"] = super().minimumSizeHint()
280        return self._hints["minimum"]

A word-wrapped label that remembers its size for its text: Qt asks a wrapped label its height again on every layout pass, laying out its text each time, and with the timeline's findings (hundreds of rows) and an answer streaming in, that kept the window busy.

WrappedLabel(text: str = '')
246    def __init__(self, text: str = ""):
247        super().__init__(text)
248        self.setWordWrap(True)
249        self._heights: dict[int, int] = {}
250        self._hints: dict[str, object] = {}

__init__(self, text: str, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, textFormat: PySide6.QtCore.Qt.TextFormat | None = None, pixmap: PySide6.QtGui.QPixmap | None = None, scaledContents: bool | None = None, alignment: PySide6.QtCore.Qt.AlignmentFlag | None = None, wordWrap: bool | None = None, margin: int | None = None, indent: int | None = None, openExternalLinks: bool | None = None, textInteractionFlags: PySide6.QtCore.Qt.TextInteractionFlag | None = None, hasSelectedText: bool | None = None, selectedText: str | None = None) -> None __init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, text: str | None = None, textFormat: PySide6.QtCore.Qt.TextFormat | None = None, pixmap: PySide6.QtGui.QPixmap | None = None, scaledContents: bool | None = None, alignment: PySide6.QtCore.Qt.AlignmentFlag | None = None, wordWrap: bool | None = None, margin: int | None = None, indent: int | None = None, openExternalLinks: bool | None = None, textInteractionFlags: PySide6.QtCore.Qt.TextInteractionFlag | None = None, hasSelectedText: bool | None = None, selectedText: str | None = None) -> None

Initialize self. See help(type(self)) for accurate signature.

def setText(self, text: str):
252    def setText(self, text: str):
253        if text != self.text():
254            self._forget()
255        super().setText(text)

setText(self, arg__1: str, /) -> None

def changeEvent(self, event):
261    def changeEvent(self, event):
262        if event.type() in (QEvent.FontChange, QEvent.StyleChange):
263            self._forget()
264        super().changeEvent(event)

changeEvent(self, arg__1: PySide6.QtCore.QEvent, /) -> None

def heightForWidth(self, width: int) -> int:
266    def heightForWidth(self, width: int) -> int:
267        height = self._heights.get(width)
268        if height is None:
269            height = self._heights[width] = super().heightForWidth(width)
270        return height

heightForWidth(self, arg__1: int, /) -> int

def sizeHint(self):
272    def sizeHint(self):
273        if "size" not in self._hints:
274            self._hints["size"] = super().sizeHint()
275        return self._hints["size"]

sizeHint(self, /) -> PySide6.QtCore.QSize

def minimumSizeHint(self):
277    def minimumSizeHint(self):
278        if "minimum" not in self._hints:
279            self._hints["minimum"] = super().minimumSizeHint()
280        return self._hints["minimum"]

minimumSizeHint(self, /) -> PySide6.QtCore.QSize

staticMetaObject = PySide6.QtCore.QMetaObject("WrappedLabel" inherits "QLabel": )
class FindingRow(PySide6.QtWidgets.QWidget):
283class FindingRow(QWidget):
284    """One finding: its line, category and comment; click to go to the
285    line; Fix with AI…"""
286
287    def __init__(self, view: FindingsView):
288        super().__init__()
289        self.finding: Finding | None = None
290        self.setObjectName("FindingRow")
291        self.setCursor(Qt.PointingHandCursor)
292        self.label = WrappedLabel()
293        self.label.setToolTip("Go to the line")
294        fix = QToolButton()
295        fix.setText("Fix with AI…")
296        fix.setToolTip("Ask the AI for an edit that addresses this finding")
297        fix.clicked.connect(lambda: self.finding is not None and view.fix.emit(self.finding))
298        box = QHBoxLayout(self)
299        box.setContentsMargins(6, 3, 6, 3)
300        box.addWidget(self.label, 1)
301        box.addWidget(fix, 0, Qt.AlignTop)
302        self._view = view
303
304    def mousePressEvent(self, event):
305        if event.button() == Qt.LeftButton and self.finding is not None:
306            self._view.clicked.emit(self.finding)
307        super().mousePressEvent(event)

One finding: its line, category and comment; click to go to the line; Fix with AI…

FindingRow(view: FindingsView)
287    def __init__(self, view: FindingsView):
288        super().__init__()
289        self.finding: Finding | None = None
290        self.setObjectName("FindingRow")
291        self.setCursor(Qt.PointingHandCursor)
292        self.label = WrappedLabel()
293        self.label.setToolTip("Go to the line")
294        fix = QToolButton()
295        fix.setText("Fix with AI…")
296        fix.setToolTip("Ask the AI for an edit that addresses this finding")
297        fix.clicked.connect(lambda: self.finding is not None and view.fix.emit(self.finding))
298        box = QHBoxLayout(self)
299        box.setContentsMargins(6, 3, 6, 3)
300        box.addWidget(self.label, 1)
301        box.addWidget(fix, 0, Qt.AlignTop)
302        self._view = view

__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None

Initialize self. See help(type(self)) for accurate signature.

label
def mousePressEvent(self, event):
304    def mousePressEvent(self, event):
305        if event.button() == Qt.LeftButton and self.finding is not None:
306            self._view.clicked.emit(self.finding)
307        super().mousePressEvent(event)

mousePressEvent(self, event: PySide6.QtGui.QMouseEvent, /) -> None

staticMetaObject = PySide6.QtCore.QMetaObject("FindingRow" inherits "QWidget": )
class FindingsView(PySide6.QtWidgets.QWidget):
310class FindingsView(QWidget):
311    """Findings, one row each: click to go to the line; Fix with AI…
312
313    The rows are kept and updated in place (only a row whose finding changed
314    gets new text; new findings add rows at the end): rebuilding every row
315    for each finding a review streamed in took ever longer, and with a few
316    dozen findings the window crawled."""
317
318    clicked = Signal(object)
319    fix = Signal(object)
320
321    def __init__(self, parent=None):
322        super().__init__(parent)
323        self.layout_ = QVBoxLayout(self)
324        self.layout_.setContentsMargins(0, 0, 0, 0)
325        self.layout_.setSpacing(2)
326        self.rows: list[FindingRow] = []
327
328    def show_findings(self, findings: list[Finding], pal: dict, is_light: bool):
329        family = monospace_font(editor=True).family()
330        # Each row stays with its finding (by line and category): a finding
331        # coming in between others gets a new row there, rather than every
332        # row below taking the next one's text (and being measured again).
333        spare: dict[tuple, list[FindingRow]] = {}
334        for row in self.rows:
335            spare.setdefault(self._key(row.finding), []).append(row)
336        rows = []
337        for f in sorted(findings, key=lambda f: f.line):
338            same = spare.get(self._key(f))
339            row = same.pop(0) if same else FindingRow(self)
340            row.finding = f
341            color = CATEGORY_COLORS.get(f.category, pal["muted"])
342            lines = str(f.line) if f.end_line == f.line else f"{f.line}–{f.end_line}"
343            text = (f"<span style='font-family:\"{family}\"'>{lines}</span>"
344                    f"&nbsp;&nbsp;<b style='color:{color}'>{CATEGORY_ICONS.get(f.category, '')} "
345                    f"{_esc(f.category)}</b>&nbsp;&nbsp;{_esc(f.comment)}")
346            if row.label.text() != text:  # (only what changed is measured again)
347                row.label.setText(text)
348            rows.append(row)
349        for left in spare.values():
350            for row in left:
351                self.layout_.removeWidget(row)
352                row.deleteLater()
353        for index, row in enumerate(rows):  # (new rows into their places; the others keep theirs)
354            if self.layout_.indexOf(row) != index:
355                self.layout_.insertWidget(index, row)
356        self.rows = rows
357        self.setVisible(bool(findings))
358
359    @staticmethod
360    def _key(f: Finding | None) -> tuple:
361        return (f.line, f.end_line, f.category) if f is not None else ()

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

The rows are kept and updated in place (only a row whose finding changed gets new text; new findings add rows at the end): rebuilding every row for each finding a review streamed in took ever longer, and with a few dozen findings the window crawled.

FindingsView(parent=None)
321    def __init__(self, parent=None):
322        super().__init__(parent)
323        self.layout_ = QVBoxLayout(self)
324        self.layout_.setContentsMargins(0, 0, 0, 0)
325        self.layout_.setSpacing(2)
326        self.rows: list[FindingRow] = []

__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None

Initialize self. See help(type(self)) for accurate signature.

def clicked(unknown):

The type of the None singleton.

def fix(unknown):

The type of the None singleton.

layout_
rows: list[FindingRow]
def show_findings( self, findings: list[vibefpmgui.ai_review.Finding], pal: dict, is_light: bool):
328    def show_findings(self, findings: list[Finding], pal: dict, is_light: bool):
329        family = monospace_font(editor=True).family()
330        # Each row stays with its finding (by line and category): a finding
331        # coming in between others gets a new row there, rather than every
332        # row below taking the next one's text (and being measured again).
333        spare: dict[tuple, list[FindingRow]] = {}
334        for row in self.rows:
335            spare.setdefault(self._key(row.finding), []).append(row)
336        rows = []
337        for f in sorted(findings, key=lambda f: f.line):
338            same = spare.get(self._key(f))
339            row = same.pop(0) if same else FindingRow(self)
340            row.finding = f
341            color = CATEGORY_COLORS.get(f.category, pal["muted"])
342            lines = str(f.line) if f.end_line == f.line else f"{f.line}–{f.end_line}"
343            text = (f"<span style='font-family:\"{family}\"'>{lines}</span>"
344                    f"&nbsp;&nbsp;<b style='color:{color}'>{CATEGORY_ICONS.get(f.category, '')} "
345                    f"{_esc(f.category)}</b>&nbsp;&nbsp;{_esc(f.comment)}")
346            if row.label.text() != text:  # (only what changed is measured again)
347                row.label.setText(text)
348            rows.append(row)
349        for left in spare.values():
350            for row in left:
351                self.layout_.removeWidget(row)
352                row.deleteLater()
353        for index, row in enumerate(rows):  # (new rows into their places; the others keep theirs)
354            if self.layout_.indexOf(row) != index:
355                self.layout_.insertWidget(index, row)
356        self.rows = rows
357        self.setVisible(bool(findings))
staticMetaObject = PySide6.QtCore.QMetaObject("FindingsView" inherits "QWidget": Methods: #33 type=Signal, signature=clicked(PyObject), parameters=PyObject #34 type=Signal, signature=fix(PyObject), parameters=PyObject )
class EntryWidget(PySide6.QtWidgets.QFrame):
364class EntryWidget(QFrame):
365    """One entry of the timeline: a header (click to fold or open it) and
366    what it holds."""
367
368    def __init__(self, entry: Entry, monitor: ReviewMonitor):
369        super().__init__()
370        self.entry, self.monitor = entry, monitor
371        self.expanded = True
372        self.setObjectName("Entry")
373        self.header = QWidget()
374        self.header.setCursor(Qt.PointingHandCursor)
375        self.header.mousePressEvent = lambda event: self.set_expanded(not self.expanded) \
376            if event.button() == Qt.LeftButton else None
377        self.kind = QLabel()
378        self.title = QLabel()
379        self.title.setTextFormat(Qt.RichText)
380        self.title.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
381        self.when = QLabel()
382        self.show_button = QToolButton()
383        self.show_button.setText("Show in Editor")
384        self.show_button.setToolTip("Mark these findings in the editor again")
385        self.show_button.clicked.connect(lambda: monitor.show_in_editor_requested.emit(entry))
386        self.copy_button = QToolButton()
387        self.copy_button.setText("Copy")
388        self.copy_button.setToolTip("Copy this entry (Markdown)")
389        self.copy_button.clicked.connect(lambda: QApplication.clipboard().setText(entry.markdown()))
390        self.chevron = QLabel()
391        top = QHBoxLayout(self.header)
392        top.setContentsMargins(0, 0, 0, 0)
393        top.setSpacing(8)
394        top.addWidget(self.chevron)
395        top.addWidget(self.kind)
396        top.addWidget(self.title, 1)
397        top.addWidget(self.show_button)
398        top.addWidget(self.copy_button)
399        top.addWidget(self.when)
400
401        self.body = QWidget()
402        self.question = QLabel()
403        self.question.setWordWrap(True)
404        self.question.setTextInteractionFlags(Qt.TextSelectableByMouse)
405        self.reasoning_button = QToolButton()
406        self.reasoning_button.setCheckable(True)
407        self.reasoning_button.setText("Show reasoning")
408        self.reasoning_button.toggled.connect(self._show_reasoning)
409        self.reasoning_button.setToolTip("What the model thought before it answered")
410        self.reasoning = QLabel()
411        self.reasoning.setWordWrap(True)
412        self.reasoning.setTextInteractionFlags(Qt.TextSelectableByMouse)
413        self.reasoning.setTextFormat(Qt.PlainText)
414        self.reasoning.hide()
415        self.answer = MarkdownView()
416        self.findings = FindingsView()
417        self.findings.clicked.connect(lambda f: monitor.finding_selected.emit(entry, f))
418        self.findings.fix.connect(lambda f: monitor.fix_requested.emit(entry, f))
419        self.summary = QLabel()
420        self.summary.setWordWrap(True)
421        self.summary.setTextInteractionFlags(Qt.TextSelectableByMouse)
422        self.edit_list = EditList(scrolling=False)
423        self.edit_list.accept_requested.connect(lambda i: monitor.accept_requested.emit(entry, i))
424        self.edit_list.reject_requested.connect(lambda i: monitor.reject_requested.emit(entry, i))
425        self.edit_list.accept_all_requested.connect(lambda: monitor.accept_all_requested.emit(entry))
426        self.edit_list.reject_all_requested.connect(lambda: monitor.reject_all_requested.emit(entry))
427        self.edit_list.edit_clicked.connect(lambda i: monitor.edit_clicked.emit(entry, i))
428        self.edit_list.hide()
429        self.footer = QLabel()
430        body = QVBoxLayout(self.body)
431        body.setContentsMargins(22, 4, 0, 0)
432        body.setSpacing(6)
433        for widget in (self.question, self.reasoning_button, self.reasoning, self.answer, self.findings,
434                       self.summary, self.edit_list, self.footer):
435            body.addWidget(widget)
436        layout = QVBoxLayout(self)
437        layout.setContentsMargins(10, 8, 10, 8)
438        layout.setSpacing(4)
439        layout.addWidget(self.header)
440        layout.addWidget(self.body)
441        self.refresh()
442
443    def _show_reasoning(self, on: bool):
444        self.reasoning_button.setText("Hide reasoning" if on else "Show reasoning")
445        self.reasoning.setText(self.entry.reasoning.strip())
446        self.reasoning.setVisible(on)
447
448    def set_expanded(self, on: bool):
449        self.expanded = on
450        self.refresh()
451
452    def refresh(self, file_text: str | None = None):
453        """Show the entry as it is now (``file_text``: its file's text, for
454        the edit cards' diffs)."""
455        e, pal = self.entry, self.monitor.palette_colors
456        muted = pal["muted"]
457        label, color = KINDS.get(e.kind, (e.kind, muted))
458        self.kind.setText(f"<b style='color:{color}'>{label}</b>")
459        self.chevron.setText("▾" if self.expanded else "▸")
460        self.title.setText(self._title(muted))
461        self.when.setText(f"<span style='color:{muted}'>{e.started}</span>")
462        self.show_button.setVisible(bool(e.findings) and e.path is not None)
463        self.copy_button.setVisible(self.expanded and (bool(e.answer) or bool(e.findings)))
464        self.body.setVisible(self.expanded)
465        if not self.expanded:
466            return
467        if e.kind == "review":
468            question = f"<span style='color:{muted}'>{_esc(e.question)}</span>"
469            if e.status == RUNNING and not e.findings:
470                question += f"<br><i style='color:{muted}'>Findings appear here as the model writes them.</i>"
471            elif e.status == DONE and not e.findings:
472                question += "<br><i>No specific findings.</i>"
473        elif e.kind == "explain":
474            question = f"<b>{_esc(e.question)}</b>"
475        else:
476            question = f"<b>You:</b> {_esc(e.question)}"
477        self.question.setText(question)
478        self.reasoning_button.setVisible(bool(e.reasoning))
479        if e.reasoning and self.reasoning.isVisibleTo(self.body):
480            self.reasoning.setText(e.reasoning.strip())
481        self.reasoning.setStyleSheet(f"color: {muted};")
482        if e.kind == "review":
483            text = ""
484        elif e.kind == "explain":
485            text = e.answer
486        else:
487            text = answer_display(e.answer)
488        if e.status == FAILED and not e.answer and e.kind != "review":
489            text = f"*{e.note}*"
490        self.answer.setVisible(bool(text))
491        self.answer.set_markdown(text)
492        self.findings.show_findings(e.findings, pal, self.monitor.is_light)
493        self.summary.setText(f"<b>Summary:</b> {_esc(e.summary)}" if e.summary else "")
494        self.summary.setVisible(bool(e.summary))
495        if e.edits and file_text is not None:
496            self.edit_list.set_palette(pal, self.monitor.is_light)
497            self.edit_list.show_edits(e.edits, file_text, e.where, monospace_font(editor=True).family())
498        self.edit_list.setVisible(bool(e.edits))
499        note = e.note if e.status != RUNNING else ""
500        if e.status == FAILED and e.kind != "review" and not e.answer:
501            note = ""  # (said above)
502        self.footer.setText(f"<span style='color:{'#e03131' if e.status == FAILED else muted}'>"
503                            f"{_esc(note)}</span>" if note else "")
504        self.footer.setVisible(bool(note))
505
506    def _title(self, muted: str) -> str:
507        e = self.entry
508        parts = []
509        if e.kind in ("ask", "edit", "explain"):
510            question = e.question if len(e.question) <= 80 else e.question[:77] + "…"
511            parts.append(f"“{_esc(question)}”")
512        if e.where:
513            parts.append(_esc(e.where))
514        if e.kind == "review" and e.question:
515            parts.append(_esc(e.question.lower()))
516        status = ""
517        if e.status == RUNNING:
518            status = "running…"
519        elif e.status == FAILED:
520            status = "failed" if "stopped" not in e.note.lower() else "stopped"
521        elif e.kind == "review" or e.findings:
522            status = f"{len(e.findings)} finding{'s' * (len(e.findings) != 1)}"
523        if e.edits:
524            applied = sum(x.status == ed.APPLIED for x in e.edits)
525            pending = e.pending_edits()
526            status = ", ".join(s for s in (status if e.status != DONE else "",
527                                           f"{applied} of {len(e.edits)} applied",
528                                           f"{pending} pending" if pending else "") if s)
529        text = " · ".join(parts)
530        return text + (f" <span style='color:{muted}'>· {status}</span>" if status else "")

One entry of the timeline: a header (click to fold or open it) and what it holds.

EntryWidget( entry: Entry, monitor: ReviewMonitor)
368    def __init__(self, entry: Entry, monitor: ReviewMonitor):
369        super().__init__()
370        self.entry, self.monitor = entry, monitor
371        self.expanded = True
372        self.setObjectName("Entry")
373        self.header = QWidget()
374        self.header.setCursor(Qt.PointingHandCursor)
375        self.header.mousePressEvent = lambda event: self.set_expanded(not self.expanded) \
376            if event.button() == Qt.LeftButton else None
377        self.kind = QLabel()
378        self.title = QLabel()
379        self.title.setTextFormat(Qt.RichText)
380        self.title.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
381        self.when = QLabel()
382        self.show_button = QToolButton()
383        self.show_button.setText("Show in Editor")
384        self.show_button.setToolTip("Mark these findings in the editor again")
385        self.show_button.clicked.connect(lambda: monitor.show_in_editor_requested.emit(entry))
386        self.copy_button = QToolButton()
387        self.copy_button.setText("Copy")
388        self.copy_button.setToolTip("Copy this entry (Markdown)")
389        self.copy_button.clicked.connect(lambda: QApplication.clipboard().setText(entry.markdown()))
390        self.chevron = QLabel()
391        top = QHBoxLayout(self.header)
392        top.setContentsMargins(0, 0, 0, 0)
393        top.setSpacing(8)
394        top.addWidget(self.chevron)
395        top.addWidget(self.kind)
396        top.addWidget(self.title, 1)
397        top.addWidget(self.show_button)
398        top.addWidget(self.copy_button)
399        top.addWidget(self.when)
400
401        self.body = QWidget()
402        self.question = QLabel()
403        self.question.setWordWrap(True)
404        self.question.setTextInteractionFlags(Qt.TextSelectableByMouse)
405        self.reasoning_button = QToolButton()
406        self.reasoning_button.setCheckable(True)
407        self.reasoning_button.setText("Show reasoning")
408        self.reasoning_button.toggled.connect(self._show_reasoning)
409        self.reasoning_button.setToolTip("What the model thought before it answered")
410        self.reasoning = QLabel()
411        self.reasoning.setWordWrap(True)
412        self.reasoning.setTextInteractionFlags(Qt.TextSelectableByMouse)
413        self.reasoning.setTextFormat(Qt.PlainText)
414        self.reasoning.hide()
415        self.answer = MarkdownView()
416        self.findings = FindingsView()
417        self.findings.clicked.connect(lambda f: monitor.finding_selected.emit(entry, f))
418        self.findings.fix.connect(lambda f: monitor.fix_requested.emit(entry, f))
419        self.summary = QLabel()
420        self.summary.setWordWrap(True)
421        self.summary.setTextInteractionFlags(Qt.TextSelectableByMouse)
422        self.edit_list = EditList(scrolling=False)
423        self.edit_list.accept_requested.connect(lambda i: monitor.accept_requested.emit(entry, i))
424        self.edit_list.reject_requested.connect(lambda i: monitor.reject_requested.emit(entry, i))
425        self.edit_list.accept_all_requested.connect(lambda: monitor.accept_all_requested.emit(entry))
426        self.edit_list.reject_all_requested.connect(lambda: monitor.reject_all_requested.emit(entry))
427        self.edit_list.edit_clicked.connect(lambda i: monitor.edit_clicked.emit(entry, i))
428        self.edit_list.hide()
429        self.footer = QLabel()
430        body = QVBoxLayout(self.body)
431        body.setContentsMargins(22, 4, 0, 0)
432        body.setSpacing(6)
433        for widget in (self.question, self.reasoning_button, self.reasoning, self.answer, self.findings,
434                       self.summary, self.edit_list, self.footer):
435            body.addWidget(widget)
436        layout = QVBoxLayout(self)
437        layout.setContentsMargins(10, 8, 10, 8)
438        layout.setSpacing(4)
439        layout.addWidget(self.header)
440        layout.addWidget(self.body)
441        self.refresh()

__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, frameShape: PySide6.QtWidgets.QFrame.Shape | None = None, frameShadow: PySide6.QtWidgets.QFrame.Shadow | None = None, lineWidth: int | None = None, midLineWidth: int | None = None, frameWidth: int | None = None, frameRect: PySide6.QtCore.QRect | None = None) -> None

Initialize self. See help(type(self)) for accurate signature.

expanded
header
kind
title
when
show_button
copy_button
chevron
body
question
reasoning_button
reasoning
answer
findings
summary
edit_list
footer
def set_expanded(self, on: bool):
448    def set_expanded(self, on: bool):
449        self.expanded = on
450        self.refresh()
def refresh(self, file_text: str | None = None):
452    def refresh(self, file_text: str | None = None):
453        """Show the entry as it is now (``file_text``: its file's text, for
454        the edit cards' diffs)."""
455        e, pal = self.entry, self.monitor.palette_colors
456        muted = pal["muted"]
457        label, color = KINDS.get(e.kind, (e.kind, muted))
458        self.kind.setText(f"<b style='color:{color}'>{label}</b>")
459        self.chevron.setText("▾" if self.expanded else "▸")
460        self.title.setText(self._title(muted))
461        self.when.setText(f"<span style='color:{muted}'>{e.started}</span>")
462        self.show_button.setVisible(bool(e.findings) and e.path is not None)
463        self.copy_button.setVisible(self.expanded and (bool(e.answer) or bool(e.findings)))
464        self.body.setVisible(self.expanded)
465        if not self.expanded:
466            return
467        if e.kind == "review":
468            question = f"<span style='color:{muted}'>{_esc(e.question)}</span>"
469            if e.status == RUNNING and not e.findings:
470                question += f"<br><i style='color:{muted}'>Findings appear here as the model writes them.</i>"
471            elif e.status == DONE and not e.findings:
472                question += "<br><i>No specific findings.</i>"
473        elif e.kind == "explain":
474            question = f"<b>{_esc(e.question)}</b>"
475        else:
476            question = f"<b>You:</b> {_esc(e.question)}"
477        self.question.setText(question)
478        self.reasoning_button.setVisible(bool(e.reasoning))
479        if e.reasoning and self.reasoning.isVisibleTo(self.body):
480            self.reasoning.setText(e.reasoning.strip())
481        self.reasoning.setStyleSheet(f"color: {muted};")
482        if e.kind == "review":
483            text = ""
484        elif e.kind == "explain":
485            text = e.answer
486        else:
487            text = answer_display(e.answer)
488        if e.status == FAILED and not e.answer and e.kind != "review":
489            text = f"*{e.note}*"
490        self.answer.setVisible(bool(text))
491        self.answer.set_markdown(text)
492        self.findings.show_findings(e.findings, pal, self.monitor.is_light)
493        self.summary.setText(f"<b>Summary:</b> {_esc(e.summary)}" if e.summary else "")
494        self.summary.setVisible(bool(e.summary))
495        if e.edits and file_text is not None:
496            self.edit_list.set_palette(pal, self.monitor.is_light)
497            self.edit_list.show_edits(e.edits, file_text, e.where, monospace_font(editor=True).family())
498        self.edit_list.setVisible(bool(e.edits))
499        note = e.note if e.status != RUNNING else ""
500        if e.status == FAILED and e.kind != "review" and not e.answer:
501            note = ""  # (said above)
502        self.footer.setText(f"<span style='color:{'#e03131' if e.status == FAILED else muted}'>"
503                            f"{_esc(note)}</span>" if note else "")
504        self.footer.setVisible(bool(note))

Show the entry as it is now (file_text: its file's text, for the edit cards' diffs).

staticMetaObject = PySide6.QtCore.QMetaObject("EntryWidget" inherits "QFrame": )
class Divider(PySide6.QtWidgets.QLabel):
533class Divider(QLabel):
534    def __init__(self):
535        super().__init__("— New chat: the AI forgets everything above —")
536        self.setAlignment(Qt.AlignCenter)
537        self.entry = Entry("divider", status=DONE)
538
539    def refresh(self, _file_text=None):
540        self.setStyleSheet(f"color: {PALETTES['light' if app_is_light() else 'dark']['muted']};")

QLabel(self, text: str, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, textFormat: PySide6.QtCore.Qt.TextFormat | None = None, pixmap: PySide6.QtGui.QPixmap | None = None, scaledContents: bool | None = None, alignment: PySide6.QtCore.Qt.AlignmentFlag | None = None, wordWrap: bool | None = None, margin: int | None = None, indent: int | None = None, openExternalLinks: bool | None = None, textInteractionFlags: PySide6.QtCore.Qt.TextInteractionFlag | None = None, hasSelectedText: bool | None = None, selectedText: str | None = None) -> None QLabel(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, text: str | None = None, textFormat: PySide6.QtCore.Qt.TextFormat | None = None, pixmap: PySide6.QtGui.QPixmap | None = None, scaledContents: bool | None = None, alignment: PySide6.QtCore.Qt.AlignmentFlag | None = None, wordWrap: bool | None = None, margin: int | None = None, indent: int | None = None, openExternalLinks: bool | None = None, textInteractionFlags: PySide6.QtCore.Qt.TextInteractionFlag | None = None, hasSelectedText: bool | None = None, selectedText: str | None = None) -> None

Divider()
534    def __init__(self):
535        super().__init__("— New chat: the AI forgets everything above —")
536        self.setAlignment(Qt.AlignCenter)
537        self.entry = Entry("divider", status=DONE)

__init__(self, text: str, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, textFormat: PySide6.QtCore.Qt.TextFormat | None = None, pixmap: PySide6.QtGui.QPixmap | None = None, scaledContents: bool | None = None, alignment: PySide6.QtCore.Qt.AlignmentFlag | None = None, wordWrap: bool | None = None, margin: int | None = None, indent: int | None = None, openExternalLinks: bool | None = None, textInteractionFlags: PySide6.QtCore.Qt.TextInteractionFlag | None = None, hasSelectedText: bool | None = None, selectedText: str | None = None) -> None __init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, text: str | None = None, textFormat: PySide6.QtCore.Qt.TextFormat | None = None, pixmap: PySide6.QtGui.QPixmap | None = None, scaledContents: bool | None = None, alignment: PySide6.QtCore.Qt.AlignmentFlag | None = None, wordWrap: bool | None = None, margin: int | None = None, indent: int | None = None, openExternalLinks: bool | None = None, textInteractionFlags: PySide6.QtCore.Qt.TextInteractionFlag | None = None, hasSelectedText: bool | None = None, selectedText: str | None = None) -> None

Initialize self. See help(type(self)) for accurate signature.

entry
def refresh(self, _file_text=None):
539    def refresh(self, _file_text=None):
540        self.setStyleSheet(f"color: {PALETTES['light' if app_is_light() else 'dark']['muted']};")
staticMetaObject = PySide6.QtCore.QMetaObject("Divider" inherits "QLabel": )
class ReviewMonitor(PySide6.QtWidgets.QWidget):
543class ReviewMonitor(QWidget):
544    """The timeline, the progress of what's running (and Stop), and the box
545    for questions (Ask) and changes (Edit). It emits what the user does."""
546
547    stop_requested = Signal()
548    chat_requested = Signal(str, bool, str)  # question, send the open file along, "ask" or "edit"
549    new_chat_requested = Signal()
550    clear_requested = Signal()
551    selection_cleared = Signal()
552    finding_selected = Signal(object, object)  # entry, finding
553    fix_requested = Signal(object, object)     # entry, finding
554    show_in_editor_requested = Signal(object)  # entry
555    accept_requested = Signal(object, int)     # entry, edit's index
556    reject_requested = Signal(object, int)
557    accept_all_requested = Signal(object)
558    reject_all_requested = Signal(object)
559    edit_clicked = Signal(object, int)
560
561    def __init__(self, parent=None):
562        super().__init__(parent)
563        self.entries: list[Entry] = []
564        self.widgets: list[EntryWidget | Divider] = []
565        self.current: Entry | None = None  # the one running
566        self.filter = "all"
567        self.context_note = ""  # how full the model's context is ("context ~14k of 32k")
568        self.is_light = True
569        self.palette_colors = PALETTES["light"]
570        self._started = 0.0
571        self._tokens = 0
572        self._status = ""
573        self._follow = True  # (keep the newest in view, unless scrolled up)
574
575        self.setObjectName("ReviewMonitor")
576        self.setAttribute(Qt.WA_StyledBackground, True)
577        self.title_icon = TitleIcon("robot")
578        self.title = QLabel("AI")
579        self.subtitle = StatusLabel()
580        header = QHBoxLayout()
581        header.setSpacing(6)
582        header.addWidget(self.title_icon)
583        header.addWidget(self.title)
584        header.addSpacing(4)
585        header.addWidget(self.subtitle)
586        header.addStretch(1)
587        self.filter_buttons: dict[str, QPushButton] = {}
588        group = QButtonGroup(self)
589        for key, label in FILTERS.items():
590            button = QPushButton(label)
591            button.setCheckable(True)
592            button.setObjectName("Filter")
593            button.clicked.connect(lambda _=False, k=key: self.set_filter(k))
594            group.addButton(button)
595            header.addWidget(button)
596            self.filter_buttons[key] = button
597        self.filter_buttons["all"].setChecked(True)
598        self.clear_button = QPushButton("Clear")
599        self.clear_button.setToolTip("Empty the timeline (the AI forgets it too)")
600        self.clear_button.clicked.connect(self.clear_requested)
601        header.addSpacing(6)
602        header.addWidget(self.clear_button)
603
604        self.progress = ProgressLine(self)
605        self.progress_label = StatusLabel()
606        self.stop_button = QPushButton("Stop")
607        self.stop_button.setToolTip("Stop the model")
608        self.stop_button.clicked.connect(self.stop_requested)
609        self.stop_button.hide()
610        progress_row = QHBoxLayout()
611        progress_row.setSpacing(10)
612        progress_row.addWidget(self.progress, 1)
613        progress_row.addWidget(self.progress_label)
614        progress_row.addWidget(self.stop_button)
615
616        self.timeline = QWidget()
617        self.timeline.setObjectName("Timeline")
618        self.timeline_layout = QVBoxLayout(self.timeline)
619        self.timeline_layout.setContentsMargins(0, 0, 4, 0)
620        self.timeline_layout.setSpacing(6)
621        self.empty = QLabel("Everything the AI does shows here: reviews (AI → Review Current File), "
622                            "explanations (Explain with AI), and questions and edits from the box below.")
623        self.empty.setAlignment(Qt.AlignCenter)
624        self.empty.setWordWrap(True)
625        self.timeline_layout.addWidget(self.empty)
626        self.timeline_layout.addStretch(1)
627        self.scroll = QScrollArea()
628        self.scroll.setWidgetResizable(True)
629        self.scroll.setFrameShape(QFrame.NoFrame)
630        self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
631        self.scroll.setWidget(self.timeline)
632        bar = self.scroll.verticalScrollBar()
633        bar.rangeChanged.connect(lambda _lo, hi: bar.setValue(hi) if self._follow else None)
634        bar.valueChanged.connect(lambda value: setattr(self, "_follow", value >= bar.maximum() - 4))
635
636        # The box for questions and changes.
637        self.chat_kind = "ask"
638        self.ask_button = QPushButton("Ask")
639        self.edit_button = QPushButton("Edit")
640        self.ask_button.setToolTip("Ask about Fortran, fpm, the open file, or anything above")
641        self.edit_button.setToolTip("Ask for a change to the open file: you accept or reject each edit")
642        kinds = QButtonGroup(self)
643        for button, kind in ((self.ask_button, "ask"), (self.edit_button, "edit")):
644            button.setCheckable(True)
645            button.setObjectName("ChatKind")
646            kinds.addButton(button)
647            button.clicked.connect(lambda _=False, k=kind: self.set_chat_kind(k))
648        self.ask_button.setChecked(True)
649        self.selection_note = QPushButton()  # (Edit Selection with AI: which lines; click to drop)
650        self.selection_note.setToolTip("Only these lines are to change (click to drop the selection)")
651        self.selection_note.clicked.connect(lambda: (self.set_selection_note(""), self.selection_cleared.emit()))
652        self.selection_note.hide()
653        self.chat_edit = QLineEdit()
654        self.chat_edit.setPlaceholderText("Ask the AI about Fortran, fpm or the open file… (Return to send)")
655        self.chat_edit.setClearButtonEnabled(True)
656        self.chat_edit.returnPressed.connect(self._send_chat)
657        self.chat_edit.installEventFilter(self)  # (Up/Down: earlier questions)
658        self.asked: list[str] = []  # questions asked, oldest first (this run of the app only)
659        self._recall = 0            # where Up/Down are in them (len(asked): the text being typed)
660        self._draft = ""            # what was being typed before Up
661        self.chat_file_check = QCheckBox("Send the open file")
662        self.chat_file_check.setChecked(True)
663        self.chat_file_check.setToolTip("Send the file open in the editor along with the question, so the "
664                                        "answer can be about it (lines it points to are listed and marked "
665                                        "in the editor)")
666        self.send_button = QPushButton("Send")
667        self.send_button.clicked.connect(self._send_chat)
668        self.new_chat_button = QPushButton("New Chat")
669        self.new_chat_button.setToolTip("The AI forgets everything so far (it stays in the timeline)")
670        self.new_chat_button.clicked.connect(self.new_chat_requested)
671        chat_row = QHBoxLayout()
672        chat_row.setSpacing(8)
673        chat_row.addWidget(self.ask_button)
674        chat_row.addWidget(self.edit_button)
675        chat_row.addSpacing(4)
676        chat_row.addWidget(self.selection_note)
677        chat_row.addWidget(self.chat_edit, 1)
678        chat_row.addWidget(self.chat_file_check)
679        chat_row.addWidget(self.send_button)
680        chat_row.addWidget(self.new_chat_button)
681
682        layout = QVBoxLayout(self)
683        layout.setContentsMargins(14, 10, 14, 6)
684        layout.setSpacing(8)
685        layout.addLayout(header)
686        layout.addLayout(progress_row)
687        layout.addWidget(self.scroll, 1)
688        layout.addLayout(chat_row)
689
690        # While the model writes, the running entry is shown again at most
691        # this often (each time is the whole answer, as Markdown, its code
692        # highlighted: on every piece, a long answer froze the window).
693        self._render_timer = QTimer(self)
694        self._render_timer.setSingleShot(True)
695        self._render_timer.timeout.connect(self._render_current)
696        self._render_cost = 0.0  # seconds the last showing took
697        self._timer = QTimer(self)
698        self._timer.setInterval(50)
699        self._timer.timeout.connect(self._update_label)
700        self.refresh_fonts()
701
702    # ------------------------------------------------------------- styling
703    def refresh_palette(self):
704        self.is_light = app_is_light()
705        self.palette_colors = pal = PALETTES["light" if self.is_light else "dark"]
706        accent = "#1f6feb" if self.is_light else "#388bfd"
707        self.setStyleSheet(
708            panel_stylesheet(self.objectName(), pal)
709            + f"""
710            #ReviewMonitor QScrollArea, #Timeline {{ background: {pal['bg']}; border: none; }}
711            #Entry {{ border: 1px solid {pal['line']}; border-radius: 7px; background: {pal['bg']}; }}
712            #FindingRow:hover {{ background: {pal['hover']}; border-radius: 4px; }}
713            QPushButton#ChatKind:checked, QPushButton#Filter:checked {{ background: {accent}; color: white;
714                border: 1px solid {accent}; border-radius: 5px; padding: 3px 10px; }}
715            """)
716        self.title_icon.draw(pal["text"], self.title)
717        self.subtitle.setStyleSheet(f"color: {pal['muted']};")
718        self.empty.setStyleSheet(f"color: {pal['muted']};")
719        for widget in self.widgets:
720            if isinstance(widget, EntryWidget):
721                widget.answer.restyle()  # (code in the editor's colors, which may have changed)
722            self._refresh(widget)
723        self.progress.update()
724
725    def refresh_fonts(self):
726        title_font = QFont(QApplication.font())
727        title_font.setPointSizeF(title_font.pointSizeF() + 2)
728        title_font.setWeight(QFont.Bold)
729        self.title.setFont(title_font)
730        self.refresh_palette()
731
732    # ------------------------------------------------------------ entries
733    @property
734    def running(self) -> bool:
735        return self.current is not None
736
737    def add_entry(self, entry: Entry) -> Entry:
738        """A new action, at the bottom (the older ones fold); it's running."""
739        for widget in self.widgets:
740            if isinstance(widget, EntryWidget) and widget.expanded:
741                widget.set_expanded(False)
742        self._add_widget(EntryWidget(entry, self), entry)
743        self.current = entry
744        self.subtitle.setText(" · ".join(s for s in (entry.where, entry.model) if s))
745        self._status = "Starting"
746        self._started = time.monotonic()
747        self._tokens = 0
748        self.progress.set_busy(True)
749        self.stop_button.setEnabled(False)  # (until there's a model to stop: see set_stoppable)
750        self.stop_button.show()
751        self._timer.start()
752        self._update_label()
753        return entry
754
755    def add_divider(self):
756        """New Chat: a line across the timeline."""
757        divider = Divider()
758        self._add_widget(divider, divider.entry)
759
760    def _add_widget(self, widget, entry: Entry):
761        self._follow = True
762        self.entries.append(entry)
763        self.widgets.append(widget)
764        self.timeline_layout.insertWidget(self.timeline_layout.count() - 1, widget)
765        self.empty.hide()
766        self._apply_filter(widget)
767        self._refresh(widget)
768
769    def widget_for(self, entry: Entry) -> EntryWidget | None:
770        return next((w for w in self.widgets if w.entry is entry), None)
771
772    def refresh(self, entry: Entry, file_text: str | None = None):
773        """Show an entry as it is now (``file_text``: its file's text, for
774        its edit cards)."""
775        widget = self.widget_for(entry)
776        if widget is not None:
777            self._refresh(widget, file_text)
778
779    def _refresh(self, widget, file_text: str | None = None):
780        widget.refresh(file_text)
781        self._apply_filter(widget)
782
783    def set_status(self, status: str, fraction: float = -1.0):
784        """What the running entry is doing: a status text, or a download's
785        progress (fraction 0..1)."""
786        self._status = status
787        if fraction >= 0:
788            self.progress.set(fraction, "running")
789        else:
790            self.progress.set_busy(True)
791        self._update_label()
792
793    def add_answer(self, text: str):
794        """More of the running entry's answer, as the model writes it."""
795        entry = self.current
796        if entry is None:
797            return
798        self._tokens += 1
799        word = {"review": "Reviewing", "explain": "Explaining"}.get(entry.kind, "Answering")
800        if self._status != word:
801            self._status = word
802            self._update_label()
803        entry.answer += text
804        self._render_soon()
805
806    RENDER_INTERVAL = 0.15  # seconds, at least, between showings of the answer being written
807
808    def refresh_soon(self, entry: Entry):
809        """Show an entry again: the running one with the next showing (see
810        _render_soon: what the model writes comes in many small pieces),
811        any other one now."""
812        if entry is self.current:
813            self._render_soon()
814        else:
815            self.refresh(entry)
816
817    def _render_soon(self):
818        if not self._render_timer.isActive():
819            # (Twice what the last showing took, if more: the window stays responsive.)
820            self._render_timer.start(int(1000 * max(self.RENDER_INTERVAL, 2 * self._render_cost)))
821
822    def _render_current(self):
823        if self.current is not None:
824            started = time.monotonic()
825            self.refresh(self.current)
826            # Laid out now, not later: the time it takes counts, so a costly
827            # showing makes the next one wait longer and the window stays free.
828            # Only this panel's timeline: laying out the whole app here (all
829            # posted layout requests) cut into other windows' changes, a panel
830            # docked again by double-clicking its title left half placed.
831            widget = self.widget_for(self.current)
832            if widget is not None:
833                widget.layout().activate()
834            self.timeline_layout.activate()
835            self._render_cost = time.monotonic() - started
836
837    def add_reasoning(self, text: str):
838        """More of a thinking model's reasoning (before its answer)."""
839        entry = self.current
840        if entry is None:
841            return
842        self._tokens += 1
843        if self._status != "Reasoning":
844            self._status = "Reasoning"
845            self._update_label()
846        entry.reasoning += text
847        self._render_soon()
848
849    def finish(self, ok: bool, note: str = ""):
850        """The running entry is done (or failed: ``note`` says why)."""
851        entry = self.current
852        self.current = None
853        self._render_timer.stop()  # (shown in full just below)
854        self._render_cost = 0.0
855        self._timer.stop()
856        self.stop_button.hide()
857        self.progress.set(1.0, "ok" if ok else "failed")
858        self._status = note
859        if entry is not None:
860            entry.status = DONE if ok else FAILED
861            entry.note = note
862            self.refresh(entry)
863        self._update_label()
864
865    def set_stoppable(self, on: bool):
866        """The model is working (Stop can end it), or not."""
867        self.stop_button.setEnabled(on)
868
869    def clear(self):
870        """An empty timeline."""
871        for widget in self.widgets:
872            widget.deleteLater()
873        self.entries, self.widgets, self.current = [], [], None
874        self._render_timer.stop()
875        self._timer.stop()
876        self.stop_button.hide()
877        self.progress.set(0, "running")
878        self._status = ""
879        self.context_note = ""
880        self.subtitle.clear()
881        self.empty.show()
882        self._update_label()
883
884    def reset(self):
885        """Back to the empty state (e.g. when another project is opened)."""
886        self.clear()
887
888    # ------------------------------------------------------------- filter
889    def set_filter(self, key: str):
890        self.filter = key
891        self.filter_buttons[key].setChecked(True)
892        for widget in self.widgets:
893            self._apply_filter(widget)
894
895    def _apply_filter(self, widget):
896        e, key = widget.entry, self.filter
897        shown = (key == "all" or (key == "chat" and e.kind in ("ask", "edit"))
898                 or (key == "pending" and e.pending_edits() > 0) or e.kind == key)
899        widget.setVisible(shown)
900
901    # --------------------------------------------------------------- chat
902    def set_sending(self, on: bool):
903        """Questions can be sent (not while the model is busy: the question
904        stays in the box until it's done)."""
905        self.send_button.setEnabled(on)
906        self.send_button.setToolTip("" if on else "The AI is busy: wait for its answer, or Stop it")
907
908    def _send_chat(self):
909        question = self.chat_edit.text().strip()
910        if question and self.send_button.isEnabled():
911            if not self.asked or self.asked[-1] != question:
912                self.asked.append(question)
913            self._recall, self._draft = len(self.asked), ""
914            self.chat_requested.emit(question, self.chat_file_check.isChecked(), self.chat_kind)
915
916    def set_chat_kind(self, kind: str):
917        """"ask" or "edit" (which always sends the open file)."""
918        self.chat_kind = kind
919        editing = kind == "edit"
920        (self.edit_button if editing else self.ask_button).setChecked(True)
921        if editing:
922            self._file_wanted = self.chat_file_check.isChecked()
923            self.chat_file_check.setChecked(True)
924        elif not self.chat_file_check.isEnabled():
925            self.chat_file_check.setChecked(getattr(self, "_file_wanted", True))
926        self.chat_file_check.setEnabled(not editing)
927        self.chat_edit.setPlaceholderText(
928            "Describe a change to the open file… (Return to send)" if editing
929            else "Ask the AI about Fortran, fpm or the open file… (Return to send)")
930        if not editing:
931            self.set_selection_note("")
932
933    def set_selection_note(self, text: str):
934        self.selection_note.setText(f"{text}  ✕" if text else "")
935        self.selection_note.setVisible(bool(text))
936
937    def _recall_question(self, step: int) -> bool:
938        """Up (-1) / Down (+1): the previous / next question asked, as in a
939        shell; past the newest, what was being typed. False at either end."""
940        position = self._recall + step
941        if not 0 <= position <= len(self.asked):
942            return False
943        if self._recall == len(self.asked):
944            self._draft = self.chat_edit.text()
945        self._recall = position
946        self.chat_edit.setText(self.asked[position] if position < len(self.asked) else self._draft)
947        return True
948
949    def eventFilter(self, obj, event):
950        # (macOS marks arrow keys with KeypadModifier: only real modifiers count.)
951        if obj is self.chat_edit and event.type() == QEvent.KeyPress \
952                and not event.modifiers() & ~Qt.KeypadModifier and event.key() in (Qt.Key_Up, Qt.Key_Down):
953            self._recall_question(-1 if event.key() == Qt.Key_Up else 1)
954            return True
955        return super().eventFilter(obj, event)
956
957    # ------------------------------------------------------------- display
958    def _update_label(self):
959        pal = self.palette_colors
960        muted = f"<span style='color:{pal['muted']}'>"
961        context = f" · {self.context_note}" if self.context_note else ""
962        if self.running:
963            seconds = time.monotonic() - self._started
964            extra = f" · {self._tokens} token{'s' * (self._tokens != 1)}" if self._tokens else ""
965            text = f"<b>{self._status or 'Working'}…</b> {muted}{seconds:.0f} s{extra}{context}</span>"
966        elif self._status:
967            text = f"{muted}{_esc(self._status)}{context}</span>"
968        else:
969            text = ""
970        self.progress_label.setText(text)

The timeline, the progress of what's running (and Stop), and the box for questions (Ask) and changes (Edit). It emits what the user does.

ReviewMonitor(parent=None)
561    def __init__(self, parent=None):
562        super().__init__(parent)
563        self.entries: list[Entry] = []
564        self.widgets: list[EntryWidget | Divider] = []
565        self.current: Entry | None = None  # the one running
566        self.filter = "all"
567        self.context_note = ""  # how full the model's context is ("context ~14k of 32k")
568        self.is_light = True
569        self.palette_colors = PALETTES["light"]
570        self._started = 0.0
571        self._tokens = 0
572        self._status = ""
573        self._follow = True  # (keep the newest in view, unless scrolled up)
574
575        self.setObjectName("ReviewMonitor")
576        self.setAttribute(Qt.WA_StyledBackground, True)
577        self.title_icon = TitleIcon("robot")
578        self.title = QLabel("AI")
579        self.subtitle = StatusLabel()
580        header = QHBoxLayout()
581        header.setSpacing(6)
582        header.addWidget(self.title_icon)
583        header.addWidget(self.title)
584        header.addSpacing(4)
585        header.addWidget(self.subtitle)
586        header.addStretch(1)
587        self.filter_buttons: dict[str, QPushButton] = {}
588        group = QButtonGroup(self)
589        for key, label in FILTERS.items():
590            button = QPushButton(label)
591            button.setCheckable(True)
592            button.setObjectName("Filter")
593            button.clicked.connect(lambda _=False, k=key: self.set_filter(k))
594            group.addButton(button)
595            header.addWidget(button)
596            self.filter_buttons[key] = button
597        self.filter_buttons["all"].setChecked(True)
598        self.clear_button = QPushButton("Clear")
599        self.clear_button.setToolTip("Empty the timeline (the AI forgets it too)")
600        self.clear_button.clicked.connect(self.clear_requested)
601        header.addSpacing(6)
602        header.addWidget(self.clear_button)
603
604        self.progress = ProgressLine(self)
605        self.progress_label = StatusLabel()
606        self.stop_button = QPushButton("Stop")
607        self.stop_button.setToolTip("Stop the model")
608        self.stop_button.clicked.connect(self.stop_requested)
609        self.stop_button.hide()
610        progress_row = QHBoxLayout()
611        progress_row.setSpacing(10)
612        progress_row.addWidget(self.progress, 1)
613        progress_row.addWidget(self.progress_label)
614        progress_row.addWidget(self.stop_button)
615
616        self.timeline = QWidget()
617        self.timeline.setObjectName("Timeline")
618        self.timeline_layout = QVBoxLayout(self.timeline)
619        self.timeline_layout.setContentsMargins(0, 0, 4, 0)
620        self.timeline_layout.setSpacing(6)
621        self.empty = QLabel("Everything the AI does shows here: reviews (AI → Review Current File), "
622                            "explanations (Explain with AI), and questions and edits from the box below.")
623        self.empty.setAlignment(Qt.AlignCenter)
624        self.empty.setWordWrap(True)
625        self.timeline_layout.addWidget(self.empty)
626        self.timeline_layout.addStretch(1)
627        self.scroll = QScrollArea()
628        self.scroll.setWidgetResizable(True)
629        self.scroll.setFrameShape(QFrame.NoFrame)
630        self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
631        self.scroll.setWidget(self.timeline)
632        bar = self.scroll.verticalScrollBar()
633        bar.rangeChanged.connect(lambda _lo, hi: bar.setValue(hi) if self._follow else None)
634        bar.valueChanged.connect(lambda value: setattr(self, "_follow", value >= bar.maximum() - 4))
635
636        # The box for questions and changes.
637        self.chat_kind = "ask"
638        self.ask_button = QPushButton("Ask")
639        self.edit_button = QPushButton("Edit")
640        self.ask_button.setToolTip("Ask about Fortran, fpm, the open file, or anything above")
641        self.edit_button.setToolTip("Ask for a change to the open file: you accept or reject each edit")
642        kinds = QButtonGroup(self)
643        for button, kind in ((self.ask_button, "ask"), (self.edit_button, "edit")):
644            button.setCheckable(True)
645            button.setObjectName("ChatKind")
646            kinds.addButton(button)
647            button.clicked.connect(lambda _=False, k=kind: self.set_chat_kind(k))
648        self.ask_button.setChecked(True)
649        self.selection_note = QPushButton()  # (Edit Selection with AI: which lines; click to drop)
650        self.selection_note.setToolTip("Only these lines are to change (click to drop the selection)")
651        self.selection_note.clicked.connect(lambda: (self.set_selection_note(""), self.selection_cleared.emit()))
652        self.selection_note.hide()
653        self.chat_edit = QLineEdit()
654        self.chat_edit.setPlaceholderText("Ask the AI about Fortran, fpm or the open file… (Return to send)")
655        self.chat_edit.setClearButtonEnabled(True)
656        self.chat_edit.returnPressed.connect(self._send_chat)
657        self.chat_edit.installEventFilter(self)  # (Up/Down: earlier questions)
658        self.asked: list[str] = []  # questions asked, oldest first (this run of the app only)
659        self._recall = 0            # where Up/Down are in them (len(asked): the text being typed)
660        self._draft = ""            # what was being typed before Up
661        self.chat_file_check = QCheckBox("Send the open file")
662        self.chat_file_check.setChecked(True)
663        self.chat_file_check.setToolTip("Send the file open in the editor along with the question, so the "
664                                        "answer can be about it (lines it points to are listed and marked "
665                                        "in the editor)")
666        self.send_button = QPushButton("Send")
667        self.send_button.clicked.connect(self._send_chat)
668        self.new_chat_button = QPushButton("New Chat")
669        self.new_chat_button.setToolTip("The AI forgets everything so far (it stays in the timeline)")
670        self.new_chat_button.clicked.connect(self.new_chat_requested)
671        chat_row = QHBoxLayout()
672        chat_row.setSpacing(8)
673        chat_row.addWidget(self.ask_button)
674        chat_row.addWidget(self.edit_button)
675        chat_row.addSpacing(4)
676        chat_row.addWidget(self.selection_note)
677        chat_row.addWidget(self.chat_edit, 1)
678        chat_row.addWidget(self.chat_file_check)
679        chat_row.addWidget(self.send_button)
680        chat_row.addWidget(self.new_chat_button)
681
682        layout = QVBoxLayout(self)
683        layout.setContentsMargins(14, 10, 14, 6)
684        layout.setSpacing(8)
685        layout.addLayout(header)
686        layout.addLayout(progress_row)
687        layout.addWidget(self.scroll, 1)
688        layout.addLayout(chat_row)
689
690        # While the model writes, the running entry is shown again at most
691        # this often (each time is the whole answer, as Markdown, its code
692        # highlighted: on every piece, a long answer froze the window).
693        self._render_timer = QTimer(self)
694        self._render_timer.setSingleShot(True)
695        self._render_timer.timeout.connect(self._render_current)
696        self._render_cost = 0.0  # seconds the last showing took
697        self._timer = QTimer(self)
698        self._timer.setInterval(50)
699        self._timer.timeout.connect(self._update_label)
700        self.refresh_fonts()

__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None

Initialize self. See help(type(self)) for accurate signature.

def stop_requested(unknown):

The type of the None singleton.

def chat_requested(unknown):

The type of the None singleton.

def new_chat_requested(unknown):

The type of the None singleton.

def clear_requested(unknown):

The type of the None singleton.

def selection_cleared(unknown):

The type of the None singleton.

def finding_selected(unknown):

The type of the None singleton.

def fix_requested(unknown):

The type of the None singleton.

def show_in_editor_requested(unknown):

The type of the None singleton.

def accept_requested(unknown):

The type of the None singleton.

def reject_requested(unknown):

The type of the None singleton.

def accept_all_requested(unknown):

The type of the None singleton.

def reject_all_requested(unknown):

The type of the None singleton.

def edit_clicked(unknown):

The type of the None singleton.

entries: list[Entry]
widgets: list[EntryWidget | Divider]
current: Entry | None
filter
context_note
is_light
palette_colors
title_icon
title
subtitle
filter_buttons: dict[str, PySide6.QtWidgets.QPushButton]
clear_button
progress
progress_label
stop_button
timeline
timeline_layout
empty
def scroll(unknown):

scroll(self, dx: int, dy: int, /) -> None scroll(self, dx: int, dy: int, arg__3: PySide6.QtCore.QRect, /) -> None

chat_kind
ask_button
edit_button
selection_note
chat_edit
asked: list[str]
chat_file_check
send_button
new_chat_button
def refresh_palette(self):
703    def refresh_palette(self):
704        self.is_light = app_is_light()
705        self.palette_colors = pal = PALETTES["light" if self.is_light else "dark"]
706        accent = "#1f6feb" if self.is_light else "#388bfd"
707        self.setStyleSheet(
708            panel_stylesheet(self.objectName(), pal)
709            + f"""
710            #ReviewMonitor QScrollArea, #Timeline {{ background: {pal['bg']}; border: none; }}
711            #Entry {{ border: 1px solid {pal['line']}; border-radius: 7px; background: {pal['bg']}; }}
712            #FindingRow:hover {{ background: {pal['hover']}; border-radius: 4px; }}
713            QPushButton#ChatKind:checked, QPushButton#Filter:checked {{ background: {accent}; color: white;
714                border: 1px solid {accent}; border-radius: 5px; padding: 3px 10px; }}
715            """)
716        self.title_icon.draw(pal["text"], self.title)
717        self.subtitle.setStyleSheet(f"color: {pal['muted']};")
718        self.empty.setStyleSheet(f"color: {pal['muted']};")
719        for widget in self.widgets:
720            if isinstance(widget, EntryWidget):
721                widget.answer.restyle()  # (code in the editor's colors, which may have changed)
722            self._refresh(widget)
723        self.progress.update()
def refresh_fonts(self):
725    def refresh_fonts(self):
726        title_font = QFont(QApplication.font())
727        title_font.setPointSizeF(title_font.pointSizeF() + 2)
728        title_font.setWeight(QFont.Bold)
729        self.title.setFont(title_font)
730        self.refresh_palette()
running: bool
733    @property
734    def running(self) -> bool:
735        return self.current is not None
def add_entry( self, entry: Entry) -> Entry:
737    def add_entry(self, entry: Entry) -> Entry:
738        """A new action, at the bottom (the older ones fold); it's running."""
739        for widget in self.widgets:
740            if isinstance(widget, EntryWidget) and widget.expanded:
741                widget.set_expanded(False)
742        self._add_widget(EntryWidget(entry, self), entry)
743        self.current = entry
744        self.subtitle.setText(" · ".join(s for s in (entry.where, entry.model) if s))
745        self._status = "Starting"
746        self._started = time.monotonic()
747        self._tokens = 0
748        self.progress.set_busy(True)
749        self.stop_button.setEnabled(False)  # (until there's a model to stop: see set_stoppable)
750        self.stop_button.show()
751        self._timer.start()
752        self._update_label()
753        return entry

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

def add_divider(self):
755    def add_divider(self):
756        """New Chat: a line across the timeline."""
757        divider = Divider()
758        self._add_widget(divider, divider.entry)

New Chat: a line across the timeline.

def widget_for( self, entry: Entry) -> EntryWidget | None:
769    def widget_for(self, entry: Entry) -> EntryWidget | None:
770        return next((w for w in self.widgets if w.entry is entry), None)
def refresh( self, entry: Entry, file_text: str | None = None):
772    def refresh(self, entry: Entry, file_text: str | None = None):
773        """Show an entry as it is now (``file_text``: its file's text, for
774        its edit cards)."""
775        widget = self.widget_for(entry)
776        if widget is not None:
777            self._refresh(widget, file_text)

Show an entry as it is now (file_text: its file's text, for its edit cards).

def set_status(self, status: str, fraction: float = -1.0):
783    def set_status(self, status: str, fraction: float = -1.0):
784        """What the running entry is doing: a status text, or a download's
785        progress (fraction 0..1)."""
786        self._status = status
787        if fraction >= 0:
788            self.progress.set(fraction, "running")
789        else:
790            self.progress.set_busy(True)
791        self._update_label()

What the running entry is doing: a status text, or a download's progress (fraction 0..1).

def add_answer(self, text: str):
793    def add_answer(self, text: str):
794        """More of the running entry's answer, as the model writes it."""
795        entry = self.current
796        if entry is None:
797            return
798        self._tokens += 1
799        word = {"review": "Reviewing", "explain": "Explaining"}.get(entry.kind, "Answering")
800        if self._status != word:
801            self._status = word
802            self._update_label()
803        entry.answer += text
804        self._render_soon()

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

RENDER_INTERVAL = 0.15
def refresh_soon(self, entry: Entry):
808    def refresh_soon(self, entry: Entry):
809        """Show an entry again: the running one with the next showing (see
810        _render_soon: what the model writes comes in many small pieces),
811        any other one now."""
812        if entry is self.current:
813            self._render_soon()
814        else:
815            self.refresh(entry)

Show an entry again: the running one with the next showing (see _render_soon: what the model writes comes in many small pieces), any other one now.

def add_reasoning(self, text: str):
837    def add_reasoning(self, text: str):
838        """More of a thinking model's reasoning (before its answer)."""
839        entry = self.current
840        if entry is None:
841            return
842        self._tokens += 1
843        if self._status != "Reasoning":
844            self._status = "Reasoning"
845            self._update_label()
846        entry.reasoning += text
847        self._render_soon()

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

def finish(self, ok: bool, note: str = ''):
849    def finish(self, ok: bool, note: str = ""):
850        """The running entry is done (or failed: ``note`` says why)."""
851        entry = self.current
852        self.current = None
853        self._render_timer.stop()  # (shown in full just below)
854        self._render_cost = 0.0
855        self._timer.stop()
856        self.stop_button.hide()
857        self.progress.set(1.0, "ok" if ok else "failed")
858        self._status = note
859        if entry is not None:
860            entry.status = DONE if ok else FAILED
861            entry.note = note
862            self.refresh(entry)
863        self._update_label()

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

def set_stoppable(self, on: bool):
865    def set_stoppable(self, on: bool):
866        """The model is working (Stop can end it), or not."""
867        self.stop_button.setEnabled(on)

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

def clear(self):
869    def clear(self):
870        """An empty timeline."""
871        for widget in self.widgets:
872            widget.deleteLater()
873        self.entries, self.widgets, self.current = [], [], None
874        self._render_timer.stop()
875        self._timer.stop()
876        self.stop_button.hide()
877        self.progress.set(0, "running")
878        self._status = ""
879        self.context_note = ""
880        self.subtitle.clear()
881        self.empty.show()
882        self._update_label()

An empty timeline.

def reset(self):
884    def reset(self):
885        """Back to the empty state (e.g. when another project is opened)."""
886        self.clear()

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

def set_filter(self, key: str):
889    def set_filter(self, key: str):
890        self.filter = key
891        self.filter_buttons[key].setChecked(True)
892        for widget in self.widgets:
893            self._apply_filter(widget)
def set_sending(self, on: bool):
902    def set_sending(self, on: bool):
903        """Questions can be sent (not while the model is busy: the question
904        stays in the box until it's done)."""
905        self.send_button.setEnabled(on)
906        self.send_button.setToolTip("" if on else "The AI is busy: wait for its answer, or Stop it")

Questions can be sent (not while the model is busy: the question stays in the box until it's done).

def set_chat_kind(self, kind: str):
916    def set_chat_kind(self, kind: str):
917        """"ask" or "edit" (which always sends the open file)."""
918        self.chat_kind = kind
919        editing = kind == "edit"
920        (self.edit_button if editing else self.ask_button).setChecked(True)
921        if editing:
922            self._file_wanted = self.chat_file_check.isChecked()
923            self.chat_file_check.setChecked(True)
924        elif not self.chat_file_check.isEnabled():
925            self.chat_file_check.setChecked(getattr(self, "_file_wanted", True))
926        self.chat_file_check.setEnabled(not editing)
927        self.chat_edit.setPlaceholderText(
928            "Describe a change to the open file… (Return to send)" if editing
929            else "Ask the AI about Fortran, fpm or the open file… (Return to send)")
930        if not editing:
931            self.set_selection_note("")

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

def set_selection_note(self, text: str):
933    def set_selection_note(self, text: str):
934        self.selection_note.setText(f"{text}  ✕" if text else "")
935        self.selection_note.setVisible(bool(text))
def eventFilter(self, obj, event):
949    def eventFilter(self, obj, event):
950        # (macOS marks arrow keys with KeypadModifier: only real modifiers count.)
951        if obj is self.chat_edit and event.type() == QEvent.KeyPress \
952                and not event.modifiers() & ~Qt.KeypadModifier and event.key() in (Qt.Key_Up, Qt.Key_Down):
953            self._recall_question(-1 if event.key() == Qt.Key_Up else 1)
954            return True
955        return super().eventFilter(obj, event)

eventFilter(self, watched: PySide6.QtCore.QObject, event: PySide6.QtCore.QEvent, /) -> bool

staticMetaObject = PySide6.QtCore.QMetaObject("ReviewMonitor" inherits "QWidget": Methods: #33 type=Signal, signature=stop_requested() #34 type=Signal, signature=chat_requested(QString,bool,QString), parameters=QString, bool, QString #35 type=Signal, signature=new_chat_requested() #36 type=Signal, signature=clear_requested() #37 type=Signal, signature=selection_cleared() #38 type=Signal, signature=finding_selected(PyObject,PyObject), parameters=PyObject, PyObject #39 type=Signal, signature=fix_requested(PyObject,PyObject), parameters=PyObject, PyObject #40 type=Signal, signature=show_in_editor_requested(PyObject), parameters=PyObject #41 type=Signal, signature=accept_requested(PyObject,int), parameters=PyObject, int #42 type=Signal, signature=reject_requested(PyObject,int), parameters=PyObject, int #43 type=Signal, signature=accept_all_requested(PyObject), parameters=PyObject #44 type=Signal, signature=reject_all_requested(PyObject), parameters=PyObject #45 type=Signal, signature=edit_clicked(PyObject,int), parameters=PyObject, int )