vibefpmgui.window.ai

The AI menu, the local Ollama server and model, AI reviews, and the AI's worker threads.

The Review panel (vibefpmgui.review_monitor) shows what comes back.

  1"""The AI menu, the local Ollama server and model, AI reviews, and the AI's worker threads.
  2
  3The Review panel (`vibefpmgui.review_monitor`) shows what comes back.
  4"""
  5
  6from __future__ import annotations
  7
  8import time
  9
 10from qtpy.QtCore import QProcess, QTimer
 11from qtpy.QtGui import QActionGroup, QKeySequence, QTextCursor
 12from qtpy.QtWidgets import QInputDialog, QMessageBox, QSizePolicy, QWidget
 13
 14from .. import ai_context as ctx
 15from .. import ai_review as ai
 16from ..background import call_in_background
 17from ..editor import EditorTab
 18from ..processes import kill_process_tree
 19from ..review_monitor import Entry, ReviewMonitor
 20
 21
 22class AIMixin:
 23    """The AI menu, Ollama and AI reviews (a part of `vibefpmgui.main_window.MainWindow`)."""
 24
 25    def _build_ai_menu(self):
 26        self.ai_model = self.settings.value("ai_model", ai.DEFAULT_MODEL)
 27        self.ai_focus = self.settings.value("ai_focus", "general")
 28        self._ai_thread = None
 29        self._ai_worker = None
 30        self._ai_editor: EditorTab | None = None
 31        self._ollama_process: QProcess | None = None
 32        # Ollama is asked things on a pool thread (see background.call_in_background):
 33        self._ai_starting = False  # a review is getting ready (server, model): AI Review waits
 34        self._known_models: list[str] | None = None  # installed models, as last heard (None: not yet)
 35        self._after_model = lambda: None  # what runs once the model is there: a review, an explanation, a chat
 36        self._turns: list[ctx.Turn] = []  # what the model remembers: every entry since New Chat (see ai_context)
 37        self._token_ratio = 1.0  # measured tokens over estimated ones (see _chat_finished)
 38        self._model_contexts: dict[str, int | None] = {}  # each model's longest context, as Ollama says
 39        self._ai_entry: Entry | None = None  # the Review panel's entry for what's running (or last ran)
 40        # What the model remembers starts over with New Chat, Clear or another
 41        # project: a request started before (stopped, but still ending) mustn't
 42        # add to it.
 43        self._memory_generation = 0
 44        self._request_generation = 0
 45        self._edit_selection: tuple[EditorTab, int, int] | None = None  # Edit Selection with AI: the lines
 46        self._asking_models = False
 47
 48        menu = self.menuBar().addMenu("&AI")
 49        self.review_action = menu.addAction("Review Current File")
 50        self.review_action.setShortcut(QKeySequence("Ctrl+Alt+R"))
 51        self.review_action.setStatusTip("Ask a local AI model (via Ollama) to review the file in the editor")
 52        self.review_action.triggered.connect(self.start_review)
 53        # Also on the toolbar, at the far right.
 54        self.review_action.setIconText("AI Review")
 55        keys = self.review_action.shortcut().toString(QKeySequence.NativeText)
 56        self.review_action.setToolTip(f"AI Review ({keys}) — review the file in the editor "
 57                                      "with a local AI model")
 58        spacer = QWidget()
 59        spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
 60        self.command_toolbar.addWidget(spacer)
 61        self.command_toolbar.addAction(self.format_action)
 62        self.command_icons[self.format_action] = "format"
 63        self.command_toolbar.addAction(self.review_action)
 64        self.command_icons[self.review_action] = "robot"
 65        self._update_command_icons()
 66        self.stop_review_action = menu.addAction("Stop")
 67        self.stop_review_action.setEnabled(False)
 68        self.stop_review_action.triggered.connect(self.stop_review)
 69        self.edit_with_ai_action = menu.addAction("Edit with AI…")
 70        self.edit_with_ai_action.setShortcut(QKeySequence("Ctrl+Alt+E"))
 71        self.edit_with_ai_action.setStatusTip("Ask the AI to change the file in the editor (the selection, if "
 72                                              "there is one); you accept or reject each edit")
 73        self.edit_with_ai_action.triggered.connect(self.edit_with_ai)
 74        self.stop_ollama_action = menu.addAction("Stop Ollama Server")
 75        self.stop_ollama_action.setStatusTip("Stop the Ollama server the app started (and the model it has "
 76                                             "loaded, freeing its memory); it starts again when the AI is next used")
 77        self.stop_ollama_action.triggered.connect(self.stop_ollama)
 78        menu.aboutToShow.connect(self._update_ollama_action)
 79        self._update_ollama_action()
 80        self.clear_review_action = menu.addAction("Clear Review Markers")
 81        self.clear_review_action.triggered.connect(
 82            lambda: self._with_editor(lambda e: e.clear_review()))
 83        menu.addSeparator()
 84        self.model_menu = menu.addMenu("Model")
 85        self.model_menu.aboutToShow.connect(self._fill_model_menu)
 86        self.ai_think = self.settings.value("ai_think", False, type=bool)
 87        self.ai_answer_tokens = self.settings.value("ai_answer_tokens", ai.MAX_ANSWER_TOKENS, type=int)
 88        self.think_action = menu.addAction("Think Before Answering")
 89        self.think_action.setCheckable(True)
 90        self.think_action.setChecked(self.ai_think)
 91        self.think_action.setStatusTip(
 92            "Let a thinking model (qwen3, deepseek-r1…) reason before it answers: slower, sometimes better. "
 93            f"The reasoning gets {ai.REASONING_TOKENS:,} tokens of its own, on top of the answer's length")
 94        self.think_action.toggled.connect(self._set_ai_think)
 95        length_menu = self.answer_length_menu = menu.addMenu("Answer Length")
 96        lengths = QActionGroup(self)
 97        for tokens in ai.ANSWER_LENGTHS:
 98            action = length_menu.addAction(f"{tokens:,} tokens" + (" (default)" if tokens == ai.MAX_ANSWER_TOKENS
 99                                                                     else "") if tokens else "No limit")
