Repository navigation
feat: Local | With AI switch, small settings gear, quick provider/model selection #65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cdf8c62
7c2c61f
ca92ef2
43fd714
889984d
f785b50
358d8ad
9efd881
a5e85b7
3882d78
fd49403
250c302
ad4ea5b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| --- | ||
| name: gui-usability-review | ||
| description: Reproducible usability review of the Blitztext main window - offscreen clicks, screenshots, hard accessibility checks and a blind LLM rating. Use after changing app/main_window.py, theme.py or popup/toolbar layout. | ||
| --- | ||
|
|
||
| # GUI usability review | ||
|
|
||
| ## Purpose | ||
| Measure how quickly a user reaches common goals in the Qt main window and catch | ||
| accessibility defects, without a desktop session. | ||
|
|
||
| ## Trigger | ||
| Any change to `app/main_window.py`, `app/theme.py`, toolbar/popup controls or the | ||
| controller methods the window calls. | ||
|
|
||
| ## Inputs | ||
| - `scripts/gui_usability_check.py` (task list `TASKS`, click budgets) | ||
| - `tests/test_gui_usability.py` (hard gates) | ||
|
|
||
| ## Procedure | ||
| 1. Run the driver: `QT_QPA_PLATFORM=offscreen .venv/bin/python scripts/gui_usability_check.py`. | ||
| It prints a fresh temp directory containing `report.json` and one PNG per click step; copy what you want to keep. | ||
| 2. Hard gates (no LLM): `WHISPER_GUI_TESTS=1 QT_QPA_PLATFORM=offscreen .venv/bin/python -m pytest -q tests/test_gui_usability.py`. | ||
| Click budgets, goal reached, accessible names, keyboard focus, minimum target 22 px. | ||
| 3. Blind LLM rating, at least 3 independent runs per task. The reviewer gets ONLY | ||
| the task text and the PNG sequence of that task - never code, never this file's | ||
| results. Use a fresh subagent per run. | ||
| 4. Reviewer output, JSON only: | ||
| `{"task": id, "score": 1-5, "findings": [{"widget": "...", "severity": "low|medium|high", "issue": "..."}], "suggestion": "..."}` | ||
| 5. Score = mean over runs. Store under `.omc/uebergabe/gui-review/<date>/`, compare with the frozen baseline. | ||
|
|
||
| ## Rubric (score 1-5 per task) | ||
| Findability of the control, number of steps, label clarity, feedback after the | ||
| action (is the new state visible?), error/edge handling, target size and contrast. | ||
| 5 = obvious and immediate, 3 = works but needs hunting, 1 = most users fail. | ||
|
|
||
| ## Verification | ||
| Hard gates green; mean score not below the baseline; no high-severity finding unaddressed. | ||
|
|
||
| ## Rollback / abort | ||
| The driver only reads the UI with a fake controller; nothing to roll back. Abort the | ||
| review if the screenshots are blank (check `QT_QPA_PLATFORM=offscreen`). | ||
|
|
||
| ## Known pitfalls | ||
| - A drop-down counts as two clicks (open, choose); the driver sets the combo index directly, so budgets are assumed clicks, not measured ones. | ||
| - Offscreen renders no native Wayland decorations or real fonts; judge layout, not font metrics. | ||
| - Setting a combo to its current value emits no signal; set a different state first. | ||
| - Reviewer prompts must never include the source, or the rating is no longer blind. | ||
|
|
||
| ## References | ||
| `app/main_window.py`, `scripts/gui_usability_check.py`, Nielsen's 10 usability heuristics. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1067,6 +1067,95 @@ def main_window_preset_changed(self, key: str) -> None: | |
| self._refresh_preset_menu() | ||
| logger.info("Writing preset changed via main window: %s", key) | ||
|
|
||
| def main_window_provider_changed(self, provider: str) -> None: | ||
| """Switch between OpenAI and OpenRouter from the main window. | ||
|
|
||
| A custom endpoint is only changed in the settings dialog: leaving it here would | ||
| overwrite the user's URL and key variable, and entering it here could pair a stale | ||
| URL with an OpenAI key. Without a usable key the switch is refused. | ||
| """ | ||
| cfg = self.config | ||
| if provider == cfg.llm_provider: | ||
| return | ||
| previous = (cfg.llm_provider, cfg.llm_base_url, cfg.openai_api_key_env) | ||
| problem = None | ||
| if provider == "custom" or cfg.llm_provider == "custom": | ||
| problem = (t("settings.save_error.title"), t("mainwindow.provider.custom_in_settings.message")) | ||
| else: | ||
| if cfg.llm_provider == "openai": | ||
| # Remember a personal OpenAI key variable so the way back restores it. | ||
| self._openai_key_env_memo = cfg.openai_api_key_env | ||
| cfg.llm_provider = provider | ||
| if provider == "openrouter": | ||
| cfg.llm_base_url = "https://openrouter.ai/api/v1" | ||
| cfg.openai_api_key_env = "OPENROUTER_API_KEY" | ||
| else: | ||
| cfg.llm_base_url = "" | ||
| cfg.openai_api_key_env = getattr( | ||
| self, "_openai_key_env_memo", DEFAULTS["openai_api_key_env"] | ||
| ) | ||
|
Comment on lines
+1093
to
+1096
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a user configured OpenAI with a nondefault variable such as Useful? React with 👍 / 👎. |
||
| if not cfg.resolve_llm_api_key(): | ||
| problem = ( | ||
| t("mainwindow.provider.no_key.title"), | ||
| t("mainwindow.provider.no_key.message").format(env=cfg.openai_api_key_env), | ||
| ) | ||
| if problem is None: | ||
| try: | ||
| cfg.save() | ||
| except Exception as exc: | ||
| logger.error("Failed to save LLM provider: %s", exc) | ||
| problem = ( | ||
| t("settings.save_error.title"), | ||
| t("settings.save_error.message").format(error=exc), | ||
| ) | ||
| if problem is not None: | ||
| cfg.llm_provider, cfg.llm_base_url, cfg.openai_api_key_env = previous | ||
| if self._main_window is not None: | ||
| self._main_window.set_llm_provider(cfg.llm_provider) | ||
| self.show_tray_warning(*problem) | ||
| return | ||
| self._rebuild_llm_service() | ||
| self.update_menu_availability() | ||
| if self._main_window is not None: | ||
| self._main_window.set_llm_provider(provider) | ||
| logger.info("LLM provider changed via main window: %s", provider) | ||
|
|
||
| def _confirm_model_download(self, model: str) -> bool: | ||
| if self._main_window is not None: | ||
| # The selector popup is a Qt.Popup; a modal box over it would be unreliable. | ||
| self._main_window._options_popup.hide() | ||
| answer = QMessageBox.question( | ||
| self._main_window, | ||
| t("mainwindow.model.download.title"), | ||
| t("mainwindow.model.download.message").format(model=model), | ||
| ) | ||
| return answer == QMessageBox.StandardButton.Yes | ||
|
|
||
| def main_window_model_changed(self, model: str) -> None: | ||
| """Switch the Whisper model; larger models ask first because they may download.""" | ||
| cfg = self.config | ||
| if model == cfg.model: | ||
| return | ||
| previous = cfg.model | ||
| if model not in ("tiny", "base") and not self._confirm_model_download(model): | ||
| if self._main_window is not None: | ||
| self._main_window.set_model(previous) | ||
| return | ||
| cfg.model = model | ||
| try: | ||
| cfg.save() | ||
| except Exception as exc: | ||
| cfg.model = previous | ||
| if self._main_window is not None: | ||
| self._main_window.set_model(previous) | ||
| logger.error("Failed to save model: %s", exc) | ||
| self.show_tray_warning( | ||
| t("settings.save_error.title"), | ||
| t("settings.save_error.message").format(error=exc), | ||
| ) | ||
| return | ||
| logger.info("Whisper model changed via main window: %s", model) | ||
|
|
||
| def start_hotkey_worker(self) -> None: | ||
| self.stop_hotkey_worker() | ||
|
|
||
|
|
@@ -1120,6 +1209,8 @@ def show_settings_dialog(self) -> None: | |
| self._refresh_preset_menu() | ||
| if self._main_window is not None: | ||
| self._main_window.set_preset(self.config.writing_preset) | ||
| self._main_window.set_llm_provider(self.config.llm_provider) | ||
| self._main_window.set_model(self.config.model) | ||
|
|
||
| # Restart hotkey listener if mode or key changed | ||
| if self.hotkey_worker and ( | ||
|
|
@@ -1194,6 +1285,13 @@ def gui_toggle_recording(self, workflow: WorkflowType) -> None: | |
| Fallback wenn der Hotkey nicht greift oder keine Tastatur zur Hand ist. | ||
| """ | ||
| if self.state == "IDLE": | ||
| if workflow in LLM_WORKFLOWS and not self.llm_service.is_available(): | ||
| # Fail before speaking: otherwise the transcript is lost at the rewrite step. | ||
| self.show_tray_warning( | ||
| t("mainwindow.provider.no_key.title"), | ||
| t("mainwindow.provider.no_key.message").format(env=self.config.openai_api_key_env), | ||
| ) | ||
| return | ||
| self._start_recording(workflow) | ||
| elif self.state == "RECORDING": | ||
| self._stop_recording_and_process() | ||
|
|
@@ -1456,6 +1554,8 @@ def _ensure_main_window(self) -> MainWindow: | |
| window.set_history_count(self._history_panel.entry_count) | ||
| window.update_state(self.state, self.current_workflow, self._tray_error_message) | ||
| window.set_preset(self.config.writing_preset) | ||
| window.set_llm_provider(self.config.llm_provider) | ||
| window.set_model(self.config.model) | ||
| self._main_window = window | ||
| return self._main_window | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an OpenRouter configuration uses a normal namespaced model ID such as
openai/gpt-4o, selecting OpenAI here changes the provider, URL, and key but leavescfg.llm_modeluntouched;_build_llm_service()then sends that OpenRouter ID to OpenAI, causing every subsequent AI workflow to fail. The reverse switch can likewise send an OpenAI ID such asgpt-4o-minito OpenRouter, whose catalog uses namespaced IDs. Preserve a model per provider or require selecting a compatible model during the switch. OpenRouter model IDs OpenAI model IDsUseful? React with 👍 / 👎.