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_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).