100            action.setCheckable(True)
101            action.setChecked(tokens == self.ai_answer_tokens)
102            action.setStatusTip("How much the model may write in an answer (edits get at least "
103                                f"{ai.EDIT_ANSWER_TOKENS:,}); a longer answer needs more of its context")
104            action.triggered.connect(lambda _=False, t=tokens: self._set_ai_answer_tokens(t))
105            lengths.addAction(action)
106        focus_menu = menu.addMenu("Focus")
107        group = QActionGroup(self)
108        for key, label in ai.FOCUS_LABELS.items():
109            action = focus_menu.addAction(label)
110            action.setCheckable(True)
111            action.setChecked(key == self.ai_focus)
112            action.setStatusTip(ai.FOCUS[key])
113            action.triggered.connect(lambda _=False, k=key: self._set_ai_focus(k))
114            group.addAction(action)
115
116    def _build_review_panel(self):
117        """The Review panel: the AI's timeline."""
118        self.review_monitor = ReviewMonitor()
119        monitor = self.review_monitor
120        monitor.stop_requested.connect(self.stop_review)
121        monitor.chat_requested.connect(self.send_chat)
122        monitor.new_chat_requested.connect(self.new_chat)
123        monitor.clear_requested.connect(self.clear_ai_history)
124        monitor.selection_cleared.connect(lambda: setattr(self, "_edit_selection", None))
125        monitor.finding_selected.connect(self.go_to_finding)
126        monitor.fix_requested.connect(self.fix_finding)
127        monitor.show_in_editor_requested.connect(self.show_findings_in_editor)
128        monitor.accept_requested.connect(self.accept_edit)
129        monitor.reject_requested.connect(self.reject_edit)
130        monitor.accept_all_requested.connect(self.accept_all_edits)
131        monitor.reject_all_requested.connect(self.reject_all_edits)
132        monitor.edit_clicked.connect(self.show_edit)
133
134    def _update_review_actions(self):
135        """AI review needs an editor tab in front (and no review running)."""
136        has_editor = self.current_editor() is not None
137        busy = self._ai_thread is not None or getattr(self, "_ai_starting", False)
138        self.review_action.setEnabled(has_editor and not busy)
139        self.review_monitor.set_sending(not busy)  # (a question while it's answering: wait for it)
140        self.clear_review_action.setEnabled(has_editor)
141
142    def _set_ai_think(self, on: bool):
143        self.ai_think = on
144        self.settings.setValue("ai_think", on)
145
146    def _set_ai_answer_tokens(self, tokens: int):
147        self.ai_answer_tokens = tokens
148        self.settings.setValue("ai_answer_tokens", tokens)
149
150    def _answer_options(self, edit: bool = False) -> dict:
151        """The worker's settings from AI → Think Before Answering and Answer
152        Length (an edit's answer, with its old and new text: at least
153        EDIT_ANSWER_TOKENS)."""
154        tokens = self.ai_answer_tokens
155        if edit and tokens:
156            tokens = max(tokens, ai.EDIT_ANSWER_TOKENS)
157        return {"num_predict": tokens, "think": self.ai_think}
158
159    def _start_answer(self, worker) -> None:
160        """Start a worker whose reasoning (if the model thinks) goes to the
161        running entry."""
162        worker.reasoning.connect(self.review_monitor.add_reasoning)
163        self._start_ai_worker(worker)
164
165    def _set_ai_focus(self, key: str):
166        self.ai_focus = key
167        self.settings.setValue("ai_focus", key)
168
169    def _set_ai_model(self, name: str):
170        self.ai_model = name
171        self.settings.setValue("ai_model", name)
172        self.statusBar().showMessage(f"AI model: {name}", 4000)
173
174    def _fill_model_menu(self):
175        """Installed models (if Ollama is running), plus the current choice:
176        the list as last heard, at once, while Ollama is asked again."""
177        self._show_model_menu()
178        if ai.ollama_available() and not self._asking_models:
179            self._asking_models = True
180            call_in_background(ai.installed_models, self._models_heard, self)
181
182    def _models_heard(self, models: list[str]):
183        self._asking_models = False
184        self._known_models = models
185        if self.model_menu.isVisible():
186            self._show_model_menu()
187
188    def _show_model_menu(self):
189        menu = self.model_menu
190        menu.clear()
191        models = self._known_models or []
192        names = sorted(set(models) | {self.ai_model})
193        group = QActionGroup(menu)
194        for name in names:
195            label = name if name in models else f"{name} (not downloaded yet)"
196            action = menu.addAction(label)
197            action.setCheckable(True)
198            action.setChecked(name == self.ai_model)
199            action.triggered.connect(lambda _=False, n=name: self._set_ai_model(n))
200            group.addAction(action)
201        if self._known_models is None and ai.ollama_available():
202            note = menu.addAction("(Checking for installed models…)")
203            note.setEnabled(False)
204        elif not models:
205            note = menu.addAction("(Ollama isn't running — installed models appear once it is)")
206            note.setEnabled(False)
207        menu.addSeparator()
208        other = menu.addAction("Other Model…")
209        other.triggered.connect(self._choose_other_model)
210
211    def _choose_other_model(self):
212        name, ok = QInputDialog.getText(
213            self, "AI model", "Ollama model name (e.g. qwen2.5-coder:14b, qwen3-coder:30b, "
214            "gpt-oss:20b). It's downloaded the first time it's used:", text=self.ai_model)
215        if ok and name.strip():
216            self._set_ai_model(name.strip())
217
218    def start_review(self):
219        editor = self.current_editor()
220        if editor is None:
221            QMessageBox.information(self, "AI review", "Open a file in the editor first "
222                                    "(double-click a file, or File → Open File in Editor).")
223            return
224        if not self._can_ask("AI review"):
225            return
226        self._ai_editor = editor
227        self._after_model = self._run_review
228        focus = ai.FOCUS_LABELS.get(self.ai_focus, self.ai_focus)
229        self._add_entry(Entry("review", focus, editor.path, editor.path.name, self.ai_model))
230        self._ensure_ollama(self._check_model)
231
232    def _can_ask(self, title: str) -> bool:
233        """The AI is free, and Ollama's Python package is there (if not: why)."""
234        if self._ai_thread is not None or self._ai_starting:
235            self.statusBar().showMessage("The AI is busy: try again when it's done", 5000)
236            return False
237        if not ai.ollama_available():
238            QMessageBox.warning(self, title, "The 'ollama' Python package isn't installed.\n"
239                                "Run 'pixi install' (or 'pip install ollama').")
240            return False
241        return True
242
243    def _add_entry(self, entry: Entry) -> Entry:
244        """A new entry in the Review panel's timeline, for what's starting."""
245        self._ai_entry = entry
246        self._request_generation = self._memory_generation
247        self.show_panel(self.review_monitor)
248        if entry.kind not in ("ask", "edit"):
249            self.review_monitor.context_note = ""
250        return self.review_monitor.add_entry(entry)
251
252    def _remember(self, entry: Entry, turn: ctx.Turn):
253        """What the model was asked and answered: it's part of what later
254        questions send (see ai_context)."""
255        if self._request_generation != self._memory_generation:
256            return  # (asked before New Chat, Clear or another project: forgotten)
257        turn.answer, turn.edits = entry.answer, entry.edits
258        entry.turn = turn
259        self._turns.append(turn)
260
261    def _set_ai_starting(self, on: bool):
262        """A review is getting ready (asking Ollama, starting it): AI Review
263        waits, so a second click doesn't start a second server."""
264        self._ai_starting = on
265        self._update_review_actions()
266
267    def _ensure_ollama(self, then):
268        """Start "ollama serve" if no server answers, then call ``then``
269        (Ollama is asked on a pool thread: the window never waits for it)."""
270        self._set_ai_starting(True)
271        call_in_background(ai.server_running, lambda up: self._server_checked(up, then), self)
272
273    def _server_checked(self, up: bool, then):
274        if up:
275            then()
276            return
277        exe = ai.ollama_executable()
278        if exe is None:
279            self._set_ai_starting(False)
280            self._end_explain_setup("Ollama isn't running")
281            QMessageBox.warning(self, "AI review", "Ollama isn't running and the 'ollama' program "
282                                "wasn't found. Run 'pixi install', or install Ollama from ollama.com.")
283            return
284        self._ai_status("starting the local Ollama server", "Starting Ollama")
285        self._ollama_process = QProcess(self)
286        self._ollama_process.setProcessChannelMode(QProcess.MergedChannels)
287        self._ollama_process.start(exe, ["serve"])
288        deadline = time.monotonic() + 20
289
290        def poll():
291            call_in_background(ai.server_running, polled, self)
292
293        def polled(up: bool):
294            if up:
295                then()
296            elif time.monotonic() > deadline or self._ollama_process.state() == QProcess.NotRunning:
297                self._set_ai_starting(False)
298                self.review_monitor.finish(False, "Could not start the Ollama server.")
299            else:
300                QTimer.singleShot(300, self, poll)
301
302        QTimer.singleShot(300, self, poll)
303
304    def _check_model(self):
305        """Is the model downloaded? (Asked on a pool thread.)"""
306        call_in_background(ai.installed_models, self._model_checked, self)
307
308    def _model_checked(self, installed: list[str]):
309        self._known_models = installed
310        self._set_ai_starting(False)  # (from here a worker thread, or nothing, follows)
311        model = self.ai_model if ":" in self.ai_model else self.ai_model + ":latest"
312        if model in installed or self.ai_model in installed:
313            if self.ai_model not in self._model_contexts:  # (for the next chat question: asked once)
314                self._model_contexts[self.ai_model] = None
315                call_in_background(lambda m=self.ai_model: ai.model_context_length(m),
316                                   lambda n, m=self.ai_model: self._model_contexts.__setitem__(m, n), self)
317            self._after_model()
318            return
319        answer = QMessageBox.question(
320            self, "Download AI model",
321            f"The model '{self.ai_model}' isn't downloaded yet. Download it now?\n\n"
322            "Models are a few GB; this happens once, and everything runs locally afterwards.")
323        if answer != QMessageBox.Yes:
324            self._end_explain_setup(f"{self.ai_model} isn't downloaded")
325            return
326        self._ai_status(f"downloading {self.ai_model}", "Downloading")
327        worker = ai.ReviewWorker(ai.DEFAULT_HOST, self.ai_model, pull_only=True)
328        worker.progress.connect(self._pull_progress)
329        # (A method of the window, not a lambda: a lambda would run in the
330        # worker's thread, which is ending, and the review would never start.)
331        worker.finished.connect(self._pull_finished)
332        worker.failed.connect(self._review_failed)
333        self._start_ai_worker(worker)
334
335    def _ai_status(self, description: str, status: str):
336        """Getting ready (starting Ollama, downloading the model): the status
337        of the entry that's waiting for it."""
338        self.show_panel(self.review_monitor)
339        if self.review_monitor.running:
340            self.review_monitor.set_status(status)
341
342    def _pull_finished(self, *_):
343        self._ai_done(then=self._after_model)
344
345    def _pull_progress(self, status: str, fraction: float):
346        if fraction >= 0:
347            self.review_monitor.set_status(f"Downloading {self.ai_model} {fraction * 100:.0f}%", fraction)
348        else:
349            self.review_monitor.set_status(status.capitalize() or "Downloading")
350
351    def _run_review(self):
352        editor = self._ai_editor
353        if editor is None or editor not in self.editors():
354            self.review_monitor.finish(False, "The file was closed")
355            return
356        code = editor.editor.toPlainText()
357        diagnostics = editor.compiler_diagnostics()
358        lines = ctx.file_lines(code)
359        options = self._answer_options()
360        cap = self._chat_context()
361        room = options["num_predict"] + (ai.REASONING_TOKENS if options["think"] else 0) \
362            if options["num_predict"] else ai.REASONING_TOKENS
363
364        def prompt_for(first: int, last: int) -> str:
365            return ai.build_prompt(editor.path.name, editor.highlighter.lexer.name, "\n".join(lines[first - 1:last]),
366                                   self.ai_focus, diagnostics, first, len(lines))
367
368        # The selection (two lines or more), or the whole file if it fits the
369        # model's context with room for the answer; else the procedure at the
370        # cursor. (Sent anyway, a file too long loses its start, instructions
371        # included: Ollama doesn't refuse, it drops what doesn't fit.)
372        first, last, why = 1, len(lines), ""
373        selection = editor.editor.selected_lines()
374        if selection is not None and selection[1] > selection[0]:
375            (first, last), why = selection, "the selection"
376        elif ctx.estimate_tokens(prompt_for(1, len(lines)) + ai.SYSTEM_PROMPT, self._token_ratio) + room \
377                > cap * 0.9:
378            first, last = ctx.window(lines, None, editor.editor.textCursor().blockNumber() + 1)
379            why = "the procedure at the cursor: the file is too long to review at once"
380        prompt = prompt_for(first, last)
381        entry = self._ai_entry
382        if why:
383            entry.where = f"{editor.path.name}:{first}–{last}"
384            entry.question = f"{entry.question} · {why}"
385            self.review_monitor.refresh(entry)
386        num_ctx = ai.context_for(ctx.estimate_tokens(prompt + ai.SYSTEM_PROMPT, self._token_ratio), cap, room)
387        self.review_monitor.set_status("Thinking")
388        self._show_single_output(f"AI review of {editor.path.name} ({self.ai_model})", "")
389        self._review_text = ""
390        self._review_keys = []
391        self._review_code = code
392        editor.clear_review()  # markers from an earlier review would be misleading
393        worker = ai.ReviewWorker(ai.DEFAULT_HOST, self.ai_model, prompt, num_ctx=num_ctx, **options)
394        worker.chunk.connect(self._review_chunk)
395        worker.finished.connect(self._review_finished)
396        worker.failed.connect(self._review_failed)
397        self._start_answer(worker)
398
399    def _start_ai_worker(self, worker):
400        self._ai_worker = worker
401        self._ai_thread = ai.start_worker(worker, self)
402        self._ai_thread.finished.connect(self._ai_thread_done)
403        self._update_review_actions()
404        self.stop_review_action.setEnabled(True)
405        self.review_monitor.set_stoppable(True)
406
407    def _ai_thread_done(self):
408        self._ai_thread = None
409        self._ai_worker = None
410        self._update_review_actions()
411        self.stop_review_action.setEnabled(False)
412        self.review_monitor.set_stoppable(False)
413
414    def _ai_done(self, then=None):
415        # Let the download thread finish before starting the review thread.
416        if then is not None:
417            QTimer.singleShot(100, self, lambda: self._after_ai_thread(then))
418
419    def _after_ai_thread(self, then):
420        if self._ai_thread is not None:
421            QTimer.singleShot(100, self, lambda: self._after_ai_thread(then))
422        else:
423            then()
424
425    def _review_chunk(self, text: str):
426        cursor = self.test_output.textCursor()
427        cursor.movePosition(QTextCursor.End)
428        cursor.insertText(text)
429        self.test_output.setTextCursor(cursor)
430        self.test_output.ensureCursorVisible()
431        self.review_monitor.add_answer(text)
432        # Text arrives a line at a time: re-read the answer so far and show
433        # the findings found so far (the entry's list and the editor's markers).
434        self._review_text += text
435        editor, entry = self._ai_editor, self._ai_entry
436        max_line = editor.editor.blockCount() if editor in self.editors() else None
437        findings, _summary = ai.parse_findings(self._review_text, max_line)
438        keys = [(f.line, f.end_line, f.category, f.comment) for f in findings]
439        if keys != self._review_keys:
440            self._review_keys = keys
441            entry.findings = findings
442            self.review_monitor.refresh_soon(entry)  # (with the answer's next showing)
443            if editor in self.editors():
444                editor.show_review(findings)
445
446    def _review_finished(self, text: str, stats: dict):
447        editor, entry = self._ai_editor, self._ai_entry
448        max_line = editor.editor.blockCount() if editor in self.editors() else None
449        findings, summary = ai.parse_findings(text, max_line)
450        entry.findings, entry.summary = findings, summary
451        name = editor.path.name if editor is not None else entry.where.split(":")[0]
452        self._remember(entry, ctx.Turn(
453            f"Review {entry.where}", f"Review my file {entry.where} ({entry.question.lower()}): list the issues "
454            "you find.",
455            ctx.file_attachment(name, self._review_code, numbers=True, context=self._chat_context(),
456                                ratio=self._token_ratio)))
457        detail = f"{stats.get('seconds', 0):.1f} s · {self.ai_model}" if stats else self.ai_model
458        if stats.get("repeated"):
459            detail += " · stopped a repetition loop"
460        elif stats.get("thinking"):
461            detail += " · the model spent its answer's length on reasoning (see AI → Answer Length)"
462        elif stats.get("truncated"):
463            detail += " · answer cut off at the length limit"
464        self.review_monitor.finish(True, detail)
465        if editor in self.editors():
466            editor.show_review(findings)
467        self.statusBar().showMessage(f"AI review: {len(findings)} findings", 5000)
468
469    def _review_failed(self, message: str):
470        hint = ""
471        if "not found" in message.lower():
472            hint = f" (is '{self.ai_model}' downloaded? See AI → Model)"
473        self.review_monitor.finish(False, message + hint)
474        self.statusBar().showMessage(f"AI: {message}{hint}", 6000)
475
476    def stop_review(self):
477        if self._ai_worker is not None:
478            self._ai_worker.cancel()
479
480    def _ollama_running(self) -> bool:
481        """A server the app started is running (one started otherwise isn't ours to stop)."""
482        return self._ollama_process is not None and self._ollama_process.state() != QProcess.NotRunning
483
484    def _update_ollama_action(self):
485        self.stop_ollama_action.setEnabled(self._ollama_running())
486
487    def stop_ollama(self, quitting: bool = False):
488        """AI → Stop Ollama Server: the server the app started, with the
489        model runner it started (killing only the server would leave that
490        running, holding the model's memory)."""
491        if not self._ollama_running():
492            return
493        if not quitting:
494            self.stop_review()
495        kill_process_tree(self._ollama_process)
496        self._ollama_process.waitForFinished(3000)
497        self._ollama_process = None
498        self._update_ollama_action()
499        if not quitting:
500            self.statusBar().showMessage("Stopped the Ollama server (it starts again when the AI is next used)", 5000)
501
502    def _shutdown_ai(self):
503        if self._ai_worker is not None:
504            self._ai_worker.cancel()
505        if self._ai_thread is not None:
506            self._ai_thread.quit()
507            self._ai_thread.wait(3000)
508        self.stop_ollama(quitting=True)
class AIMixin:
 23class AIMixin:
 24    """The AI menu, Ollama and AI reviews (a part of `vibefpmgui.main_window.MainWindow`)."""
 25
 26    def _build_ai_menu(self):
 27        self.ai_model = self.settings.value("ai_model", ai.DEFAULT_MODEL)
 28        self.ai_focus = self.settings.value("ai_focus", "general")
 29        self._ai_thread = None
 30        self._ai_worker = None
 31        self._ai_editor: EditorTab | None = None
 32        self._ollama_process: QProcess | None = None
 33        # Ollama is asked things on a pool thread (see background.call_in_background):
 34        self._ai_starting = False  # a review is getting ready (server, model): AI Review waits
 35        self._known_models: list[str] | None = None  # installed models, as last heard (None: not yet)
 36        self._after_model = lambda: None  # what runs once the model is there: a review, an explanation, a chat
 37        self._turns: list[ctx.Turn] = []  # what the model remembers: every entry since New Chat (see ai_context)
 38        self._token_ratio = 1.0  # measured tokens over estimated ones (see _chat_finished)
 39        self._model_contexts: dict[str, int | None] = {}  # each model's longest context, as Ollama says
 40        self._ai_entry: Entry | None = None  # the Review panel's entry for what's running (or last ran)
 41        # What the model remembers starts over with New Chat, Clear or another
 42        # project: a request started before (stopped, but still ending) mustn't
 43        # add to it.
 44        self._memory_generation = 0
 45        self._request_generation = 0
 46        self._edit_selection: tuple[EditorTab, int, int] | None = None  # Edit Selection with AI: the lines
 47        self._asking_models = False
 48
 49        menu = self.menuBar().addMenu("&AI")
 50        self.review_action = menu.addAction("Review Current File")
 51        self.review_action.setShortcut(QKeySequence("Ctrl+Alt+R"))
 52        self.review_action.setStatusTip("Ask a local AI model (via Ollama) to review the file in the editor")
 53        self.review_action.triggered.connect(self.start_review)
 54        # Also on the toolbar, at the far right.
 55        self.review_action.setIconText("AI Review")
 56        keys = self.review_action.shortcut().toString(QKeySequence.NativeText)
 57        self.review_action.setToolTip(f"AI Review ({keys}) — review the file in the editor "
 58                                      "with a local AI model")
 59        spacer = QWidget()
 60        spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
 61        self.command_toolbar.addWidget(spacer)
 62        self.command_toolbar.addAction(self.format_action)
 63        self.command_icons[self.format_action] = "format"
 64        self.command_toolbar.addAction(self.review_action)
 65        self.command_icons[self.review_action] = "robot"
 66        self._update_command_icons()
 67        self.stop_review_action = menu.addAction("Stop")
 68        self.stop_review_action.setEnabled(False)
 69        self.stop_review_action.triggered.connect(self.stop_review)
 70        self.edit_with_ai_action = menu.addAction("Edit with AI…")
 71        self.edit_with_ai_action.setShortcut(QKeySequence("Ctrl+Alt+E"))
 72        self.edit_with_ai_action.setStatusTip("Ask the AI to change the file in the editor (the selection, if "
 73                                              "there is one); you accept or reject each edit")
 74        self.edit_with_ai_action.triggered.connect(self.edit_with_ai)
 75        self.stop_ollama_action = menu.addAction("Stop Ollama Server")
 76        self.stop_ollama_action.setStatusTip("Stop the Ollama server the app started (and the model it has "
 77                                             "loaded, freeing its memory); it starts again when the AI is next used")
 78        self.stop_ollama_action.triggered.connect(self.stop_ollama)
 79        menu.aboutToShow.connect(self._update_ollama_action)
 80        self._update_ollama_action()
 81        self.clear_review_action = menu.addAction("Clear Review Markers")
 82        self.clear_review_action.triggered.connect(
 83            lambda: self._with_editor(lambda e: e.clear_review()))
 84        menu.addSeparator()
 85        self.model_menu = menu.addMenu("Model")
 86        self.model_menu.aboutToShow.connect(self._fill_model_menu)
 87        self.ai_think = self.settings.value("ai_think", False, type=bool)
 88        self.ai_answer_tokens = self.settings.value("ai_answer_tokens", ai.MAX_ANSWER_TOKENS, type=int)
 89        self.think_action = menu.addAction("Think Before Answering")
 90        self.think_action.setCheckable(True)
 91        self.think_action.setChecked(self.ai_think)
 92        self.think_action.setStatusTip(
 93            "Let a thinking model (qwen3, deepseek-r1…) reason before it answers: slower, sometimes better. "
 94            f"The reasoning gets {ai.REASONING_TOKENS:,} tokens of its own, on top of the answer's length")
 95        self.think_action.toggled.connect(self._set_ai_think)
 96        length_menu = self.answer_length_menu = menu.addMenu("Answer Length")
 97        lengths = QActionGroup(self)
 98        for tokens in ai.ANSWER_LENGTHS:
 99            action = length_menu.addAction(f"{tokens:,} tokens" + (" (default)" if tokens == ai.MAX_ANSWER_TOKENS
100                                                                     else "") if tokens else "No limit")
101            action.setCheckable(True)
102            action.setChecked(tokens == self.ai_answer_tokens)
103            action.setStatusTip("How much the model may write in an answer (edits get at least "
104                                f"{ai.EDIT_ANSWER_TOKENS:,}); a longer answer needs more of its context")
105            action.triggered.connect(lambda _=False, t=tokens: self._set_ai_answer_tokens(t))
106            lengths.addAction(action)
107        focus_menu = menu.addMenu("Focus")
108        group = QActionGroup(self)
109        for key, label in ai.FOCUS_LABELS.items():
110            action = focus_menu.addAction(label)
111            action.setCheckable(True)
112            action.setChecked(key == self.ai_focus)
113            action.setStatusTip(ai.FOCUS[key])
114            action.triggered.connect(lambda _=False, k=key: self._set_ai_focus(k))
115            group.addAction(action)
116
117    def _build_review_panel(self):
118        """The Review panel: the AI's timeline."""
119        self.review_monitor = ReviewMonitor()
120        monitor = self.review_monitor
121        monitor.stop_requested.connect(self.stop_review)
122        monitor.chat_requested.connect(self.send_chat)
123        monitor.new_chat_requested.connect(self.new_chat)
124        monitor.clear_requested.connect(self.clear_ai_history)
125        monitor.selection_cleared.connect(lambda: setattr(self, "_edit_selection", None))
126        monitor.finding_selected.connect(self.go_to_finding)
127        monitor.fix_requested.connect(self.fix_finding)
128        monitor.show_in_editor_requested.connect(self.show_findings_in_editor)
129        monitor.accept_requested.connect(self.accept_edit)
130        monitor.reject_requested.connect(self.reject_edit)
131        monitor.accept_all_requested.connect(self.accept_all_edits)
132        monitor.reject_all_requested.connect(self.reject_all_edits)
133        monitor.edit_clicked.connect(self.show_edit)
134
135    def _update_review_actions(self):
136        """AI review needs an editor tab in front (and no review running)."""
137        has_editor = self.current_editor() is not None
138        busy = self._ai_thread is not None or getattr(self, "_ai_starting", False)
139        self.review_action.setEnabled(has_editor and not busy)
140        self.review_monitor.set_sending(not busy)  # (a question while it's answering: wait for it)
141        self.clear_review_action.setEnabled(has_editor)
142
143    def _set_ai_think(self, on: bool):
144        self.ai_think = on
145        self.settings.setValue("ai_think", on)
146
147    def _set_ai_answer_tokens(self, tokens: int):
148        self.ai_answer_tokens = tokens
149        self.settings.setValue("ai_answer_tokens", tokens)
150
151    def _answer_options(self, edit: bool = False) -> dict:
152        """The worker's settings from AI → Think Before Answering and Answer
153        Length (an edit's answer, with its old and new text: at least
154        EDIT_ANSWER_TOKENS)."""
155        tokens = self.ai_answer_tokens
156        if edit and tokens:
157            tokens = max(tokens, ai.EDIT_ANSWER_TOKENS)
158        return {"num_predict": tokens, "think": self.ai_think}
159
160    def _start_answer(self, worker) -> None:
161        """Start a worker whose reasoning (if the model thinks) goes to the
162        running entry."""
163        worker.reasoning.connect(self.review_monitor.add_reasoning)
164        self._start_ai_worker(worker)
165
166    def _set_ai_focus(self, key: str):
167        self.ai_focus = key
168        self.settings.setValue("ai_focus", key)
169
170    def _set_ai_model(self, name: str):
171        self.ai_model = name
172        self.settings.setValue("ai_model", name)
173        self.statusBar().showMessage(f"AI model: {name}", 4000)
174
175    def _fill_model_menu(self):
176        """Installed models (if Ollama is running), plus the current choice:
177        the list as last heard, at once, while Ollama is asked again."""
178        self._show_model_menu()
179        if ai.ollama_available() and not self._asking_models:
180            self._asking_models = True
181            call_in_background(ai.installed_models, self._models_heard, self)
182
183    def _models_heard(self, models: list[str]):
184        self._asking_models = False
185        self._known_models = models
186        if self.model_menu.isVisible():
187            self._show_model_menu()
188
189    def _show_model_menu(self):
190        menu = self.model_menu
191        menu.clear()
192        models = self._known_models or []
193        names = sorted(set(models) | {self.ai_model})
194        group = QActionGroup(menu)
195        for name in names:
196            label = name if name in models else f"{name} (not downloaded yet)"
197            action = menu.addAction(label)
198            action.setCheckable(True)
199            action.setChecked(name == self.ai_model)
200            action.triggered.connect(lambda _=False, n=name: self._set_ai_model(n))
201            group.addAction(action)
202        if self._known_models is None and ai.ollama_available():
203            note = menu.addAction("(Checking for installed models…)")
204            note.setEnabled(False)
205        elif not models:
206            note = menu.addAction("(Ollama isn't running — installed models appear once it is)")
207            note.setEnabled(False)
208        menu.addSeparator()
209        other = menu.addAction("Other Model…")
210        other.triggered.connect(self._choose_other_model)
211
212    def _choose_other_model(self):
213        name, ok = QInputDialog.getText(
214            self, "AI model", "Ollama model name (e.g. qwen2.5-coder:14b, qwen3-coder:30b, "
215            "gpt-oss:20b). It's downloaded the first time it's used:", text=self.ai_model)
216        if ok and name.strip():
217            self._set_ai_model(name.strip())
218
219    def start_review(self):
220        editor = self.current_editor()
221        if editor is None:
222            QMessageBox.information(self, "AI review", "Open a file in the editor first "
223                                    "(double-click a file, or File → Open File in Editor).")
224            return
225        if not self._can_ask("AI review"):
226            return
227        self._ai_editor = editor
228        self._after_model = self._run_review
229        focus = ai.FOCUS_LABELS.get(self.ai_focus, self.ai_focus)
230        self._add_entry(Entry("review", focus, editor.path, editor.path.name, self.ai_model))
231        self._ensure_ollama(self._check_model)
232
233    def _can_ask(self, title: str) -> bool:
234        """The AI is free, and Ollama's Python package is there (if not: why)."""
235        if self._ai_thread is not None or self._ai_starting:
236            self.statusBar().showMessage("The AI is busy: try again when it's done", 5000)
237            return False
238        if not ai.ollama_available():
239            QMessageBox.warning(self, title, "The 'ollama' Python package isn't installed.\n"
240                                "Run 'pixi install' (or 'pip install ollama').")
241            return False
242        return True
243
244    def _add_entry(self, entry: Entry) -> Entry:
245        """A new entry in the Review panel's timeline, for what's starting."""
246        self._ai_entry = entry
247        self._request_generation = self._memory_generation
248        self.show_panel(self.review_monitor)
249        if entry.kind not in ("ask", "edit"):
250            self.review_monitor.context_note = ""
251        return self.review_monitor.add_entry(entry)
252
253    def _remember(self, entry: Entry, turn: ctx.Turn):
254        """What the model was asked and answered: it's part of what later
255        questions send (see ai_context)."""
256        if self._request_generation != self._memory_generation:
257            return  # (asked before New Chat, Clear or another project: forgotten)
258        turn.answer, turn.edits = entry.answer, entry.edits
259        entry.turn = turn
260        self._turns.append(turn)
261
262    def _set_ai_starting(self, on: bool):
263        """A review is getting ready (asking Ollama, starting it): AI Review
264        waits, so a second click doesn't start a second server."""
265        self._ai_starting = on
266        self._update_review_actions()
267
268    def _ensure_ollama(self, then):
269        """Start "ollama serve" if no server answers, then call ``then``
270        (Ollama is asked on a pool thread: the window never waits for it)."""
271        self._set_ai_starting(True)
272        call_in_background(ai.server_running, lambda up: self._server_checked(up, then), self)
273
274    def _server_checked(self, up: bool, then):
275        if up:
276            then()
277            return
278        exe = ai.ollama_executable()
279        if exe is None:
280            self._set_ai_starting(False)
281            self._end_explain_setup("Ollama isn't running")
282            QMessageBox.warning(self, "AI review", "Ollama isn't running and the 'ollama' program "
283                                "wasn't found. Run 'pixi install', or install Ollama from ollama.com.")
284            return
285        self._ai_status("starting the local Ollama server", "Starting Ollama")
286        self._ollama_process = QProcess(self)
287        self._ollama_process.setProcessChannelMode(QProcess.MergedChannels)
288        self._ollama_process.start(exe, ["serve"])
289        deadline = time.monotonic() + 20
290
291        def poll():
292            call_in_background(ai.server_running, polled, self)
293
294        def polled(up: bool):
295            if up:
296                then()
297            elif time.monotonic() > deadline or self._ollama_process.state() == QProcess.NotRunning:
298                self._set_ai_starting(False)
299                self.review_monitor.finish(False, "Could not start the Ollama server.")
300            else:
301                QTimer.singleShot(300, self, poll)
302
303        QTimer.singleShot(300, self, poll)
304
305    def _check_model(self):
306        """Is the model downloaded? (Asked on a pool thread.)"""
307        call_in_background(ai.installed_models, self._model_checked, self)
308
309    def _model_checked(self, installed: list[str]):
310        self._known_models = installed
311        self._set_ai_starting(False)  # (from here a worker thread, or nothing, follows)
312        model = self.ai_model if ":" in self.ai_model else self.ai_model + ":latest"
313        if model in installed or self.ai_model in installed:
314            if self.ai_model not in self._model_contexts:  # (for the next chat question: asked once)
315                self._model_contexts[self.ai_model] = None
316                call_in_background(lambda m=self.ai_model: ai.model_context_length(m),
317                                   lambda n, m=self.ai_model: self._model_contexts.__setitem__(m, n), self)
318            self._after_model()
319            return
320        answer = QMessageBox.question(
321            self, "Download AI model",
322            f"The model '{self.ai_model}' isn't downloaded yet. Download it now?\n\n"
323            "Models are a few GB; this happens once, and everything runs locally afterwards.")
324        if answer != QMessageBox.Yes:
325            self._end_explain_setup(f"{self.ai_model} isn't downloaded")
326            return
327        self._ai_status(f"downloading {self.ai_model}", "Downloading")
328        worker = ai.ReviewWorker(ai.DEFAULT_HOST, self.ai_model, pull_only=True)
329        worker.progress.connect(self._pull_progress)
330        # (A method of the window, not a lambda: a lambda would run in the
331        # worker's thread, which is ending, and the review would never start.)
332        worker.finished.connect(self._pull_finished)
333        worker.failed.connect(self._review_failed)
334        self._start_ai_worker(worker)
335
336    def _ai_status(self, description: str, status: str):
337        """Getting ready (starting Ollama, downloading the model): the status
338        of the entry that's waiting for it."""
339        self.show_panel(self.review_monitor)
340        if self.review_monitor.running:
341            self.review_monitor.set_status(status)
342
343    def _pull_finished(self, *_):
344        self._ai_done(then=self._after_model)
345
346    def _pull_progress(self, status: str, fraction: float):
347        if fraction >= 0:
348            self.review_monitor.set_status(f"Downloading {self.ai_model} {fraction * 100:.0f}%", fraction)
349        else:
350            self.review_monitor.set_status(status.capitalize() or "Downloading")
351
352    def _run_review(self):
353        editor = self._ai_editor
354        if editor is None or editor not in self.editors():
355            self.review_monitor.finish(False, "The file was closed")
356            return
357        code = editor.editor.toPlainText()
358        diagnostics = editor.compiler_diagnostics()
359        lines = ctx.file_lines(code)
360        options = self._answer_options()
361        cap = self._chat_context()
362        room = options["num_predict"] + (ai.REASONING_TOKENS if options["think"] else 0) \
363            if options["num_predict"] else ai.REASONING_TOKENS
364
365        def prompt_for(first: int, last: int) -> str:
366            return ai.build_prompt(editor.path.name, editor.highlighter.lexer.name, "\n".join(lines[first - 1:last]),
367                                   self.ai_focus, diagnostics, first, len(lines))
368
369        # The selection (two lines or more), or the whole file if it fits the
370        # model's context with room for the answer; else the procedure at the
371        # cursor. (Sent anyway, a file too long loses its start, instructions
372        # included: Ollama doesn't refuse, it drops what doesn't fit.)
373        first, last, why = 1, len(lines), ""
374        selection = editor.editor.selected_lines()
375        if selection is not None and selection[1] > selection[0]:
376            (first, last), why = selection, "the selection"
377        elif ctx.estimate_tokens(prompt_for(1, len(lines)) + ai.SYSTEM_PROMPT, self._token_ratio) + room \
378                > cap * 0.9:
379            first, last = ctx.window(lines, None, editor.editor.textCursor().blockNumber() + 1)
380            why = "the procedure at the cursor: the file is too long to review at once"
381        prompt = prompt_for(first, last)
382        entry = self._ai_entry
383        if why:
384            entry.where = f"{editor.path.name}:{first}–{last}"
385            entry.question = f"{entry.question} · {why}"
386            self.review_monitor.refresh(entry)
387        num_ctx = ai.context_for(ctx.estimate_tokens(prompt + ai.SYSTEM_PROMPT, self._token_ratio), cap, room)
388        self.review_monitor.set_status("Thinking")
389        self._show_single_output(f"AI review of {editor.path.name} ({self.ai_model})", "")
390        self._review_text = ""
391        self._review_keys = []
392        self._review_code = code
393        editor.clear_review()  # markers from an earlier review would be misleading
394        worker = ai.ReviewWorker(ai.DEFAULT_HOST, self.ai_model, prompt, num_ctx=num_ctx, **options)
395        worker.chunk.connect(self._review_chunk)
396        worker.finished.connect(self._review_finished)
397        worker.failed.connect(self._review_failed)
398        self._start_answer(worker)
399
400    def _start_ai_worker(self, worker):
401        self._ai_worker = worker
402        self._ai_thread = ai.start_worker(worker, self)
403        self._ai_thread.finished.connect(self._ai_thread_done)
404        self._update_review_actions()
405        self.stop_review_action.setEnabled(True)
406        self.review_monitor.set_stoppable(True)
407
408    def _ai_thread_done(self):
409        self._ai_thread = None
410        self._ai_worker = None
411        self._update_review_actions()
412        self.stop_review_action.setEnabled(False)
413        self.review_monitor.set_stoppable(False)
414
415    def _ai_done(self, then=None):
416        # Let the download thread finish before starting the review thread.
417        if then is not None:
418            QTimer.singleShot(100, self, lambda: self._after_ai_thread(then))
419
420    def _after_ai_thread(self, then):
421        if self._ai_thread is not None:
422            QTimer.singleShot(100, self, lambda: self._after_ai_thread(then))
423        else:
424            then()
425
426    def _review_chunk(self, text: str):
427        cursor = self.test_output.textCursor()
428        cursor.movePosition(QTextCursor.End)
429        cursor.insertText(text)
430        self.test_output.setTextCursor(cursor)
431        self.test_output.ensureCursorVisible()
432        self.review_monitor.add_answer(text)
433        # Text arrives a line at a time: re-read the answer so far and show
434        # the findings found so far (the entry's list and the editor's markers).
435        self._review_text += text
436        editor, entry = self._ai_editor, self._ai_entry
437        max_line = editor.editor.blockCount() if editor in self.editors() else None
438        findings, _summary = ai.parse_findings(self._review_text, max_line)
439        keys = [(f.line, f.end_line, f.category, f.comment) for f in findings]
440        if keys != self._review_keys:
441            self._review_keys = keys
442            entry.findings = findings
443            self.review_monitor.refresh_soon(entry)  # (with the answer's next showing)
444            if editor in self.editors():
445                editor.show_review(findings)
446
447    def _review_finished(self, text: str, stats: dict):
448        editor, entry = self._ai_editor, self._ai_entry
449        max_line = editor.editor.blockCount() if editor in self.editors() else None
450        findings, summary = ai.parse_findings(text, max_line)
451        entry.findings, entry.summary = findings, summary
452        name = editor.path.name if editor is not None else entry.where.split(":")[0]
453        self._remember(entry, ctx.Turn(
454            f"Review {entry.where}", f"Review my file {entry.where} ({entry.question.lower()}): list the issues "
455            "you find.",
456            ctx.file_attachment(name, self._review_code, numbers=True, context=self._chat_context(),
457                                ratio=self._token_ratio)))
458        detail = f"{stats.get('seconds', 0):.1f} s · {self.ai_model}" if stats else self.ai_model
459        if stats.get("repeated"):
460            detail += " · stopped a repetition loop"
461        elif stats.get("thinking"):
462            detail += " · the model spent its answer's length on reasoning (see AI → Answer Length)"
463        elif stats.get("truncated"):
464            detail += " · answer cut off at the length limit"
465        self.review_monitor.finish(True, detail)
466        if editor in self.editors():
467            editor.show_review(findings)
468        self.statusBar().showMessage(f"AI review: {len(findings)} findings", 5000)
469
470    def _review_failed(self, message: str):
471        hint = ""
472        if "not found" in message.lower():
473            hint = f" (is '{self.ai_model}' downloaded? See AI → Model)"
474        self.review_monitor.finish(False, message + hint)
475        self.statusBar().showMessage(f"AI: {message}{hint}", 6000)
476
477    def stop_review(self):
478        if self._ai_worker is not None:
479            self._ai_worker.cancel()
480
481    def _ollama_running(self) -> bool:
482        """A server the app started is running (one started otherwise isn't ours to stop)."""
483        return self._ollama_process is not None and self._ollama_process.state() != QProcess.NotRunning
484
485    def _update_ollama_action(self):
486        self.stop_ollama_action.setEnabled(self._ollama_running())
487
488    def stop_ollama(self, quitting: bool = False):
489        """AI → Stop Ollama Server: the server the app started, with the
490        model runner it started (killing only the server would leave that
491        running, holding the model's memory)."""
492        if not self._ollama_running():
493            return
494        if not quitting:
495            self.stop_review()
496        kill_process_tree(self._ollama_process)
497        self._ollama_process.waitForFinished(3000)
498        self._ollama_process = None
499        self._update_ollama_action()
500        if not quitting:
501            self.statusBar().showMessage("Stopped the Ollama server (it starts again when the AI is next used)", 5000)
502
503    def _shutdown_ai(self):
504        if self._ai_worker is not None:
505            self._ai_worker.cancel()
506        if self._ai_thread is not None:
507            self._ai_thread.quit()
508            self._ai_thread.wait(3000)
509        self.stop_ollama(quitting=True)

