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" <b style='color:{color}'>{CATEGORY_ICONS.get(f.category, '')} " 344 f"{_esc(f.category)}</b> {_esc(f.comment)}") 345 if row.label.text() != text: # (only what changed is measured again) 346 row.label.setText(text) 347 rows.append(row) 348 for left in spare.values(): 349 for row in left: 350 self.layout_.removeWidget(row) 351 row.deleteLater() 352 for index, row in enumerate(rows): # (new rows into their places; the others keep theirs) 353 if self.layout_.indexOf(row) != index: 354 self.layout_.insertWidget(index, row) 355 self.rows = rows 356 self.setVisible(bool(findings)) 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)
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).
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).
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.
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).
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).
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.
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.
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.
252 def setText(self, text: str): 253 if text != self.text(): 254 self._forget() 255 super().setText(text)
setText(self, arg__1: str, /) -> None
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
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
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
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…
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.
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" <b style='color:{color}'>{CATEGORY_ICONS.get(f.category, '')} " 345 f"{_esc(f.category)}</b> {_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.
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.
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" <b style='color:{color}'>{CATEGORY_ICONS.get(f.category, '')} " 345 f"{_esc(f.category)}</b> {_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))
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.
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.
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).
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
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.
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.
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.
scroll(self, dx: int, dy: int, /) -> None scroll(self, dx: int, dy: int, arg__3: PySide6.QtCore.QRect, /) -> None
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()
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.
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.
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).
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).
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.
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.
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).
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).
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.
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.
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).
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).
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).
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