The AI menu, Ollama and AI reviews (a part of vibefpmgui.main_window.MainWindow).

def start_review(self):
219    def start_review(self):
220        editor = self.current_editor()
221        if editor is None:
222            QMessageBox.information(self, "AI review", "Open a file in the editor first "
223                                    "(double-click a file, or File → Open File in Editor).")
224            return
225        if not self._can_ask("AI review"):
226            return
227        self._ai_editor = editor
228        self._after_model = self._run_review
229        focus = ai.FOCUS_LABELS.get(self.ai_focus, self.ai_focus)
230        self._add_entry(Entry("review", focus, editor.path, editor.path.name, self.ai_model))
231        self._ensure_ollama(self._check_model)
def stop_review(self):
477    def stop_review(self):
478        if self._ai_worker is not None:
479            self._ai_worker.cancel()
def stop_ollama(self, quitting: bool = False):
488    def stop_ollama(self, quitting: bool = False):
489        """AI → Stop Ollama Server: the server the app started, with the
490        model runner it started (killing only the server would leave that
491        running, holding the model's memory)."""
492        if not self._ollama_running():
493            return
494        if not quitting:
495            self.stop_review()
496        kill_process_tree(self._ollama_process)
497        self._ollama_process.waitForFinished(3000)
498        self._ollama_process = None
499        self._update_ollama_action()
500        if not quitting:
501            self.statusBar().showMessage("Stopped the Ollama server (it starts again when the AI is next used)", 5000)

AI → Stop Ollama Server: the server the app started, with the model runner it started (killing only the server would leave that running, holding the model's memory).