[office/crow-translate] /: LocalAI hotfixes + despaghettification of AI Vision OCR into an implementation of modular OCR

Maciej Bonin <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 5f16d059b4e983ac29be37965fa655459d59727a by Maciej Bonin.
Committed on 17/08/2026 at 21:10.
Pushed by pillowtrucker into branch 'master'.

LocalAI hotfixes + despaghettification of AI Vision OCR into an implementation of modular OCR

## What this MR does

Four milestones, in commit order:

### 1. URL normalization
Users paste either a base URL (SDK style) or the full endpoint from docs. `completionsUrl()`/`modelsUrl()` detect which shape was entered instead of blindly appending suffixes. A base that carries its own path (e.g. z.ai `https://api.z.ai/api/coding/paas/v4`) is treated as the complete SDK base: only the kind suffix is appended, never a second version segment.

### 2. Despaghettify AI Vision OCR into a modular OCR engine
Vision (translate an image) used to live INSIDE `LocalAiTranslationProvider`: `setSourceImage()` stashed image bytes on the translation provider, and `sendTranslation()` branched into a fused multimodal call that OCRed and translated in ONE request. Screen-capture OCR (Tesseract) was a completely separate pipeline sharing nothing with it.

OCR is now a provider concept:
- New `AOcrProvider` interface (`src/ocr/aocrprovider.h`): `recognize(image)`, `recognized`/`failed`/`canceled` signals. `Ocr` is renamed `TesseractOcr` and implements it; D-Bus object path unchanged.
- New `LlmOcr` engine sends the image to an OpenAI/Anthropic-compatible vision model with a **transcription-only** prompt and emits the recognized text - no translation happens inside it.
- MainWindow's image input (drag&drop/paste/file, preview overlay, Escape) routes through the configured OCR engine and recognizes a dropped image immediately; recognized text lands in the source edit and is translated by whatever translation backend is active - vision works with Google/Mozhi/LibreTranslate/LocalAI alike, and LLM OCR works for pure recognition too.
- Settings gain an engine picker on the OCR page (Tesseract / vision model). LLM OCR reuses the LocalAI provider's URL/key and gets a live vision-model probe (`VisionModelProbe`, native Ollama/LM Studio capability reporting where the server offers it) plus its own model/prompt/timeout.
- Shared OpenAI/Anthropic request-shape helpers (`llm/openaiendpoint.{h,cpp}`) extracted out of the translation provider.
- `LocalAiTranslationProvider` is back to pure text-in/text-out: all vision state, the multimodal branch and the old per-mode settings are deleted; `ATranslationProvider`'s image virtuals are removed.
- Dead settings dropped without migration (the old fused prompt is semantically wrong for OCR transcription). Language detection is honest about what it is, and the detection prompt is source-agnostic.

### 3. Module status strip
Nothing in the UI said what was actually happening once you pressed a button. `ModuleStatus` is a shared "what is running" model fed by both OCR engines, the screen grabber/snipping area, the active translator and the active TTS provider; `StatusStrip` renders it at the bottom of the main window and the popup, and removes itself cleanly from AT-SPI when hidden. LocalAI translation and Mozhi also report source-language detection through the same strip. A permanent `recognized -> replaceText` connection deliberately suppresses `textEdited` so armed OCR flows can chain their own translation - `handleRecognizedText` now runs the same follow-ups typed text gets (translate-button state, TTS button state, locale autodetection, auto-translation) whenever no armed connection owns the recognition, fixing an unarmed recognition (plain recognize-screen-area, or an image with auto-translate off) leaving the UI stuck until the next keystroke.

### 4. Fix duplicated OCR transcriptions from repeat-prone local models
Small local vision models (verified against a real Ollama instance running a glm-ocr model) reliably finish the correct transcription, then - lacking a clean stop token for the task - wrap it in a markdown fence and loop re-emitting the same text until they exhaust their token budget, so a single response lands doubled (or worse) in the source edit. A stop sequence on the fence marker cuts generation the instant that repeat would start: confirmed against the real model, completion tokens dropped from ~3900 to ~50 and the response is exactly the clean transcription. Also sends `reasoning_effort: none` when the provider's "Disable reasoning" setting is on, matching `LocalAiTranslationProvider`, for genuinely reasoning-capable vision models.

## Testing
- In-tree unit tests pin both URL helpers, the LlmOcr wire shape (multimodal request, transcription prompt, stop sequence, error/timeout paths), the ModuleStatus model, StatusStrip rendering, and everyday MainWindow features end-to-end against `MockHttpServer`.
- `tests/test_llmocr_live.cpp` is a live regression test that drives the real `LlmOcr` against a real local Ollama instance and vision-OCR model (`CROW_TEST_OLLAMA_MODEL`/`CROW_TEST_OLLAMA_URL` override which) - a mocked HTTP response can't reproduce a model's own decoding pathology, only a real one can prove the duplicated-transcription fix holds. It self-skips, never fails, when Ollama or the model isn't available, so any developer can run it without it ever blocking CI.
- Existing LocalAI provider tests unchanged and passing.
- Verified live against z.ai: `.../v4/v1/models` 404s, `.../v4/models` returns the model list.

M  +16   -2    CMakeLists.txt
A  +146  -0    src/llm/openaiendpoint.cpp     [License: GPL(v3.0+)]
A  +48   -0    src/llm/openaiendpoint.h     [License: GPL(v3.0+)]
A  +197  -0    src/llm/visionmodelprobe.cpp     [License: GPL(v3.0+)]
A  +66   -0    src/llm/visionmodelprobe.h     [License: GPL(v3.0+)]
M  +1    -1    src/main.cpp
M  +310  -154  src/mainwindow.cpp
M  +36   -3    src/mainwindow.h
M  +16   -5    src/mainwindow.ui
A  +294  -0    src/modulestatus.cpp     [License: GPL(v3.0+)]
A  +130  -0    src/modulestatus.h     [License: GPL(v3.0+)]
A  +39   -0    src/ocr/aocrprovider.h     [License: GPL(v3.0+)]
A  +281  -0    src/ocr/llmocr.cpp     [License: GPL(v3.0+)]
A  +67   -0    src/ocr/llmocr.h     [License: GPL(v3.0+)]
M  +6    -0    src/ocr/snippingarea.cpp
R  +25   -15   src/ocr/tesseractocr.cpp [from: src/ocr/ocr.cpp - 081% similarity]
R  +15   -14   src/ocr/tesseractocr.h [from: src/ocr/ocr.h - 063% similarity]
M  +6    -0    src/popupwindow.cpp
M  +37   -21   src/popupwindow.ui
M  +0    -8    src/provideroptionsmanager.cpp
M  +150  -78   src/settings/appsettings.cpp
M  +39   -14   src/settings/appsettings.h
M  +368  -272  src/settings/settingsdialog.cpp
M  +34   -7    src/settings/settingsdialog.h
M  +33   -17   src/settings/settingsdialog.ui
A  +212  -0    src/statusstrip.cpp     [License: GPL(v3.0+)]
A  +66   -0    src/statusstrip.h     [License: GPL(v3.0+)]
A  +33   -0    src/statusstrip.ui
M  +4    -10   src/translator/atranslationprovider.h
M  +35   -143  src/translator/localaitranslationprovider.cpp
M  +0    -19   src/translator/localaitranslationprovider.h
M  +8    -0    src/translator/mozhitranslationprovider.cpp
A  +38   -0    src/translator/translationlogic.cpp     [License: GPL(v3.0+)]
A  +38   -0    src/translator/translationlogic.h     [License: GPL(v3.0+)]
M  +109  -2    tests/CMakeLists.txt
A  +117  -0    tests/test_appsettings_prompts.cpp     [License: GPL(v3.0+)]
A  +338  -0    tests/test_llmocr.cpp     [License: GPL(v3.0+)]
A  +132  -0    tests/test_llmocr_live.cpp     [License: GPL(v3.0+)]
M  +140  -3    tests/test_localai_provider.cpp
A  +468  -0    tests/test_mainwindow_features.cpp     [License: GPL(v3.0+)]
A  +238  -0    tests/test_mainwindow_status.cpp     [License: GPL(v3.0+)]
A  +539  -0    tests/test_modulestatus.cpp     [License: GPL(v3.0+)]
M  +49   -3    tests/test_settingsdialog_localai.cpp
A  +104  -0    tests/test_snippingarea_terminal.cpp     [License: GPL(v3.0+)]
A  +278  -0    tests/test_statusstrip.cpp     [License: GPL(v3.0+)]
M  +1    -1    tests/test_translation.cpp
A  +74   -0    tests/test_translationlogic.cpp     [License: GPL(v3.0+)]

https://invent.kde.org/office/crow-translate/-/commit/5f16d059b4e983ac29be37965fa655459d59727a

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 7b11aa6f..2135a4a8 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -170,8 +170,13 @@ add_library(${PROJECT_NAME}-lib STATIC
     src/mainwindow.h
     src/mainwindow.cpp
     src/mainwindow.ui
-    src/ocr/ocr.h
-    src/ocr/ocr.cpp
+    src/modulestatus.h
+    src/modulestatus.cpp
+    src/ocr/aocrprovider.h
+    src/ocr/tesseractocr.h
+    src/ocr/tesseractocr.cpp
+    src/ocr/llmocr.h
+    src/ocr/llmocr.cpp
     src/ocr/screengrabbers/abstractscreengrabber.h
     src/ocr/screengrabbers/abstractscreengrabber.cpp
     src/ocr/screengrabbers/genericscreengrabber.h
@@ -185,6 +190,9 @@ add_library(${PROJECT_NAME}-lib STATIC
     src/popupwindow.ui
     src/screenwatcher.h
     src/screenwatcher.cpp
+    src/statusstrip.h
+    src/statusstrip.cpp
+    src/statusstrip.ui
     src/selection.h
     src/selection.cpp
     src/settings/appsettings.h
@@ -226,6 +234,12 @@ add_library(${PROJECT_NAME}-lib STATIC
     src/translator/mozhitranslationprovider.cpp
     src/translator/localaitranslationprovider.h
     src/translator/localaitranslationprovider.cpp
+    src/translator/translationlogic.h
+    src/translator/translationlogic.cpp
+    src/llm/openaiendpoint.h
+    src/llm/openaiendpoint.cpp
+    src/llm/visionmodelprobe.h
+    src/llm/visionmodelprobe.cpp
     src/provideroptions.h
     src/provideroptions.cpp
     src/provideroptionsmanager.h
diff --git a/src/llm/openaiendpoint.cpp b/src/llm/openaiendpoint.cpp
new file mode 100644
index 00000000..ac4dfcc6
--- /dev/null
+++ b/src/llm/openaiendpoint.cpp
@@ -0,0 +1,146 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Oleksandr Mikriukov <[email protected]>
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#include "openaiendpoint.h"
+
+#include <QByteArray>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QNetworkRequest>
+#include <QStringList>
+#include <QUrl>
+
+namespace OpenAiEndpoint
+{
+
+QString completionsUrl(const QString &baseUrl, bool isAnthropic)
+{
+    // Users paste either a base URL ("https://api.example.com/v1", as handed
+    // to official SDKs) or the full endpoint they got from docs
+    // (".../v1/chat/completions"). Always appending the suffix produced a
+    // doubled path for the latter. Detect which one was entered and normalize:
+    // strip trailing slashes, then append the kind-specific suffix only when
+    // the URL does not already end in it. Only suffixes this code actually
+    // builds a matching request body for are recognized here - notably NOT
+    // Ollama's native "/api/chat" (its request/response shape differs from
+    // the OpenAI-compatible one callers always send), so a URL pointed there
+    // falls through to getting a suffix appended instead of being silently
+    // treated as complete. Ollama also serves an OpenAI-compatible
+    // "/v1/chat/completions" endpoint, which is what this code targets by
+    // default.
+    QString base = baseUrl.trimmed();
+    while (base.endsWith(QLatin1Char('/'))) {
+        base.chop(1);
+    }
+
+    static const QStringList kCompletionsSuffixes = {
+        QStringLiteral("/v1/chat/completions"),
+        QStringLiteral("/v1/messages"), // Anthropic
+    };
+
+    for (const QString &suffix : kCompletionsSuffixes) {
+        if (base.endsWith(suffix, Qt::CaseInsensitive)) {
+            // Already a full completions endpoint: use as typed. An
+            // Anthropic-style "/v1/messages" URL in an OpenAI-kind provider
+            // (or vice versa) is still honored as-is - the user pointed at a
+            // concrete endpoint and knows better than the suffix defaults.
+            return base;
+        }
+    }
+    // A base that already carries a path (e.g. z.ai's
+    // ".../api/coding/paas/v4") is the complete SDK base URL: append only the
+    // kind suffix, as the official SDK would. Only a bare host (no path,
+    // crow's default "...:11434") needs the conventional "/v1" segment
+    // prepended.
+    const QUrl url(base);
+    const QString path = url.path();
+    const bool bareHost = path.isEmpty() || path == QLatin1String("/");
+    if (isAnthropic) {
+        return base + QStringLiteral("/v1/messages");
+    }
+    return bareHost ? base + QStringLiteral("/v1/chat/completions")
+                    : base + QStringLiteral("/chat/completions");
+}
+
+QString serverRoot(const QString &baseUrl)
+{
+    // If the user pasted a full endpoint URL (any of the completions shapes),
+    // derive its base by stripping the endpoint path so a probe does not end
+    // up at ".../chat/completions/v1/models".
+    QString base = baseUrl.trimmed();
+    while (base.endsWith(QLatin1Char('/'))) {
+        base.chop(1);
+    }
+
+    static const QStringList kEndpointSuffixes = {
+        QStringLiteral("/v1/chat/completions"),
+        QStringLiteral("/v1/messages"),
+        QStringLiteral("/v1/models"),
+    };
+
+    for (const QString &suffix : kEndpointSuffixes) {
+        if (base.endsWith(suffix, Qt::CaseInsensitive)) {
+            base.chop(suffix.size());
+            break;
+        }
+    }
+    while (base.endsWith(QLatin1Char('/'))) {
+        base.chop(1);
+    }
+    return base;
+}
+
+QString modelsUrl(const QString &baseUrl)
+{
+    const QString base = serverRoot(baseUrl);
+    // A base that already carries a path (e.g. z.ai's
+    // ".../api/coding/paas/v4") is the complete SDK base: the probe lives at
+    // "<base>/models". A bare host (no path) needs the conventional "/v1"
+    // segment: "<host>/v1/models".
+    const QUrl url(base);
+    const QString path = url.path();
+    if (path.isEmpty() || path == QLatin1String("/")) {
+        return base + QStringLiteral("/v1/models");
+    }
+    return base + QStringLiteral("/models");
+}
+
+void setAuthHeaders(QNetworkRequest &request, bool isAnthropic, const QString &apiKey)
+{
+    if (apiKey.isEmpty()) {
+        return;
+    }
+    if (isAnthropic) {
+        request.setRawHeader("x-api-key", apiKey.toUtf8());
+        request.setRawHeader("anthropic-version", "2023-06-01");
+    } else {
+        request.setRawHeader("Authorization", "Bearer " + apiKey.toUtf8());
+    }
+}
+
+QString extractContent(const QByteArray &data, bool isAnthropic)
+{
+    const QJsonObject obj = QJsonDocument::fromJson(data).object();
+    if (isAnthropic) {
+        const QJsonArray blocks = obj.value(QStringLiteral("content")).toArray();
+        for (const QJsonValue &block : blocks) {
+            const QJsonObject blockObj = block.toObject();
+            if (blockObj.value(QStringLiteral("type")).toString() == QLatin1String("text")) {
+                return blockObj.value(QStringLiteral("text")).toString();
+            }
+        }
+        return QString();
+    }
+    const QJsonArray choices = obj.value(QStringLiteral("choices")).toArray();
+    if (!choices.isEmpty()) {
+        return choices.first().toObject().value(QStringLiteral("message")).toObject().value(QStringLiteral("content")).toString();
+    }
+    // Fallback for Ollama native shape, just in case.
+    return obj.value(QStringLiteral("message")).toObject().value(QStringLiteral("content")).toString();
+}
+
+} // namespace OpenAiEndpoint
diff --git a/src/llm/openaiendpoint.h b/src/llm/openaiendpoint.h
new file mode 100644
index 00000000..57a90068
--- /dev/null
+++ b/src/llm/openaiendpoint.h
@@ -0,0 +1,48 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Oleksandr Mikriukov <[email protected]>
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef OPENAIENDPOINT_H
+#define OPENAIENDPOINT_H
+
+#include <QString>
+
+class QByteArray;
+class QNetworkRequest;
+
+// Wire-level helpers for OpenAI-compatible and Anthropic-compatible HTTP
+// endpoints: turning whatever URL a user pasted into the concrete endpoint to
+// call, attaching the right auth headers, and pulling the assistant text back
+// out of a response.
+//
+// Deliberately provider-neutral and free of any translation or OCR concept:
+// LocalAiTranslationProvider and LlmOcr are independently configured features
+// that happen to speak the same two API shapes, so neither should have to
+// include the other to talk to its own endpoint.
+namespace OpenAiEndpoint
+{
+
+// Endpoint to POST a chat completion / message to.
+QString completionsUrl(const QString &baseUrl, bool isAnthropic);
+
+// Endpoint to GET the model list from.
+QString modelsUrl(const QString &baseUrl);
+
+// The server root a pasted URL refers to, with any trailing slashes and any
+// recognized endpoint path stripped. Callers that need a non-OpenAI path on
+// the same host - Ollama's "/api/tags", LM Studio's "/api/v0/models" - build
+// it from here so they honor the same paste-anything normalization.
+QString serverRoot(const QString &baseUrl);
+
+// Bearer for OpenAI-compatible, x-api-key + anthropic-version for Anthropic.
+// A blank key leaves the request untouched (local servers need no auth).
+void setAuthHeaders(QNetworkRequest &request, bool isAnthropic, const QString &apiKey);
+
+// Assistant text out of a completions/messages response body.
+QString extractContent(const QByteArray &data, bool isAnthropic);
+
+} // namespace OpenAiEndpoint
+
+#endif // OPENAIENDPOINT_H
diff --git a/src/llm/visionmodelprobe.cpp b/src/llm/visionmodelprobe.cpp
new file mode 100644
index 00000000..733400ab
--- /dev/null
+++ b/src/llm/visionmodelprobe.cpp
@@ -0,0 +1,197 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#include "visionmodelprobe.h"
+
+#include "openaiendpoint.h"
+
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QNetworkRequest>
+
+namespace
+{
+
+constexpr int kProbeTimeoutMs = 5000;
+
+bool hasVisionCapability(const QJsonObject &model)
+{
+    // Ollama: "capabilities": ["completion", "vision", ...]
+    const QJsonArray capabilities = model.value(QStringLiteral("capabilities")).toArray();
+    for (const QJsonValue &capability : capabilities) {
+        if (capability.toString().compare(QLatin1String("vision"), Qt::CaseInsensitive) == 0) {
+            return true;
+        }
+    }
+    // LM Studio reports it as a flag, and types its vision-language models
+    // "vlm" rather than "llm". Accept both spellings - its native API is not
+    // versioned in a way worth betting a feature on.
+    if (model.value(QStringLiteral("vision")).toBool()) {
+        return true;
+    }
+    return model.value(QStringLiteral("type")).toString().compare(QLatin1String("vlm"), Qt::CaseInsensitive) == 0;
+}
+
+} // namespace
+
+VisionModelProbe::VisionModelProbe(QObject *parent)
+    : QObject(parent)
+    , m_network(new QNetworkAccessManager(this))
+{
+    m_network->setTransferTimeout(kProbeTimeoutMs);
+}
+
+VisionModelProbe::~VisionModelProbe()
+{
+    if (m_reply != nullptr) {
+        m_reply->disconnect(this);
+        m_reply->abort();
+        m_reply->deleteLater();
+    }
+}
+
+bool VisionModelProbe::reportsCapabilities(const QString &providerId)
+{
+    return providerId == QLatin1String("ollama") || providerId == QLatin1String("lmstudio");
+}
+
+void VisionModelProbe::cancel()
+{
+    if (m_reply != nullptr) {
+        m_reply->disconnect(this);
+        m_reply->abort();
+        m_reply->deleteLater();
+        m_reply = nullptr;
+    }
+}
+
+void VisionModelProbe::probe(const QString &providerId, const QString &baseUrl, const QString &apiKey)
+{
+    cancel();
+    m_providerId = providerId;
+    m_baseUrl = baseUrl;
+    m_apiKey = apiKey;
+
+    if (!reportsCapabilities(providerId)) {
+        requestOpenAiModels(baseUrl, apiKey);
+        return;
+    }
+
+    const QString root = OpenAiEndpoint::serverRoot(baseUrl);
+    const QString nativeUrl = providerId == QLatin1String("ollama")
+        ? root + QStringLiteral("/api/tags")
+        : root + QStringLiteral("/api/v0/models");
+
+    QNetworkRequest request{QUrl(nativeUrl)};
+    OpenAiEndpoint::setAuthHeaders(request, false, apiKey);
+    m_reply = m_network->get(request);
+    connect(m_reply, &QNetworkReply::finished, this, &VisionModelProbe::onNativeFinished);
+}
+
+void VisionModelProbe::requestOpenAiModels(const QString &baseUrl, const QString &apiKey)
+{
+    QNetworkRequest request{QUrl(OpenAiEndpoint::modelsUrl(baseUrl))};
+    OpenAiEndpoint::setAuthHeaders(request, m_providerId == QLatin1String("anthropic"), apiKey);
+    m_reply = m_network->get(request);
+    connect(m_reply, &QNetworkReply::finished, this, &VisionModelProbe::onOpenAiFinished);
+}
+
+void VisionModelProbe::onNativeFinished()
+{
+    QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
+    if (reply == nullptr || reply != m_reply) {
+        if (reply != nullptr) {
+            reply->deleteLater();
+        }
+        return;
+    }
+    m_reply = nullptr;
+    reply->deleteLater();
+
+    if (reply->error() != QNetworkReply::NoError) {
+        // The native endpoint is the only source of capability information,
+        // but it is not the only way to list models: an Ollama behind a proxy
+        // that only forwards /v1, or a newer LM Studio that moved its native
+        // API, still answers /v1/models. Fall back to the list without
+        // capabilities rather than failing the whole refresh.
+        requestOpenAiModels(m_baseUrl, m_apiKey);
+        return;
+    }
+
+    const QJsonObject root = QJsonDocument::fromJson(reply->readAll()).object();
+    // Ollama keys the array "models", LM Studio keys it "data".
+    QJsonArray models = root.value(QStringLiteral("models")).toArray();
+    if (models.isEmpty()) {
+        models = root.value(QStringLiteral("data")).toArray();
+    }
+
+    QStringList allModels;
+    QStringList visionModels;
+    for (const QJsonValue &value : std::as_const(models)) {
+        const QJsonObject model = value.toObject();
+        QString name = model.value(QStringLiteral("name")).toString();
+        if (name.isEmpty()) {
+            name = model.value(QStringLiteral("model")).toString();
+        }
+        if (name.isEmpty()) {
+            name = model.value(QStringLiteral("id")).toString();
+        }
+        if (name.isEmpty()) {
+            continue;
+        }
+        allModels.append(name);
+        if (hasVisionCapability(model)) {
+            visionModels.append(name);
+        }
+    }
+
+    if (allModels.isEmpty()) {
+        requestOpenAiModels(m_baseUrl, m_apiKey);
+        return;
+    }
+
+    emit finished(allModels, visionModels);
+}
+
+void VisionModelProbe::onOpenAiFinished()
+{
+    QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
+    if (reply == nullptr || reply != m_reply) {
+        if (reply != nullptr) {
+            reply->deleteLater();
+        }
+        return;
+    }
+    m_reply = nullptr;
+    reply->deleteLater();
+
+    if (reply->error() != QNetworkReply::NoError) {
+        emit failed(reply->errorString());
+        return;
+    }
+
+    const QJsonArray data = QJsonDocument::fromJson(reply->readAll()).object().value(QStringLiteral("data")).toArray();
+
+    QStringList allModels;
+    QStringList visionModels;
+    for (const QJsonValue &value : std::as_const(data)) {
+        const QJsonObject model = value.toObject();
+        const QString name = model.value(QStringLiteral("id")).toString();
+        if (name.isEmpty()) {
+            continue;
+        }
+        allModels.append(name);
+        // Almost never present on a /v1/models response, but an OpenAI-
+        // compatible server that does report it should be believed.
+        if (hasVisionCapability(model)) {
+            visionModels.append(name);
+        }
+    }
+
+    emit finished(allModels, visionModels);
+}
diff --git a/src/llm/visionmodelprobe.h b/src/llm/visionmodelprobe.h
new file mode 100644
index 00000000..03fcaf36
--- /dev/null
+++ b/src/llm/visionmodelprobe.h
@@ -0,0 +1,66 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef VISIONMODELPROBE_H
+#define VISIONMODELPROBE_H
+
+#include <QObject>
+#include <QString>
+#include <QStringList>
+
+class QNetworkAccessManager;
+class QNetworkReply;
+
+// Asks a server what models it has and, where the server is willing to say,
+// which of them accept images.
+//
+// Only two of the supported server kinds report capabilities at all: Ollama
+// inlines a "capabilities" array in /api/tags, and LM Studio reports a model
+// type/vision flag in its native /api/v0/models. The OpenAI-compatible
+// /v1/models response - which is all Anthropic, FastFlowLM and arbitrary
+// cloud endpoints offer - carries no capability information whatsoever.
+//
+// So "not in visionModels" means "this server did not tell us", never "this
+// model cannot do vision". Callers must present the two lists as
+// proven/unproven and must not filter the unproven ones away: a correct
+// vision model behind a plain /v1/models endpoint would vanish from the UI.
+class VisionModelProbe : public QObject
+{
+    Q_OBJECT
+    Q_DISABLE_COPY(VisionModelProbe)
+
+public:
+    explicit VisionModelProbe(QObject *parent = nullptr);
+    ~VisionModelProbe() override;
+
+    // providerId selects the probing strategy; baseUrl is whatever the user
+    // typed and is normalized through OpenAiEndpoint.
+    void probe(const QString &providerId, const QString &baseUrl, const QString &apiKey);
+    void cancel();
+
+    // True when the provider kind can report vision capability at all, i.e.
+    // when an empty visionModels list is meaningful rather than merely
+    // uninformative.
+    static bool reportsCapabilities(const QString &providerId);
+
+signals:
+    // allModels is everything the server listed, in server order.
+    // visionModels is the subset it explicitly marked as image-capable.
+    void finished(const QStringList &allModels, const QStringList &visionModels);
+    void failed(const QString &error);
+
+private:
+    void requestOpenAiModels(const QString &baseUrl, const QString &apiKey);
+    void onNativeFinished();
+    void onOpenAiFinished();
+
+    QNetworkAccessManager *m_network;
+    QNetworkReply *m_reply = nullptr;
+    QString m_providerId;
+    QString m_baseUrl;
+    QString m_apiKey;
+};
+
+#endif // VISIONMODELPROBE_H
diff --git a/src/main.cpp b/src/main.cpp
index f892e1d8..d2c31323 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -25,7 +25,7 @@
 #endif
 
 #ifdef Q_OS_UNIX
-#include "ocr/ocr.h"
+#include "ocr/tesseractocr.h"
 
 #include <QDBusConnection>
 #include <QDBusError>
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index a6486ad9..2f87b005 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -9,28 +9,34 @@
 #include "mainwindow.h"
 #include "ui_mainwindow.h"
 
+#include "modulestatus.h"
 #include "popupwindow.h"
 #include "provideroptions.h"
 #include "provideroptionsmanager.h"
 #include "screenwatcher.h"
 #include "selection.h"
 #include "singleapplication.h"
+#include "statusstrip.h"
+#include "ocr/aocrprovider.h"
+#include "ocr/llmocr.h"
 #include "ocr/screengrabbers/abstractscreengrabber.h"
+#include "ocr/tesseractocr.h"
 #include "settings/appsettings.h"
 #include "settings/settingsdialog.h"
 #include "translator/atranslationprovider.h"
+#include "translator/translationlogic.h"
 #include "tts/attsprovider.h"
 #include "tts/voice.h"
 
 #include <QAbstractItemView>
 #include <QApplication>
-#include <QBuffer>
 #include <QButtonGroup>
 #include <QClipboard>
 #include <QCloseEvent>
 #include <QDragEnterEvent>
 #include <QDropEvent>
 #include <QFile>
+#include <QFileDialog>
 #include <QFontMetrics>
 #include <QImage>
 #include <QKeyEvent>
@@ -38,6 +44,7 @@
 #include <QMessageBox>
 #include <QMimeData>
 #include <QSet>
+#include <QStandardPaths>
 #include <QTimer>
 
 #include <cassert>
@@ -59,14 +66,24 @@ MainWindow::MainWindow(QWidget *parent)
     , m_closeWindowsShortcut(new QShortcut(this))
     , ui(new Ui::MainWindow)
     , m_optionsManager(new ProviderOptionsManager(this))
-    , m_ocr(new Ocr())
+    , m_tesseractOcr(new TesseractOcr(this))
+    , m_llmOcr(new LlmOcr(this))
     , m_screenCaptureTimer(new QTimer(this))
     , m_snippingArea(new SnippingArea(this))
     , m_trayIcon(new TrayIcon(this))
     , m_orientationWatcher(new ScreenWatcher(this))
     , m_screenGrabber(AbstractScreenGrabber::createScreenGrabber(this))
+    , m_moduleStatus(new ModuleStatus(this))
 {
     ui->setupUi(this);
+
+    // The status strip lives in the status bar rather than centralLayout:
+    // setOrientation() reassigns that layout's direction per screen
+    // orientation, which would place the strip beside the columns in
+    // landscape and invert its position in InvertedPortraitOrientation.
+    m_statusStrip = new StatusStrip;
+    m_statusStrip->setModel(m_moduleStatus);
+    ui->statusbar->addWidget(m_statusStrip, 1);
     // Save original Mozhi engine combo items so they can be restored after
     // switching to LocalAI (which clears the combo).
     for (int i = 0; i < ui->engineComboBox->count(); ++i) {
@@ -81,8 +98,10 @@ MainWindow::MainWindow(QWidget *parent)
     ui->sourceEdit->setAccessibleName(QStringLiteral("sourceEdit"));
     ui->translationEdit->setAccessibleName(QStringLiteral("translationEdit"));
     ui->translateButton->setAccessibleName(QStringLiteral("translateButton"));
+    ui->abortButton->setAccessibleName(QStringLiteral("abortButton"));
     ui->sourcePlayPauseButton->setAccessibleName(QStringLiteral("sourcePlayPauseButton"));
     ui->translationPlayPauseButton->setAccessibleName(QStringLiteral("translationPlayPauseButton"));
+    ui->openImageButton->setAccessibleName(QStringLiteral("openImageButton"));
 
     // Screen orientation
     connect(m_orientationWatcher, &ScreenWatcher::screenOrientationChanged, this, &MainWindow::setOrientation);
@@ -110,15 +129,51 @@ MainWindow::MainWindow(QWidget *parent)
 
     // OCR logic
     connect(m_screenGrabber, &AbstractScreenGrabber::grabbed, m_snippingArea, &SnippingArea::snip);
-    connect(m_snippingArea, &SnippingArea::snipped, m_ocr, &Ocr::recognize);
-    connect(m_ocr, &Ocr::recognized, ui->sourceEdit, &SourceTextEdit::replaceText);
+    connect(m_snippingArea, &SnippingArea::snipped, this, [this](const QPixmap &pixmap, int dpi) {
+        activeOcr()->recognize(pixmap.toImage(), dpi);
+    });
+    // An abandoned snip must not leave a translation armed for whatever gets
+    // recognized next.
+    connect(m_snippingArea, &SnippingArea::cancelled, this, &MainWindow::disarmOcrTranslation);
+    // A failed grab never opens the snipping area, so it emits no SnippingArea
+    // signal either - the same armed-connection leak as an abandoned snip.
+    connect(m_screenGrabber, &AbstractScreenGrabber::grabbingFailed, this, &MainWindow::disarmOcrTranslation);
+    connect(m_tesseractOcr, &TesseractOcr::recognized, ui->sourceEdit, &SourceTextEdit::replaceText);
+    connect(m_llmOcr, &LlmOcr::recognized, ui->sourceEdit, &SourceTextEdit::replaceText);
+    // Text inserted through replaceText() deliberately suppresses
+    // SourceTextEdit::textEdited (the armed flows chain their own follow-up),
+    // so an UNarmed recognition - a plain recognize-screen-area, or an image
+    // with auto-translate off - used to leave the translate button disabled
+    // and language detection not running until the user typed something:
+    // any real edit fires textEdited, which drives all of these. Run the
+    // same follow-ups typed text gets. These permanent connections are
+    // created before any armed/temporary one, so the gate below sees the
+    // current armed state correctly.
+    const auto handleRecognizedText = [this]() {
+        if (m_ocrTranslateConnection)
+            return; // the armed path chains its own translation
+        updateTranslateButtonState();
+        updateTTSButtonStates();
+        updateAutoLocales();
+        handleAutoTranslation();
+    };
+    connect(m_tesseractOcr, &TesseractOcr::recognized, this, handleRecognizedText);
+    connect(m_llmOcr, &LlmOcr::recognized, this, handleRecognizedText);
     m_screenCaptureTimer->setSingleShot(true);
 
+    // Both engines and the grabber/snipping area are long-lived; bind them
+    // once. Both engines, not activeOcr(): the active one switches per
+    // settings read.
+    m_moduleStatus->bindOcr(m_tesseractOcr, m_llmOcr);
+    m_moduleStatus->bindCapture(m_screenGrabber, m_snippingArea);
+
     loadAppSettings();
     m_tts = ATTSProvider::createTTSProvider(this, m_chosenTTSBackend);
     connect(m_tts, &ATTSProvider::errorOccurred, this, &MainWindow::onTTSError);
+    m_moduleStatus->bindTtsProvider(m_tts);
     applyTTSProviderSettings();
     m_translator = ATranslationProvider::createTranslationProvider(this, m_chosenTranslationBackend);
+    m_moduleStatus->bindTranslator(m_translator);
 
     updateProviderUI();
 
@@ -287,9 +342,33 @@ MainWindow::~MainWindow()
     delete ui;
 }
 
-Ocr *MainWindow::ocr() const
+AOcrProvider *MainWindow::ocr() const
 {
-    return m_ocr;
+    return activeOcr();
+}
+
+TesseractOcr *MainWindow::tesseractOcr() const
+{
+    return m_tesseractOcr;
+}
+
+AOcrProvider *MainWindow::activeOcr() const
+{
+    if (AppSettings().ocrEngine() == AppSettings::OcrEngine::Llm) {
+        return m_llmOcr;
+    }
+    return m_tesseractOcr;
+}
+
+void MainWindow::configureLlmOcr()
+{
+    const AppSettings settings;
+    const QString providerId = settings.ocrLlmProvider();
+    m_llmOcr->setEndpoint(settings.localProviderUrl(providerId), AppSettings::localProviderIsAnthropic(providerId), settings.localProviderApiKey(providerId));
+    m_llmOcr->setModel(settings.ocrLlmModel(providerId));
+    m_llmOcr->setTimeout(settings.ocrLlmTimeout(providerId));
+    m_llmOcr->setPrompt(settings.ocrLlmPrompt(settings.ocrLlmModel(providerId)));
+    m_llmOcr->setDisableThinking(settings.localAiDisableThinking(providerId));
 }
 
 QComboBox *MainWindow::getEngineComboBox() const
@@ -362,6 +441,11 @@ SourceTextEdit *MainWindow::sourceEdit() const
     return ui->sourceEdit;
 }
 
+ModuleStatus *MainWindow::moduleStatus() const
+{
+    return m_moduleStatus;
+}
+
 QToolButton *MainWindow::sourcePlayPauseButton() const
 {
     return ui->sourcePlayPauseButton;
@@ -540,89 +624,102 @@ Q_SCRIPTABLE void MainWindow::copyTranslatedSelection()
     }
 }
 
-Q_SCRIPTABLE void MainWindow::recognizeScreenArea()
+// Common preamble for every OCR entry point. Configuring the LLM engine
+// before asking whether it is configured is not optional: LlmOcr::isConfigured()
+// reports the state of the last configureLlmOcr() call, not the state of the
+// settings, so checking first rejects an engine the user has just set up.
+bool MainWindow::prepareOcr()
 {
-    if (m_ocr->languagesString().isEmpty()) {
-        QMessageBox::critical(this, Ocr::tr("OCR languages are not loaded"), Ocr::tr("You should set at least one OCR language in the application settings"));
-        return;
+    configureLlmOcr();
+    if (!activeOcr()->isConfigured()) {
+        QMessageBox::critical(this, TesseractOcr::tr("OCR is not configured"), TesseractOcr::tr("Set up the OCR engine in the application settings"));
+        return false;
     }
 
-    AppSettings settings;
+    const AppSettings settings;
     if (settings.isForceSourceAutodetect()) {
         ui->sourceLanguagesWidget->checkAutoButton();
     }
     if (settings.isForceTranslationAutodetect()) {
         ui->translationLanguagesWidget->checkAutoButton();
     }
+    return true;
+}
 
-    if (m_screenGrabber != nullptr) {
-        m_screenGrabber->grab();
-    }
+// Chains one translation onto the next successful recognition. The recognized
+// text reaches the source edit through the permanent
+// AOcrProvider::recognized -> SourceTextEdit::replaceText connections; this
+// only adds the translation, so the text is never written twice.
+//
+// The connection is a member rather than a local shared_ptr because it has to
+// survive until it fires *or* until something cancels the capture - a snip
+// abandoned with Escape used to leave it armed, so the next recognition of any
+// kind (including a plain "recognize screen area") translated unexpectedly.
+void MainWindow::armOcrTranslation()
+{
+    disarmOcrTranslation();
+    m_ocrTranslateConnection = connect(activeOcr(), &AOcrProvider::recognized, this, [this](const QString &text) {
+        disarmOcrTranslation();
+        ui->sourceEdit->stopEditTimer(); // Prevent delayed textEdited signal
+
+        // Use auto-detect for OCR text if auto button is checked, otherwise use selected language
+        const bool isSourceAutoChecked = ui->sourceLanguagesWidget->isAutoButtonChecked();
+        const bool isTranslationAutoChecked = ui->translationLanguagesWidget->isAutoButtonChecked();
+        const Language sourceLanguage = isSourceAutoChecked ? Language::autoLanguage() : m_sourceLang;
+        const Language destinationLanguage = isTranslationAutoChecked ? Language::autoLanguage() : m_destLang;
+
+        if (m_translator && m_translator->getState() == ATranslationProvider::State::Ready) {
+            emit translationRequested(text, destinationLanguage, sourceLanguage);
+            return;
+        }
+        // Wait for translator to be ready
+        disconnect(m_ocrTranslatorReadyConnection);
+        m_ocrTranslatorReadyConnection =
+            connect(m_translator, &ATranslationProvider::stateChanged, this, [this, text, sourceLanguage, destinationLanguage](ATranslationProvider::State state) {
+                if (state == ATranslationProvider::State::Ready) {
+                    disconnect(m_ocrTranslatorReadyConnection);
+                    emit translationRequested(text, destinationLanguage, sourceLanguage);
+                }
+            });
+    });
 }
 
-Q_SCRIPTABLE void MainWindow::translateScreenArea()
+void MainWindow::disarmOcrTranslation()
+{
+    disconnect(m_ocrTranslateConnection);
+    disconnect(m_ocrTranslatorReadyConnection);
+}
+
+Q_SCRIPTABLE void MainWindow::recognizeScreenArea()
 {
-    if (m_ocr->languagesString().isEmpty()) {
-        QMessageBox::critical(this, Ocr::tr("OCR languages are not loaded"), Ocr::tr("You should set at least one OCR language in the application settings"));
+    if (!prepareOcr()) {
         return;
     }
 
-    AppSettings settings;
-    if (settings.isForceSourceAutodetect()) {
-        ui->sourceLanguagesWidget->checkAutoButton();
+    if (m_screenGrabber != nullptr) {
+        disarmOcrTranslation();
+        startScreenCapture();
     }
-    if (settings.isForceTranslationAutodetect()) {
-        ui->translationLanguagesWidget->checkAutoButton();
+}
+
+Q_SCRIPTABLE void MainWindow::translateScreenArea()
+{
+    if (!prepareOcr()) {
+        return;
     }
 
     if (m_screenGrabber != nullptr) {
-        auto ocrConnection = std::make_shared<QMetaObject::Connection>();
-        *ocrConnection = connect(m_ocr, &Ocr::recognized, this, [this, ocrConnection](const QString &text) {
-            ui->sourceEdit->setPlainText(text);
-            ui->sourceEdit->stopEditTimer(); // Prevent delayed textEdited signal
-
-            // Use auto-detect for OCR text if auto button is checked, otherwise use selected language
-            const bool isSourceAutoChecked = ui->sourceLanguagesWidget->isAutoButtonChecked();
-            const bool isTranslationAutoChecked = ui->translationLanguagesWidget->isAutoButtonChecked();
-            const Language sourceLanguage = isSourceAutoChecked ? Language::autoLanguage() : m_sourceLang;
-            const Language destinationLanguage = isTranslationAutoChecked ? Language::autoLanguage() : m_destLang;
-            qDebug() << "OCR: sourceAutoChecked:" << isSourceAutoChecked << "translationAutoChecked:" << isTranslationAutoChecked
-                     << "sourceLanguage:" << sourceLanguage.name() << "destinationLanguage:" << destinationLanguage.name();
-
-            if (m_translator && m_translator->getState() == ATranslationProvider::State::Ready) {
-                emit translationRequested(text, destinationLanguage, sourceLanguage);
-            } else {
-                // Wait for translator to be ready
-                auto connection = std::make_shared<QMetaObject::Connection>();
-                *connection =
-                    connect(m_translator, &ATranslationProvider::stateChanged, this, [this, text, sourceLanguage, destinationLanguage, connection](ATranslationProvider::State state) {
-                        if (state == ATranslationProvider::State::Ready) {
-                            emit translationRequested(text, destinationLanguage, sourceLanguage);
-                            disconnect(*connection);
-                        }
-                    });
-            }
-            disconnect(*ocrConnection);
-        });
-        m_screenGrabber->grab();
+        armOcrTranslation();
+        startScreenCapture();
     }
 }
 
 Q_SCRIPTABLE void MainWindow::delayedRecognizeScreenArea()
 {
-    if (m_ocr->languagesString().isEmpty()) {
-        QMessageBox::critical(this, Ocr::tr("OCR languages are not loaded"), Ocr::tr("You should set at least one OCR language in the application settings"));
+    if (!prepareOcr()) {
         return;
     }
 
-    AppSettings settings;
-    if (settings.isForceSourceAutodetect()) {
-        ui->sourceLanguagesWidget->checkAutoButton();
-    }
-    if (settings.isForceTranslationAutodetect()) {
-        ui->translationLanguagesWidget->checkAutoButton();
-    }
-
     if ((m_screenCaptureTimer != nullptr) && (m_screenGrabber != nullptr)) {
         const AppSettings settings;
         m_screenCaptureTimer->start(settings.captureDelay());
@@ -631,7 +728,8 @@ Q_SCRIPTABLE void MainWindow::delayedRecognizeScreenArea()
             &QTimer::timeout,
             this,
             [this]() {
-                m_screenGrabber->grab();
+                disarmOcrTranslation();
+                startScreenCapture();
             },
             Qt::SingleShotConnection);
     }
@@ -639,19 +737,10 @@ Q_SCRIPTABLE void MainWindow::delayedRecognizeScreenArea()
 
 Q_SCRIPTABLE void MainWindow::delayedTranslateScreenArea()
 {
-    if (m_ocr->languagesString().isEmpty()) {
-        QMessageBox::critical(this, Ocr::tr("OCR languages are not loaded"), Ocr::tr("You should set at least one OCR language in the application settings"));
+    if (!prepareOcr()) {
         return;
     }
 
-    AppSettings settings;
-    if (settings.isForceSourceAutodetect()) {
-        ui->sourceLanguagesWidget->checkAutoButton();
-    }
-    if (settings.isForceTranslationAutodetect()) {
-        ui->translationLanguagesWidget->checkAutoButton();
-    }
-
     if ((m_screenCaptureTimer != nullptr) && (m_screenGrabber != nullptr)) {
         const AppSettings settings;
         m_screenCaptureTimer->start(settings.captureDelay());
@@ -660,37 +749,8 @@ Q_SCRIPTABLE void MainWindow::delayedTranslateScreenArea()
             &QTimer::timeout,
             this,
             [this]() {
-                auto ocrConnection = std::make_shared<QMetaObject::Connection>();
-                *ocrConnection = connect(m_ocr, &Ocr::recognized, this, [this, ocrConnection](const QString &text) {
-                    ui->sourceEdit->setPlainText(text);
-                    ui->sourceEdit->stopEditTimer(); // Prevent delayed textEdited signal
-
-                    // Use auto-detect for OCR text if auto button is checked, otherwise use selected language
-                    const bool isSourceAutoChecked = ui->sourceLanguagesWidget->isAutoButtonChecked();
-                    const bool isTranslationAutoChecked = ui->translationLanguagesWidget->isAutoButtonChecked();
-                    const Language sourceLanguage = isSourceAutoChecked ? Language::autoLanguage() : m_sourceLang;
-                    const Language destinationLanguage = isTranslationAutoChecked ? Language::autoLanguage() : m_destLang;
-                    qDebug() << "Delayed OCR: sourceAutoChecked:" << isSourceAutoChecked << "translationAutoChecked:" << isTranslationAutoChecked
-                             << "sourceLanguage:" << sourceLanguage.name() << "destinationLanguage:" << destinationLanguage.name();
-
-                    if (m_translator && m_translator->getState() == ATranslationProvider::State::Ready) {
-                        emit translationRequested(text, destinationLanguage, sourceLanguage);
-                    } else {
-                        // Wait for translator to be ready
-                        auto connection = std::make_shared<QMetaObject::Connection>();
-                        *connection = connect(m_translator,
-                                              &ATranslationProvider::stateChanged,
-                                              this,
-                                              [this, text, sourceLanguage, destinationLanguage, connection](ATranslationProvider::State state) {
-                                                  if (state == ATranslationProvider::State::Ready) {
-                                                      emit translationRequested(text, destinationLanguage, sourceLanguage);
-                                                      disconnect(*connection);
-                                                  }
-                                              });
-                    }
-                    disconnect(*ocrConnection);
-                });
-                m_screenGrabber->grab();
+                armOcrTranslation();
+                startScreenCapture();
             },
             Qt::SingleShotConnection);
     }
@@ -702,6 +762,15 @@ Q_SCRIPTABLE void MainWindow::clearText()
     ui->translationEdit->clear();
 }
 
+void MainWindow::startScreenCapture()
+{
+    if (m_screenGrabber == nullptr)
+        return;
+
+    m_moduleStatus->beginScreenCapture();
+    m_screenGrabber->grab();
+}
+
 Q_SCRIPTABLE void MainWindow::openSettings()
 {
     SettingsDialog config(this);
@@ -861,6 +930,11 @@ void MainWindow::loadAppSettings()
     // Interface
     ui->translationEdit->setFont(settings.font());
     ui->sourceEdit->setFont(settings.font());
+    // De-identify BEFORE hiding the status bar: the AT-SPI bridge ignores
+    // property updates for widgets in an already-hidden hierarchy, so the
+    // strip's labels would keep announcing their last name indefinitely.
+    m_statusStrip->setShown(settings.isShowStatusBar());
+    ui->statusbar->setVisible(settings.isShowStatusBar());
 
     ui->sourceLanguagesWidget->setLanguageFormat(settings.mainWindowLanguageFormat());
     ui->translationLanguagesWidget->setLanguageFormat(settings.mainWindowLanguageFormat());
@@ -888,16 +962,17 @@ void MainWindow::loadAppSettings()
     ui->sourceEdit->setSimplifySource(settings.isSimplifySource());
 
     if (const QByteArray languages = settings.ocrLanguagesString(), path = settings.ocrLanguagesPath();
-        !m_ocr->init(languages, path, settings.tesseractParameters())) {
+        !m_tesseractOcr->init(languages, path, settings.tesseractParameters())) {
         if (languages != AppSettings::defaultOcrLanguagesString() || path != AppSettings::defaultOcrLanguagesPath())
-            m_trayIcon->showMessage(Ocr::tr("Unable to set OCR languages"), Ocr::tr("Unable to initialize Tesseract with %1").arg(QString(languages)));
+            m_trayIcon->showMessage(TesseractOcr::tr("Unable to set OCR languages"), TesseractOcr::tr("Unable to initialize Tesseract with %1").arg(QString(languages)));
     }
     if (const AppSettings::RegionRememberType type = settings.regionRememberType(); m_snippingArea->regionRememberType() != type) {
         m_snippingArea->setRegionRememberType(type);
         if (type == AppSettings::RememberAlways)
             m_snippingArea->setCropRegion(settings.cropRegion());
     }
-    m_ocr->setConvertLineBreaks(settings.isConvertLineBreaks());
+    m_tesseractOcr->setConvertLineBreaks(settings.isConvertLineBreaks());
+    configureLlmOcr();
     m_screenCaptureTimer->setInterval(settings.captureDelay());
     m_snippingArea->setCaptureOnRelese(settings.isConfirmOnRelease());
     m_snippingArea->setShowMagnifier(settings.isShowMagnifier());
@@ -1167,6 +1242,7 @@ void MainWindow::swapTranslator(ATranslationProvider::ProviderBackend newBackend
 
     m_chosenTranslationBackend = newBackend;
     m_translator = ATranslationProvider::createTranslationProvider(this, m_chosenTranslationBackend);
+    m_moduleStatus->bindTranslator(m_translator);
 
     updateProviderUI();
 
@@ -1235,6 +1311,7 @@ void MainWindow::swapTTSProvider(ATTSProvider::ProviderBackend newBackend)
 
     m_chosenTTSBackend = newBackend;
     m_tts = ATTSProvider::createTTSProvider(this, m_chosenTTSBackend);
+    m_moduleStatus->bindTtsProvider(m_tts);
 
     connect(m_tts, &ATTSProvider::stateChanged, this, &MainWindow::ttsStateChanged);
     connect(m_tts, &ATTSProvider::errorOccurred, this, &MainWindow::onTTSError);
@@ -1708,6 +1785,13 @@ void MainWindow::on_translateButton_clicked()
     const Language sourceLanguage = isSourceAutoChecked ? Language::autoLanguage() : m_sourceLang;
     const Language destinationLanguage = isTranslationAutoChecked ? Language::autoLanguage() : m_destLang;
 
+    if (m_hasSourceImage) {
+        // Recognition is still running on the image; translate whatever it
+        // produces rather than translating an empty source edit.
+        armOcrTranslation();
+        return;
+    }
+
     emit translationRequested(ui->sourceEdit->toSourceText(), destinationLanguage, sourceLanguage);
 }
 
@@ -1802,24 +1886,10 @@ void MainWindow::on_delayedTranslateScreenAreaButton_clicked()
 Language MainWindow::preferredTranslationLanguage(const Language &sourceLang) const
 {
     const AppSettings settings;
-    Language primaryLang = settings.primaryLanguage();
-    if (primaryLang == Language::autoLanguage())
-        primaryLang = Language(QLocale::system());
-
-    // First choice: use primary if different from source
-    if (primaryLang != sourceLang)
-        return primaryLang;
-
-    // Primary same as source, try secondary
-    Language secondaryLang = settings.secondaryLanguage();
-    if (secondaryLang == Language::autoLanguage())
-        secondaryLang = Language(QLocale::system());
-
-    if (secondaryLang != sourceLang)
-        return secondaryLang;
-
-    // Both primary and secondary same as source, fall back to system language
-    return Language(QLocale::system());
+    return TranslationLogic::preferredDestination(sourceLang,
+                                                  settings.primaryLanguage(),
+                                                  settings.secondaryLanguage(),
+                                                  Language(QLocale::system()));
 }
 
 void MainWindow::handleTranslationRequest(const QString &text, const Language &destLang, const Language &srcLang)
@@ -1859,19 +1929,7 @@ void MainWindow::setSourceImageInternal(const QImage &src)
         img = img.scaled(AppSettings::maxSourceImageDimension, AppSettings::maxSourceImageDimension, Qt::KeepAspectRatio, Qt::SmoothTransformation);
     }
 
-    QByteArray data;
-    QBuffer buf(&data);
-    buf.open(QIODevice::WriteOnly);
-    img.save(&buf, "JPEG", AppSettings::sourceImageJpegQuality);
-
-    if (data.size() > AppSettings::maxSourceImageBytes) {
-        ui->sourceEdit->setPlainText(tr("Image too large (%1 MB). Max %2 MB.")
-                                         .arg(data.size() / (1024.0 * 1024.0), 0, 'f', 1)
-                                         .arg(AppSettings::maxSourceImageBytes / (1024.0 * 1024.0), 0, 'f', 1));
-        return;
-    }
-
-    m_translator->setSourceImage(data);
+    m_pendingOcrImage = img;
 
     // Show preview over sourceEdit using geometry
     m_originalPixmap = QPixmap::fromImage(img);
@@ -1884,9 +1942,7 @@ void MainWindow::setSourceImageInternal(const QImage &src)
 
     updateTranslateButtonState();
 
-    if (ui->autoTranslateCheckBox->isChecked()) {
-        on_translateButton_clicked();
-    }
+    recognizeSourceImage();
 }
 
 void MainWindow::clearSourceImage()
@@ -1894,13 +1950,102 @@ void MainWindow::clearSourceImage()
     if (!m_hasSourceImage) {
         return;
     }
+    // Recognition now starts the moment an image arrives, so dropping the
+    // image while it is still running has to stop it too - otherwise Clear or
+    // Swap leaves a request in flight that lands its text (and possibly a
+    // translation) in a source edit the user has already emptied. The
+    // recognized/failed/canceled handlers disconnect themselves before calling
+    // this, so a still-connected handler is exactly the "aborted early" case.
+    if (m_imageOcrRecognizedConnection) {
+        disconnect(m_imageOcrRecognizedConnection);
+        disconnect(m_imageOcrCanceledConnection);
+        disconnect(m_imageOcrFailedConnection);
+        disarmOcrTranslation();
+        activeOcr()->cancel();
+    }
+
     m_hasSourceImage = false;
-    m_translator->clearSourceImage();
+    m_pendingOcrImage = QImage();
     m_imagePreview->hide();
     m_originalPixmap = QPixmap();
     ui->sourceLanguagesWidget->setEnabled(true);
 }
 
+static QByteArray stripNonStandardPngChunks(const QByteArray &data);
+
+// An image that arrives by drop, paste or the open button is transcribed
+// straight away, the same way a screen-area capture is: the point of dropping
+// an image is to see its text. Whether that text then gets translated is the
+// auto-translate setting's business, exactly as it is for typed text.
+void MainWindow::recognizeSourceImage()
+{
+    if (!prepareOcr()) {
+        clearSourceImage();
+        return;
+    }
+
+    AOcrProvider *engine = activeOcr();
+
+    disconnect(m_imageOcrRecognizedConnection);
+    disconnect(m_imageOcrCanceledConnection);
+    disconnect(m_imageOcrFailedConnection);
+
+    if (ui->autoTranslateCheckBox->isChecked()) {
+        armOcrTranslation();
+    } else {
+        disarmOcrTranslation();
+    }
+
+    // The recognized text itself lands in the source edit through the
+    // permanent recognized -> replaceText connection; these only manage the
+    // preview overlay that the text replaces.
+    m_imageOcrRecognizedConnection = connect(engine, &AOcrProvider::recognized, this, [this]() {
+        disconnect(m_imageOcrRecognizedConnection);
+        disconnect(m_imageOcrCanceledConnection);
+        disconnect(m_imageOcrFailedConnection);
+        clearSourceImage();
+    });
+    m_imageOcrCanceledConnection = connect(engine, &AOcrProvider::canceled, this, [this]() {
+        disconnect(m_imageOcrRecognizedConnection);
+        disconnect(m_imageOcrCanceledConnection);
+        disconnect(m_imageOcrFailedConnection);
+        disarmOcrTranslation();
+        clearSourceImage();
+        updateTranslateButtonState();
+    });
+    m_imageOcrFailedConnection = connect(engine, &AOcrProvider::failed, this, [this](const QString &error) {
+        disconnect(m_imageOcrRecognizedConnection);
+        disconnect(m_imageOcrCanceledConnection);
+        disconnect(m_imageOcrFailedConnection);
+        disarmOcrTranslation();
+        clearSourceImage();
+        ui->sourceEdit->replaceText(tr("OCR failed: %1").arg(error));
+        updateTranslateButtonState();
+    });
+
+    engine->recognize(m_pendingOcrImage, 96);
+}
+
+// Loads an image file, retrying through a PNG repair pass for the files Qt
+// refuses because of non-standard ancillary chunks (screenshots from some
+// tools carry them). Shared by drag-and-drop and the open-image button.
+bool MainWindow::loadImageFromFile(const QString &path, QImage &out)
+{
+    if (out.load(path)) {
+        return true;
+    }
+    QFile file(path);
+    if (!file.open(QIODevice::ReadOnly)) {
+        return false;
+    }
+    const QByteArray raw = file.readAll();
+    const QByteArray clean = stripNonStandardPngChunks(raw);
+    if (clean != raw) {
+        out.loadFromData(clean);
+    }
+    return !out.isNull();
+}
+
 static QByteArray stripNonStandardPngChunks(const QByteArray &data)
 {
     constexpr int SIG_LEN = 8;
@@ -1967,19 +2112,9 @@ bool MainWindow::eventFilter(QObject *obj, QEvent *event)
             QImage img;
             if (drop->mimeData()->hasUrls()) {
                 const QString path = drop->mimeData()->urls().constFirst().toLocalFile();
-                if (!img.load(path)) {
-                    QFile f(path);
-                    if (f.open(QIODevice::ReadOnly)) {
-                        const QByteArray raw = f.readAll();
-                        const QByteArray clean = stripNonStandardPngChunks(raw);
-                        if (clean != raw) {
-                            img.loadFromData(clean);
-                        }
-                    }
-                    if (img.isNull()) {
-                        ui->sourceEdit->setPlainText(tr("Cannot open image:\n%1").arg(path));
-                        return true;
-                    }
+                if (!loadImageFromFile(path, img)) {
+                    ui->sourceEdit->replaceText(tr("Cannot open image:\n%1").arg(path));
+                    return true;
                 }
             }
             if (img.isNull() && drop->mimeData()->hasImage()) {
@@ -2014,8 +2149,29 @@ bool MainWindow::eventFilter(QObject *obj, QEvent *event)
 void MainWindow::keyPressEvent(QKeyEvent *event)
 {
     if (event->key() == Qt::Key_Escape && m_hasSourceImage) {
+        // clearSourceImage() cancels the engine that is actually running.
+        // Cancelling both unconditionally used to make LlmOcr::cancel() emit a
+        // spurious canceled() for a Tesseract run (and vice versa).
         clearSourceImage();
         return;
     }
     QMainWindow::keyPressEvent(event);
 }
+
+void MainWindow::on_openImageButton_clicked()
+{
+    const QString path = QFileDialog::getOpenFileName(this,
+                                                      tr("Open image"),
+                                                      QStandardPaths::writableLocation(QStandardPaths::PicturesLocation),
+                                                      tr("Images (*.png *.jpg *.jpeg *.bmp *.gif *.webp *.tif *.tiff);;All files (*)"));
+    if (path.isEmpty()) {
+        return;
+    }
+
+    QImage img;
+    if (!loadImageFromFile(path, img)) {
+        QMessageBox::warning(this, tr("Open image"), tr("Cannot open image:\n%1").arg(path));
+        return;
+    }
+    setSourceImageInternal(img);
+}
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 7c4f13d3..f93356e1 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -12,9 +12,10 @@
 #include "qhotkey.h"
 #include "screenwatcher.h"
 #include "trayicon.h"
-#include "ocr/ocr.h"
+#include "ocr/aocrprovider.h"
 #include "ocr/screengrabbers/abstractscreengrabber.h"
 #include "ocr/snippingarea.h"
+#include "ocr/tesseractocr.h"
 #include "settings/appsettings.h"
 #include "translator/atranslationprovider.h"
 #include "tts/attsprovider.h"
@@ -33,7 +34,12 @@ class LanguageButtonsWidget;
 class PopupWindow;
 class SourceTextEdit;
 class ProviderOptionsManager;
+class ModuleStatus;
+class StatusStrip;
 class QLabel;
+class AOcrProvider;
+class LlmOcr;
+class TesseractOcr;
 
 namespace Ui
 {
@@ -51,7 +57,8 @@ public:
     ~MainWindow() override;
     bool eventFilter(QObject *obj, QEvent *event) override;
     void keyPressEvent(QKeyEvent *event) override;
-    Ocr *ocr() const;
+    AOcrProvider *ocr() const;
+    TesseractOcr *tesseractOcr() const;
     QComboBox *getEngineComboBox() const;
     QComboBox *sourceVoiceComboBox() const;
     QComboBox *translationVoiceComboBox() const;
@@ -66,6 +73,7 @@ public:
     QToolButton *copyAllTranslationButton() const;
     QTextEdit *translationEdit() const;
     SourceTextEdit *sourceEdit() const;
+    ModuleStatus *moduleStatus() const;
     QToolButton *sourcePlayPauseButton() const;
     QToolButton *sourceStopButton() const;
     QToolButton *translationPlayPauseButton() const;
@@ -108,9 +116,15 @@ private:
     ATranslationProvider *m_translator;
     ATranslationProvider::ProviderBackend m_chosenTranslationBackend;
     ProviderOptionsManager *m_optionsManager;
-    Ocr *m_ocr;
+    TesseractOcr *m_tesseractOcr;
+    LlmOcr *m_llmOcr;
     QTimer *m_screenCaptureTimer;
     SnippingArea *m_snippingArea;
+    ModuleStatus *m_moduleStatus;
+    StatusStrip *m_statusStrip = nullptr;
+    // Reports the capture start to the status model; grab() itself has no
+    // "started" signal and adding one would touch every grabber subclass.
+    void startScreenCapture();
     void loadAppSettings();
     void swapTranslator(ATranslationProvider::ProviderBackend newBackend);
     void setupEngineComboBoxConnection();
@@ -140,9 +154,27 @@ private:
     QList<QIcon> m_engineItemsIcons;
     void clearSourceImage();
     void setSourceImageInternal(const QImage &img);
+    bool loadImageFromFile(const QString &path, QImage &out);
     bool m_hasSourceImage = false;
     QPixmap m_originalPixmap;
     QLabel *m_imagePreview = nullptr;
+    QImage m_pendingOcrImage;
+    AOcrProvider *activeOcr() const;
+    void configureLlmOcr();
+    // Configures the active engine from settings and reports whether it is
+    // usable; every OCR entry point goes through it.
+    bool prepareOcr();
+    void recognizeSourceImage();
+    // Chains a translation onto the next recognition. Armed by the
+    // translate-screen-area paths and by an image drop while auto-translate is
+    // on; disarmed on cancel so it never fires for an unrelated later OCR.
+    void armOcrTranslation();
+    void disarmOcrTranslation();
+    QMetaObject::Connection m_ocrTranslateConnection;
+    QMetaObject::Connection m_ocrTranslatorReadyConnection;
+    QMetaObject::Connection m_imageOcrRecognizedConnection;
+    QMetaObject::Connection m_imageOcrCanceledConnection;
+    QMetaObject::Connection m_imageOcrFailedConnection;
     // Disconnected explicitly in ~MainWindow() before `delete ui` - QWidget's
     // own destructor destroys child widgets (deleteChildren(), which runs
     // the popup's destroyed() lambda below) *before* QObject's destructor
@@ -173,6 +205,7 @@ private slots:
     void on_swapButton_clicked();
     void on_abortButton_clicked();
     void on_copySourceButton_clicked();
+    void on_openImageButton_clicked();
     void on_delayedRecognizeScreenAreaButton_clicked();
     void on_clearButton_clicked();
     void on_copyTranslationButton_clicked();
diff --git a/src/mainwindow.ui b/src/mainwindow.ui
index a1a76932..22443f37 100644
--- a/src/mainwindow.ui
+++ b/src/mainwindow.ui
@@ -146,6 +146,16 @@
           </property>
          </widget>
         </item>
+        <item>
+         <widget class="QToolButton" name="openImageButton">
+          <property name="toolTip">
+           <string>Recognize text in an image file</string>
+          </property>
+          <property name="icon">
+           <iconset theme="insert-image"/>
+          </property>
+         </widget>
+        </item>
         <item>
          <widget class="QToolButton" name="delayedRecognizeScreenAreaButton">
           <property name="toolTip">
@@ -387,12 +397,13 @@
         </item>
        </layout>
       </item>
-     </layout>
-    </item>
-   </layout>
+      </layout>
+     </item>
+    </layout>
+   </widget>
+   <widget class="QStatusBar" name="statusbar"/>
   </widget>
- </widget>
- <customwidgets>
+  <customwidgets>
   <customwidget>
    <class>LanguageButtonsWidget</class>
    <extends>QWidget</extends>
diff --git a/src/modulestatus.cpp b/src/modulestatus.cpp
new file mode 100644
index 00000000..e41ba970
--- /dev/null
+++ b/src/modulestatus.cpp
@@ -0,0 +1,294 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#include "modulestatus.h"
+
+#include "ocr/aocrprovider.h"
+#include "ocr/screengrabbers/abstractscreengrabber.h"
+#include "ocr/snippingarea.h"
+#include "tts/attsprovider.h"
+#include "tts/noopttsprovider.h"
+
+ModuleStatus::ModuleStatus(QObject *parent)
+    : QObject(parent)
+{
+}
+
+ModuleStatus::Activity ModuleStatus::activity(Module module) const
+{
+    return m_entries[static_cast<int>(module)].activity;
+}
+
+QString ModuleStatus::message(Module module) const
+{
+    return messageText(m_entries[static_cast<int>(module)].message);
+}
+
+QString ModuleStatus::detail(Module module) const
+{
+    return m_entries[static_cast<int>(module)].detail;
+}
+
+bool ModuleStatus::isBusy() const
+{
+    for (const ModuleEntry &entry : m_entries) {
+        if (entry.activity == Activity::Busy)
+            return true;
+    }
+    return false;
+}
+
+bool ModuleStatus::isAvailable(Module module) const
+{
+    if (module != Module::Tts)
+        return true;
+
+    // NoopTTSProvider is what backend "None" creates, and it never emits
+    // anything at all - the segment would sit permanently idle.
+    return m_tts != nullptr && qobject_cast<NoopTTSProvider *>(m_tts) == nullptr;
+}
+
+void ModuleStatus::bindTranslator(ATranslationProvider *translator)
+{
+    if (m_translator != nullptr)
+        disconnect(m_translator, nullptr, this, nullptr);
+    m_translator = translator;
+    if (translator == nullptr)
+        return;
+
+    connect(translator, &ATranslationProvider::stateChanged, this, [this](ATranslationProvider::State newState) {
+        handleTranslationState(newState);
+    });
+    connect(translator, &ATranslationProvider::detectionStarted, this, [this]() {
+        // A detection chained inside a translation must not clobber
+        // "Translating" - it is part of that work, not new work.
+        if (m_entries[static_cast<int>(Module::Translation)].activity != Activity::Busy) {
+            m_detectionInFlight = true;
+            setEntry(Module::Translation, Activity::Busy, Message::DetectingLanguage);
+        }
+    });
+    connect(translator, &ATranslationProvider::languageDetected, this, [this]() {
+        if (!m_detectionInFlight)
+            return; // detection chained into a translation: "Translating" stays
+        m_detectionInFlight = false;
+        markIdle(Module::Translation);
+    });
+
+    // Seed by pulling: a provider swapped in mid-flight would otherwise stay
+    // unreported until its next state change.
+    m_detectionInFlight = false;
+    setEntry(Module::Translation, Activity::Idle, Message::None);
+    if (translator->getState() == ATranslationProvider::State::Processing)
+        setEntry(Module::Translation, Activity::Busy, Message::Translating);
+}
+
+void ModuleStatus::bindTtsProvider(ATTSProvider *tts)
+{
+    if (m_tts != nullptr)
+        disconnect(m_tts, nullptr, this, nullptr);
+    m_tts = tts;
+    if (tts == nullptr)
+        return;
+
+    connect(tts, &ATTSProvider::stateChanged, this, [this](QTextToSpeech::State newState) {
+        handleTtsState(newState);
+    });
+    connect(tts, &ATTSProvider::errorOccurred, this, [this](QTextToSpeech::ErrorReason reason, const QString &errorString) {
+        Q_UNUSED(reason)
+        setEntry(Module::Tts, Activity::Error, Message::SpeechError, errorString);
+    });
+
+    seedTtsState();
+}
+
+void ModuleStatus::bindOcr(AOcrProvider *tesseract, AOcrProvider *llm)
+{
+    if (m_tesseractOcr != nullptr)
+        disconnect(m_tesseractOcr, nullptr, this, nullptr);
+    if (m_llmOcr != nullptr)
+        disconnect(m_llmOcr, nullptr, this, nullptr);
+    m_tesseractOcr = tesseract;
+    m_llmOcr = llm;
+
+    bindOneOcr(tesseract);
+    bindOneOcr(llm);
+}
+
+void ModuleStatus::bindCapture(AbstractScreenGrabber *grabber, SnippingArea *snippingArea)
+{
+    if (m_grabber != nullptr)
+        disconnect(m_grabber, nullptr, this, nullptr);
+    if (m_snippingArea != nullptr)
+        disconnect(m_snippingArea, nullptr, this, nullptr);
+    m_grabber = grabber;
+    m_snippingArea = snippingArea;
+
+    if (grabber != nullptr) {
+        connect(grabber, &AbstractScreenGrabber::grabbed, this, [this]() {
+            setEntry(Module::Snipping, Activity::Busy, Message::SelectRegion);
+        });
+        connect(grabber, &AbstractScreenGrabber::grabbingFailed, this, [this]() {
+            setEntry(Module::Snipping, Activity::Error, Message::CaptureFailed);
+        });
+    }
+
+    if (snippingArea != nullptr) {
+        connect(snippingArea, &SnippingArea::snipped, this, [this]() {
+            markIdle(Module::Snipping);
+        });
+        connect(snippingArea, &SnippingArea::cancelled, this, [this]() {
+            markIdle(Module::Snipping);
+        });
+    }
+}
+
+void ModuleStatus::beginScreenCapture()
+{
+    setEntry(Module::Snipping, Activity::Busy, Message::WaitingForCapture);
+}
+
+void ModuleStatus::setEntry(Module module, Activity activity, Message message, const QString &detail)
+{
+    ModuleEntry &entry = m_entries[static_cast<int>(module)];
+    if (entry.activity == activity && entry.message == message && entry.detail == detail)
+        return;
+
+    entry.activity = activity;
+    entry.message = message;
+    entry.detail = detail;
+    emit changed();
+}
+
+void ModuleStatus::markIdle(Module module)
+{
+    if (m_entries[static_cast<int>(module)].activity == Activity::Busy)
+        setEntry(module, Activity::Idle, Message::None);
+}
+
+void ModuleStatus::bindOneOcr(AOcrProvider *engine)
+{
+    if (engine == nullptr)
+        return;
+
+    connect(engine, &AOcrProvider::started, this, [this]() {
+        setEntry(Module::Ocr, Activity::Busy, Message::RecognizingText);
+    });
+    connect(engine, &AOcrProvider::recognized, this, [this]() {
+        markIdle(Module::Ocr);
+    });
+    connect(engine, &AOcrProvider::failed, this, [this](const QString &error) {
+        setEntry(Module::Ocr, Activity::Error, Message::OcrFailed, error);
+    });
+    connect(engine, &AOcrProvider::canceled, this, [this]() {
+        markIdle(Module::Ocr);
+    });
+}
+
+void ModuleStatus::handleTranslationState(ATranslationProvider::State newState)
+{
+    // Any state transition ends the standalone-detection story: the abort
+    // and cancel paths never emit languageDetected, and this is the safety
+    // net that keeps a "Detecting language" status from stranding.
+    m_detectionInFlight = false;
+
+    switch (newState) {
+    case ATranslationProvider::State::Processing:
+        // Busy clears a sticky error from a previous run.
+        setEntry(Module::Translation, Activity::Busy, Message::Translating);
+        break;
+    case ATranslationProvider::State::Ready:
+        markIdle(Module::Translation);
+        break;
+    case ATranslationProvider::State::Processed:
+    case ATranslationProvider::State::Finished:
+        if (m_translator == nullptr) {
+            markIdle(Module::Translation);
+        } else if (m_translator->error == ATranslationProvider::TranslationError::Aborted) {
+            // A deliberate abort is the user's own action, not a failure.
+            markIdle(Module::Translation);
+        } else if (m_translator->error != ATranslationProvider::TranslationError::NoError) {
+            // Sticky: Processed -> Finished -> Ready runs to completion in
+            // one call stack, so a non-sticky error would be overwritten by
+            // the trailing Ready before anything could repaint.
+            setEntry(Module::Translation, Activity::Error, Message::TranslationFailed, m_translator->getErrorString());
+        } else {
+            markIdle(Module::Translation);
+        }
+        break;
+    }
+}
+
+void ModuleStatus::handleTtsState(QTextToSpeech::State newState)
+{
+    switch (newState) {
+    case QTextToSpeech::Speaking:
+        setEntry(Module::Tts, Activity::Busy, Message::Speaking);
+        break;
+    case QTextToSpeech::Synthesizing:
+        // MainWindow::ttsStateChanged() throws this away; for a slow network
+        // synthesizer it is the only feedback there is.
+        setEntry(Module::Tts, Activity::Busy, Message::PreparingSpeech);
+        break;
+    case QTextToSpeech::Paused:
+    case QTextToSpeech::Ready:
+        markIdle(Module::Tts);
+        break;
+    case QTextToSpeech::Error:
+        setEntry(Module::Tts, Activity::Error, Message::SpeechError, m_tts != nullptr ? m_tts->errorString() : QString());
+        break;
+    }
+}
+
+void ModuleStatus::seedTtsState()
+{
+    // A freshly bound provider owes nothing to the previous one's sticky error.
+    setEntry(Module::Tts, Activity::Idle, Message::None);
+    if (m_tts == nullptr || !isAvailable(Module::Tts))
+        return;
+
+    // Seed by pulling ATTSProvider::state(): NoopTTSProvider never emits
+    // anything, ever, so waiting for stateChanged would never converge.
+    switch (m_tts->state()) {
+    case QTextToSpeech::Speaking:
+    case QTextToSpeech::Synthesizing:
+    case QTextToSpeech::Error:
+        handleTtsState(m_tts->state());
+        break;
+    case QTextToSpeech::Ready:
+    case QTextToSpeech::Paused:
+        break;
+    }
+}
+
+QString ModuleStatus::messageText(Message message) const
+{
+    switch (message) {
+    case Message::None:
+        return {};
+    case Message::WaitingForCapture:
+        return tr("Waiting for capture");
+    case Message::SelectRegion:
+        return tr("Select a region");
+    case Message::RecognizingText:
+        return tr("Recognizing text");
+    case Message::Translating:
+        return tr("Translating");
+    case Message::DetectingLanguage:
+        return tr("Detecting language");
+    case Message::PreparingSpeech:
+        return tr("Preparing speech");
+    case Message::Speaking:
+        return tr("Speaking");
+    case Message::CaptureFailed:
+        return tr("Capture failed");
+    case Message::OcrFailed:
+        return tr("OCR failed");
+    case Message::TranslationFailed:
+        return tr("Translation failed");
+    case Message::SpeechError:
+        return tr("Speech error");
+    }
+    return {};
+}
diff --git a/src/modulestatus.h b/src/modulestatus.h
new file mode 100644
index 00000000..965ef718
--- /dev/null
+++ b/src/modulestatus.h
@@ -0,0 +1,130 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef MODULESTATUS_H
+#define MODULESTATUS_H
+
+#include "translator/atranslationprovider.h"
+
+#include <QObject>
+#include <QString>
+#include <QTextToSpeech>
+
+#include <array>
+
+class ATTSProvider;
+class AOcrProvider;
+class AbstractScreenGrabber;
+class SnippingArea;
+
+// Aggregator for "what is currently running" across the four async
+// subsystems (screen capture, OCR, translation, TTS). Both status strips -
+// the main window's and the pop-up's - read this one model instead of each
+// re-deriving state from the providers, since the pop-up has no providers
+// of its own.
+class ModuleStatus : public QObject
+{
+    Q_OBJECT
+    Q_DISABLE_COPY(ModuleStatus)
+
+public:
+    enum class Module : uint8_t { Snipping,
+                                  Ocr,
+                                  Translation,
+                                  Tts }; // display order
+    enum class Activity : uint8_t { Idle,
+                                    Busy,
+                                    Error };
+
+    explicit ModuleStatus(QObject *parent = nullptr);
+
+    Activity activity(Module module) const;
+    // tr()'d on demand so a QEvent::LanguageChange re-render picks up the new
+    // locale; never carries trailing dots - the view animates those.
+    QString message(Module module) const;
+    // Tooltip text: the full error message of the provider, not translated.
+    QString detail(Module module) const;
+    // Any module Busy; drives the view's ellipsis timer.
+    bool isBusy() const;
+    // False only for TTS when the backend is None - the view omits the
+    // segment entirely rather than show a permanently idle module.
+    bool isAvailable(Module module) const;
+
+    // The translator and TTS objects are replaced on backend change
+    // (MainWindow::swapTranslator()/swapTTSProvider()); both call the
+    // matching bind again. The OCR engines and the grabber/snipping area are
+    // long-lived and are bound once.
+    void bindTranslator(ATranslationProvider *translator);
+    void bindTtsProvider(ATTSProvider *tts);
+    // Both engines, not activeOcr(): the active one switches per settings read.
+    void bindOcr(AOcrProvider *tesseract, AOcrProvider *llm);
+    void bindCapture(AbstractScreenGrabber *grabber, SnippingArea *snippingArea);
+
+    static constexpr int moduleCount()
+    {
+        return s_moduleCount;
+    }
+
+public slots:
+    // The one moment with no signal to hang off: AbstractScreenGrabber::grab()
+    // has no "started" signal (adding one would mean touching every grabber
+    // subclass), so MainWindow reports it here right before calling grab().
+    void beginScreenCapture();
+
+signals:
+    void changed();
+
+private:
+    enum class Message : uint8_t {
+        None,
+        WaitingForCapture,
+        SelectRegion,
+        RecognizingText,
+        Translating,
+        DetectingLanguage,
+        PreparingSpeech,
+        Speaking,
+        CaptureFailed,
+        OcrFailed,
+        TranslationFailed,
+        SpeechError,
+    };
+
+    struct ModuleEntry {
+        Activity activity = Activity::Idle;
+        Message message = Message::None;
+        QString detail;
+    };
+
+    void setEntry(Module module, Activity activity, Message message, const QString &detail = QString());
+    // Demotes Busy to Idle but never clears a sticky Error: a translation
+    // error would otherwise be overwritten by the Ready that follows in the
+    // same call stack (Processed -> Finished -> reset() -> Ready runs to
+    // completion before anything can repaint), and never render at all.
+    void markIdle(Module module);
+    void bindOneOcr(AOcrProvider *engine);
+    void handleTranslationState(ATranslationProvider::State newState);
+    void handleTtsState(QTextToSpeech::State newState);
+    void seedTtsState();
+
+    QString messageText(Message message) const;
+
+    static constexpr int s_moduleCount = 4;
+    std::array<ModuleEntry, s_moduleCount> m_entries;
+
+    // Set when a standalone language detection is in flight (a detection
+    // chained inside a translation is NOT tracked - "Translating" already
+    // covers it). Cleared by languageDetected() or any stateChanged().
+    bool m_detectionInFlight = false;
+
+    ATranslationProvider *m_translator = nullptr;
+    ATTSProvider *m_tts = nullptr;
+    AOcrProvider *m_tesseractOcr = nullptr;
+    AOcrProvider *m_llmOcr = nullptr;
+    AbstractScreenGrabber *m_grabber = nullptr;
+    SnippingArea *m_snippingArea = nullptr;
+};
+
+#endif // MODULESTATUS_H
diff --git a/src/ocr/aocrprovider.h b/src/ocr/aocrprovider.h
new file mode 100644
index 00000000..7e887a55
--- /dev/null
+++ b/src/ocr/aocrprovider.h
@@ -0,0 +1,39 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef AOCRPROVIDER_H
+#define AOCRPROVIDER_H
+
+#include <QImage>
+#include <QObject>
+#include <QString>
+
+class AOcrProvider : public QObject
+{
+    Q_OBJECT
+    Q_DISABLE_COPY(AOcrProvider)
+
+public:
+    explicit AOcrProvider(QObject *parent = nullptr)
+        : QObject(parent)
+    {
+    }
+    ~AOcrProvider() override = default;
+
+    virtual QString engineName() const = 0;
+    virtual bool isConfigured() const = 0;
+    virtual void recognize(const QImage &image, int dpi) = 0;
+    virtual void cancel() = 0;
+
+signals:
+    // Recognition has actually begun (both engines are genuinely async), so
+    // "recognizing" is an observable interval for the status strip.
+    void started();
+    void recognized(const QString &text);
+    void failed(const QString &error);
+    void canceled();
+};
+
+#endif // AOCRPROVIDER_H
diff --git a/src/ocr/llmocr.cpp b/src/ocr/llmocr.cpp
new file mode 100644
index 00000000..099e63ae
--- /dev/null
+++ b/src/ocr/llmocr.cpp
@@ -0,0 +1,281 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#include "llmocr.h"
+
+#include "llm/openaiendpoint.h"
+
+#include <QBuffer>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QNetworkRequest>
+
+LlmOcr::LlmOcr(QObject *parent)
+    : AOcrProvider(parent)
+    , m_network(new QNetworkAccessManager(this))
+{
+}
+
+LlmOcr::~LlmOcr()
+{
+    if (m_reply != nullptr) {
+        m_reply->disconnect(this);
+        m_reply->abort();
+        m_reply->deleteLater();
+    }
+}
+
+QString LlmOcr::engineName() const
+{
+    return QStringLiteral("llm");
+}
+
+bool LlmOcr::isConfigured() const
+{
+    return !m_url.isEmpty() && !m_model.isEmpty();
+}
+
+void LlmOcr::setEndpoint(const QString &url, bool isAnthropic, const QString &apiKey)
+{
+    m_url = url;
+    m_isAnthropic = isAnthropic;
+    m_apiKey = apiKey;
+}
+
+void LlmOcr::setModel(const QString &model)
+{
+    m_model = model;
+}
+
+void LlmOcr::setTimeout(int seconds)
+{
+    m_timeout = seconds;
+}
+
+void LlmOcr::setPrompt(const QString &prompt)
+{
+    m_prompt = prompt;
+}
+
+void LlmOcr::setDisableThinking(bool disable)
+{
+    m_disableThinking = disable;
+}
+
+QString LlmOcr::defaultPrompt()
+{
+    return QStringLiteral(
+        "Transcribe ALL text visible in this image, exactly as written, in reading order. "
+        "Preserve the original language, line breaks and paragraphs. "
+        "Output only the transcribed text, nothing else. If there is no text, output nothing.");
+}
+
+QString LlmOcr::collapseRepeatedTranscription(const QString &text)
+{
+    QString normalized = text;
+    normalized.replace(QLatin1String("\r\n"), QLatin1String("\n"));
+    normalized.replace(QLatin1Char('\r'), QLatin1Char('\n'));
+
+    // Collapse runs of blank lines to a single newline so verbatim re-emissions
+    // of the same block become a detectable repeating sequence of lines.
+    QString folded;
+    folded.reserve(normalized.size());
+    const QChar newline = QLatin1Char('\n');
+    int newlineRun = 0;
+    for (const QChar c : normalized) {
+        if (c == newline) {
+            if (newlineRun == 0) {
+                folded.append(newline);
+            }
+            ++newlineRun;
+        } else {
+            folded.append(c);
+            newlineRun = 0;
+        }
+    }
+
+    QStringList lines;
+    const QStringList parts = folded.split(newline, Qt::SkipEmptyParts);
+    lines.reserve(parts.size());
+    for (const QString &part : parts) {
+        const QString line = part.trimmed();
+        if (!line.isEmpty()) {
+            lines.append(line);
+        }
+    }
+
+    const int n = lines.size();
+    if (n < 2) {
+        return text.trimmed();
+    }
+
+    // A whole-response repetition is the pathological signature: the completed
+    // transcription, then the same block re-emitted verbatim until the token
+    // budget runs out - which typically cuts the FINAL copy mid-block, leaving
+    // a partial tail whose last line is a prefix of the block's next line.
+    // Poetry can legitimately repeat lines, so single-line loops stay intact
+    // unless they run very long (>= 4 copies); a multi-line block repeated
+    // twice or more is collapsed even at 2 copies (user decision 2026-08-17),
+    // accepting that a 2-line refrain doubled verbatim would also collapse.
+    for (int period = 1; period <= n / 2; ++period) {
+        const int copies = n / period;
+        if (copies < 2) {
+            continue;
+        }
+        bool tiled = true;
+        for (int i = period; i < n; ++i) {
+            const QString &expected = lines[i % period];
+            const bool matches = (i == n - 1) ? expected.startsWith(lines[i]) : (lines[i] == expected);
+            if (!matches) {
+                tiled = false;
+                break;
+            }
+        }
+        if (!tiled) {
+            continue;
+        }
+        const bool runAwayLoop = (period == 1 && copies >= 4) || (period >= 2 && copies >= 2);
+        if (runAwayLoop) {
+            QStringList unit = lines.mid(0, period);
+            return unit.join(newline);
+        }
+    }
+    return text.trimmed();
+}
+
+void LlmOcr::recognize(const QImage &image, int dpi)
+{
+    Q_UNUSED(dpi)
+
+    emit started();
+
+    if (m_reply != nullptr) {
+        m_reply->disconnect(this);
+        m_reply->abort();
+        m_reply->deleteLater();
+        m_reply = nullptr;
+    }
+    m_userCanceled = false;
+
+    QByteArray imageData;
+    QBuffer buffer(&imageData);
+    buffer.open(QIODevice::WriteOnly);
+    image.save(&buffer, "JPEG");
+    const QString base64Data = QString::fromLatin1(imageData.toBase64());
+
+    const QString prompt = m_prompt.isEmpty() ? defaultPrompt() : m_prompt;
+
+    QJsonObject textPart;
+    textPart.insert(QStringLiteral("type"), QStringLiteral("text"));
+    textPart.insert(QStringLiteral("text"), prompt);
+
+    QJsonObject imagePart;
+    if (m_isAnthropic) {
+        imagePart.insert(QStringLiteral("type"), QStringLiteral("image"));
+        QJsonObject source;
+        source.insert(QStringLiteral("type"), QStringLiteral("base64"));
+        source.insert(QStringLiteral("media_type"), QStringLiteral("image/jpeg"));
+        source.insert(QStringLiteral("data"), base64Data);
+        imagePart.insert(QStringLiteral("source"), source);
+    } else {
+        imagePart.insert(QStringLiteral("type"), QStringLiteral("image_url"));
+        QJsonObject imageUrl;
+        imageUrl.insert(QStringLiteral("url"), QStringLiteral("data:image/jpeg;base64,") + base64Data);
+        imagePart.insert(QStringLiteral("image_url"), imageUrl);
+    }
+
+    QJsonArray content;
+    content.append(textPart);
+    content.append(imagePart);
+
+    QJsonObject message;
+    message.insert(QStringLiteral("role"), QStringLiteral("user"));
+    message.insert(QStringLiteral("content"), content);
+
+    QJsonArray messages;
+    messages.append(message);
+
+    // Small local vision models (an Ollama-hosted OCR model is the case that
+    // surfaced this) reliably finish the real transcription, then - lacking a
+    // clean stop token for the task - wrap it in a markdown fence and loop
+    // re-emitting it verbatim until they hit the token budget. A stop
+    // sequence on the fence marker cuts generation the instant that starts,
+    // instead of shipping the runaway repeat to the caller and trying to
+    // clean it up after the fact. Plain transcriptions never emit "```"
+    // themselves (the prompt asks for the transcribed text and nothing else).
+    static const QJsonArray kFenceStopSequence = {QStringLiteral("```")};
+
+    QJsonObject body;
+    body.insert(QStringLiteral("model"), m_model);
+    body.insert(QStringLiteral("messages"), messages);
+    body.insert(QStringLiteral("temperature"), 0.0);
+    if (m_isAnthropic) {
+        body.insert(QStringLiteral("max_tokens"), 4096);
+        body.insert(QStringLiteral("stop_sequences"), kFenceStopSequence);
+    } else {
+        body.insert(QStringLiteral("stream"), false);
+        body.insert(QStringLiteral("stop"), kFenceStopSequence);
+        if (m_disableThinking) {
+            body.insert(QStringLiteral("reasoning_effort"), QStringLiteral("none"));
+        }
+    }
+
+    QNetworkRequest request(QUrl(OpenAiEndpoint::completionsUrl(m_url, m_isAnthropic)));
+    request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
+    OpenAiEndpoint::setAuthHeaders(request, m_isAnthropic, m_apiKey);
+    m_network->setTransferTimeout(m_timeout * 1000);
+
+    m_reply = m_network->post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
+    connect(m_reply, &QNetworkReply::finished, this, &LlmOcr::onFinished);
+}
+
+void LlmOcr::cancel()
+{
+    if (m_reply != nullptr) {
+        m_userCanceled = true;
+        m_reply->abort();
+    } else {
+        emit canceled();
+    }
+}
+
+void LlmOcr::onFinished()
+{
+    QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
+    if (reply == nullptr) {
+        return;
+    }
+    if (reply != m_reply) {
+        reply->deleteLater();
+        return;
+    }
+    m_reply = nullptr;
+    reply->deleteLater();
+
+    if (reply->error() == QNetworkReply::OperationCanceledError) {
+        if (m_userCanceled) {
+            emit canceled();
+        } else {
+            emit failed(QStringLiteral("LLM OCR request timed out"));
+        }
+        return;
+    }
+
+    if (reply->error() != QNetworkReply::NoError) {
+        emit failed(QStringLiteral("LLM OCR error: ") + reply->errorString());
+        return;
+    }
+
+    const QString text = OpenAiEndpoint::extractContent(reply->readAll(), m_isAnthropic);
+    if (text.isEmpty()) {
+        emit failed(QStringLiteral("LLM OCR returned an empty response"));
+        return;
+    }
+
+    emit recognized(collapseRepeatedTranscription(text));
+}
diff --git a/src/ocr/llmocr.h b/src/ocr/llmocr.h
new file mode 100644
index 00000000..a2130089
--- /dev/null
+++ b/src/ocr/llmocr.h
@@ -0,0 +1,67 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef LLMOCR_H
+#define LLMOCR_H
+
+#include "aocrprovider.h"
+
+#include <QImage>
+#include <QString>
+
+class QNetworkAccessManager;
+class QNetworkReply;
+
+// OCR engine backed by an OpenAI-compatible or Anthropic-compatible vision
+// model. Unlike the Tesseract engine it knows nothing about translation: it
+// sends the image to a multimodal chat completions endpoint with a
+// transcription prompt and emits the recognized text, which then flows
+// through the same source-edit -> translation path as any other OCR result.
+class LlmOcr : public AOcrProvider
+{
+    Q_OBJECT
+    Q_DISABLE_COPY(LlmOcr)
+
+public:
+    explicit LlmOcr(QObject *parent = nullptr);
+    ~LlmOcr() override;
+
+    QString engineName() const override;
+    bool isConfigured() const override;
+    void recognize(const QImage &image, int dpi) override;
+    void cancel() override;
+
+    void setEndpoint(const QString &url, bool isAnthropic, const QString &apiKey);
+    void setModel(const QString &model);
+    void setTimeout(int seconds);
+    void setPrompt(const QString &prompt);
+    void setDisableThinking(bool disable);
+
+    static QString defaultPrompt();
+
+    // Collapses a transcription that a repeat-prone local vision model
+    // re-emits verbatim (blank-line separated) until it exhausts its token
+    // budget, so a runaway repeat can never land doubled in the source edit
+    // even when generation isn't stopped upstream. Static so the unit test
+    // can pin it directly.
+    static QString collapseRepeatedTranscription(const QString &text);
+
+private:
+    void onFinished();
+
+    QNetworkAccessManager *m_network;
+    QNetworkReply *m_reply = nullptr;
+
+    QString m_url;
+    QString m_model;
+    QString m_prompt;
+    QString m_apiKey;
+    bool m_isAnthropic = false;
+    int m_timeout = 300;
+    bool m_userCanceled = false;
+    bool m_disableThinking = false;
+};
+
+#endif // LLMOCR_H
diff --git a/src/ocr/snippingarea.cpp b/src/ocr/snippingarea.cpp
index a2dd324c..178f8e4e 100644
--- a/src/ocr/snippingarea.cpp
+++ b/src/ocr/snippingarea.cpp
@@ -952,6 +952,12 @@ void SnippingArea::acceptSelection()
         } else {
             emit snipped(selectedPixmap(), static_cast<int>(dpi));
         }
+    } else {
+        // An empty selection (click without a drag) still has to emit a
+        // terminal signal: without one, a pending translate-screen-area
+        // stays armed for the *next* recognition and the status strip
+        // sticks on "Select a region".
+        emit cancelled();
     }
     hide();
     releaseKeyboard();
diff --git a/src/ocr/ocr.cpp b/src/ocr/tesseractocr.cpp
similarity index 81%
rename from src/ocr/ocr.cpp
rename to src/ocr/tesseractocr.cpp
index f531ee4c..5120361b 100644
--- a/src/ocr/ocr.cpp
+++ b/src/ocr/tesseractocr.cpp
@@ -5,19 +5,18 @@
  * SPDX-License-Identifier: GPL-3.0-or-later
  */
 
-#include "ocr.h"
+#include "tesseractocr.h"
 
 #include "settings/appsettings.h"
 
-#include <QPixmap>
 #include <QtConcurrent>
 
 #if TESSERACT_MAJOR_VERSION < 5
 #include <tesseract/genericvector.h>
 #endif
 
-Ocr::Ocr(QObject *parent)
-    : QObject(parent)
+TesseractOcr::TesseractOcr(QObject *parent)
+    : AOcrProvider(parent)
 {
     // For the ability to cancel task
     m_monitor.cancel_this = &m_future;
@@ -26,12 +25,22 @@ Ocr::Ocr(QObject *parent)
     };
 }
 
-void Ocr::setConvertLineBreaks(bool convert)
+void TesseractOcr::setConvertLineBreaks(bool convert)
 {
     m_convertLineBreaks = convert;
 }
 
-QStringList Ocr::availableLanguages() const
+QString TesseractOcr::engineName() const
+{
+    return QStringLiteral("tesseract");
+}
+
+bool TesseractOcr::isConfigured() const
+{
+    return !languagesString().isEmpty();
+}
+
+QStringList TesseractOcr::availableLanguages() const
 {
     QStringList availableLanguages;
 #if TESSERACT_MAJOR_VERSION < 5
@@ -53,12 +62,12 @@ QStringList Ocr::availableLanguages() const
     return availableLanguages;
 }
 
-QByteArray Ocr::languagesString() const
+QByteArray TesseractOcr::languagesString() const
 {
     return QByteArray::fromRawData(m_tesseract.GetInitLanguagesAsString(), static_cast<int>(qstrlen(m_tesseract.GetInitLanguagesAsString())));
 }
 
-bool Ocr::init(const QByteArray &languages, const QByteArray &languagesPath, const QMap<QString, QVariant> &parameters)
+bool TesseractOcr::init(const QByteArray &languages, const QByteArray &languagesPath, const QMap<QString, QVariant> &parameters)
 {
     // Call even if the specified language is empty to initialize (Tesseract will try to load eng by default)
     if (languagesString() != languages || languages.isEmpty() || m_parameters != parameters) {
@@ -73,12 +82,13 @@ bool Ocr::init(const QByteArray &languages, const QByteArray &languagesPath, con
     return true;
 }
 
-void Ocr::recognize(const QPixmap &pixmap, int dpi)
+void TesseractOcr::recognize(const QImage &image, int dpi)
 {
     Q_ASSERT_X(qstrlen(m_tesseract.GetInitLanguagesAsString()) != 0, "recognize", "You should call init first");
 
+    emit started();
     m_future.waitForFinished();
-    m_future = QtConcurrent::run([this, dpi, image = pixmap.toImage()] {
+    m_future = QtConcurrent::run([this, dpi, image] {
         m_tesseract.SetImage(image.constBits(), image.width(), image.height(), image.depth() / 8, image.bytesPerLine());
         m_tesseract.SetSourceResolution(dpi);
         m_tesseract.Recognize(&m_monitor);
@@ -95,12 +105,12 @@ void Ocr::recognize(const QPixmap &pixmap, int dpi)
     });
 }
 
-void Ocr::cancel()
+void TesseractOcr::cancel()
 {
     m_future.cancel();
 }
 
-QStringList Ocr::availableLanguages(const QString &languagesPath)
+QStringList TesseractOcr::availableLanguages(const QString &languagesPath)
 {
     // From the specified directory
     if (!languagesPath.isEmpty())
@@ -121,7 +131,7 @@ QStringList Ocr::availableLanguages(const QString &languagesPath)
     return {};
 }
 
-void Ocr::applyParameters(const QMap<QString, QVariant> &parameters, bool saveSettings)
+void TesseractOcr::applyParameters(const QMap<QString, QVariant> &parameters, bool saveSettings)
 {
     // Apply new parameters
     for (auto it = parameters.cbegin(); it != parameters.cend(); ++it) {
@@ -137,7 +147,7 @@ void Ocr::applyParameters(const QMap<QString, QVariant> &parameters, bool saveSe
         AppSettings().setTesseractParameters(m_parameters);
 }
 
-QStringList Ocr::parseLanguageFiles(const QDir &directory)
+QStringList TesseractOcr::parseLanguageFiles(const QDir &directory)
 {
     const QFileInfoList files = directory.entryInfoList({QStringLiteral("*.traineddata")}, QDir::Files);
     QStringList languages;
@@ -146,4 +156,4 @@ QStringList Ocr::parseLanguageFiles(const QDir &directory)
         languages.append(file.baseName());
 
     return languages;
-}
+}
\ No newline at end of file
diff --git a/src/ocr/ocr.h b/src/ocr/tesseractocr.h
similarity index 63%
rename from src/ocr/ocr.h
rename to src/ocr/tesseractocr.h
index d793b3c1..f81363a8 100644
--- a/src/ocr/ocr.h
+++ b/src/ocr/tesseractocr.h
@@ -5,27 +5,30 @@
  * SPDX-License-Identifier: GPL-3.0-or-later
  */
 
-#ifndef OCR_H
-#define OCR_H
+#ifndef TESSERACTOCR_H
+#define TESSERACTOCR_H
 
+#include "aocrprovider.h"
 #include "cmake.h"
 
 #include <QFuture>
-#include <QObject>
 
 #include <tesseract/baseapi.h>
 #include <tesseract/ocrclass.h>
 
 class QDir;
 
-class Ocr : public QObject
+// Tesseract-backed OCR engine. Tesseract-specific configuration (language
+// packs, parameters) lives here and on the OCR settings page; the active
+// engine is selected by AppSettings::ocrEngine().
+class TesseractOcr : public AOcrProvider
 {
     Q_OBJECT
-    Q_CLASSINFO("D-Bus Interface", APPLICATION_ID ".Ocr")
-    Q_DISABLE_COPY(Ocr)
+    Q_CLASSINFO("D-Bus Interface", APPLICATION_ID ".TesseractOcr")
+    Q_DISABLE_COPY(TesseractOcr)
 
 public:
-    explicit Ocr(QObject *parent = nullptr);
+    explicit TesseractOcr(QObject *parent = nullptr);
 
     void setConvertLineBreaks(bool convert);
 
@@ -33,18 +36,16 @@ public:
     QByteArray languagesString() const;
     bool init(const QByteArray &languages, const QByteArray &languagesPath, const QMap<QString, QVariant> &parameters);
 
-    void recognize(const QPixmap &pixmap, int dpi);
-    void cancel();
+    QString engineName() const override;
+    bool isConfigured() const override;
+    void recognize(const QImage &image, int dpi) override;
+    void cancel() override;
 
     static QStringList availableLanguages(const QString &languagesPath);
 
 public slots:
     Q_SCRIPTABLE void applyParameters(const QMap<QString, QVariant> &parameters, bool saveSettings = false);
 
-signals:
-    void recognized(const QString &text);
-    void canceled();
-
 private:
     static QStringList parseLanguageFiles(const QDir &directory);
 
@@ -61,4 +62,4 @@ private:
     bool m_convertLineBreaks = false;
 };
 
-#endif // OCR_H
+#endif // TESSERACTOCR_H
\ No newline at end of file
diff --git a/src/popupwindow.cpp b/src/popupwindow.cpp
index 201db679..a213cf9d 100644
--- a/src/popupwindow.cpp
+++ b/src/popupwindow.cpp
@@ -131,6 +131,11 @@ PopupWindow::PopupWindow(MainWindow *parent)
     m_closeWindowsShortcut->setKey(parent->closeWindowShortcut());
     connect(m_closeWindowsShortcut, &QShortcut::activated, this, &PopupWindow::close);
 
+    // Status strip: mirrors MainWindow's model, and only appears while
+    // something is running so the resting pop-up is unchanged.
+    ui->statusStrip->setModel(parent->moduleStatus());
+    ui->statusStrip->setHideWhenIdle(true);
+
     loadSettings();
 }
 
@@ -147,6 +152,7 @@ void PopupWindow::loadSettings()
     const AppSettings settings;
     setWindowOpacity(settings.popupOpacity());
     resize(settings.popupWidth(), settings.popupHeight());
+    ui->statusStrip->setShown(settings.isShowStatusBar());
 
     ui->sourceLanguagesWidget->setLanguageFormat(settings.popupLanguageFormat());
     ui->translationLanguagesWidget->setLanguageFormat(settings.popupLanguageFormat());
diff --git a/src/popupwindow.ui b/src/popupwindow.ui
index b30ab735..5229981c 100644
--- a/src/popupwindow.ui
+++ b/src/popupwindow.ui
@@ -278,27 +278,43 @@
        </property>
       </widget>
      </item>
-     <item>
-      <widget class="QToolButton" name="copyTranslationButton">
-       <property name="toolTip">
-        <string>Copy translation to the clipboard</string>
-       </property>
-       <property name="icon">
-        <iconset theme="edit-copy"/>
-       </property>
-      </widget>
-     </item>
-    </layout>
-   </item>
-  </layout>
- </widget>
- <customwidgets>
-  <customwidget>
-   <class>LanguageButtonsWidget</class>
-   <extends>QWidget</extends>
-   <header>languagebuttonswidget.h</header>
-   <container>1</container>
-  </customwidget>
+      <item>
+       <widget class="QToolButton" name="copyTranslationButton">
+        <property name="toolTip">
+         <string>Copy translation to the clipboard</string>
+        </property>
+        <property name="icon">
+         <iconset theme="edit-copy"/>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </item>
+    <item>
+     <widget class="StatusStrip" name="statusStrip">
+      <property name="sizePolicy">
+       <sizepolicy hsizetype="Preferred" vsizetype="Fixed">
+        <horstretch>0</horstretch>
+        <verstretch>0</verstretch>
+       </sizepolicy>
+      </property>
+     </widget>
+    </item>
+   </layout>
+  </widget>
+  <customwidgets>
+   <customwidget>
+    <class>LanguageButtonsWidget</class>
+    <extends>QWidget</extends>
+    <header>languagebuttonswidget.h</header>
+    <container>1</container>
+   </customwidget>
+   <customwidget>
+    <class>StatusStrip</class>
+    <extends>QWidget</extends>
+    <header>statusstrip.h</header>
+    <container>1</container>
+   </customwidget>
   <customwidget>
    <class>TranslationEdit</class>
    <extends>QTextEdit</extends>
diff --git a/src/provideroptionsmanager.cpp b/src/provideroptionsmanager.cpp
index 20168c60..058b471b 100644
--- a/src/provideroptionsmanager.cpp
+++ b/src/provideroptionsmanager.cpp
@@ -117,15 +117,7 @@ std::unique_ptr<ProviderOptions> ProviderOptionsManager::createLocalAiTranslatio
     options->setOption("api_key", settings.localProviderApiKey(active));
     options->setOption("is_anthropic", AppSettings::localProviderIsAnthropic(active));
 
-    options->setOption("vision_enabled", settings.isVisionEnabled(active));
-    const QString visionModel = settings.localVisionModel(active);
-    options->setOption("vision_model", visionModel);
-    options->setOption("vision_prompt", settings.localVisionPrompt(visionModel));
-    options->setOption("vision_disable_thinking", settings.localAiDisableVisionThinking(active));
-    options->setOption("vision_timeout", settings.localAiVisionTimeout(active));
-
     const QString detectProvider = settings.detectProvider();
-    options->setOption("detect_via_llm", settings.detectViaLlm());
     options->setOption("detect_url", settings.localProviderUrl(detectProvider));
     options->setOption("detect_model", settings.detectModel());
     options->setOption("detect_api_key", settings.localProviderApiKey(detectProvider));
diff --git a/src/settings/appsettings.cpp b/src/settings/appsettings.cpp
index 915ff182..f5738e8e 100644
--- a/src/settings/appsettings.cpp
+++ b/src/settings/appsettings.cpp
@@ -358,6 +358,21 @@ QString AppSettings::defaultCustomIconPath()
     return TrayIcon::trayIconName(AppSettings::DefaultIcon);
 }
 
+bool AppSettings::isShowStatusBar() const
+{
+    return m_settings->value(QStringLiteral("Interface/ShowStatusBar"), defaultShowStatusBar()).toBool();
+}
+
+void AppSettings::setShowStatusBar(bool visible)
+{
+    m_settings->setValue(QStringLiteral("Interface/ShowStatusBar"), visible);
+}
+
+bool AppSettings::defaultShowStatusBar()
+{
+    return true;
+}
+
 bool AppSettings::isSourceTranslitEnabled() const
 {
     return m_settings->value(QStringLiteral("Translation/SourceTranslitEnabled"), defaultSourceTranslitEnabled()).toBool();
@@ -655,6 +670,15 @@ void AppSettings::setLibreTranslateDirect(bool direct)
 
 // ── LocalAI backend ───────────────────────────────────────────
 
+// Model names double as QSettings keys, and a '/' would silently create a
+// nested group ("openai/gpt-oss" -> group "openai", key "gpt-oss").
+static QString promptKey(const QString &model)
+{
+    QString key = model;
+    key.replace(QLatin1Char('/'), QLatin1Char('_'));
+    return key;
+}
+
 QStringList AppSettings::localProviderIds()
 {
     return {QStringLiteral("ollama"), QStringLiteral("fastflowlm"), QStringLiteral("lmstudio"),
@@ -761,16 +785,6 @@ void AppSettings::setLocalProviderApiKey(const QString &id, const QString &apiKe
     m_settings->setValue(QStringLiteral("LocalAI/") + id + QStringLiteral("/ApiKey"), apiKey);
 }
 
-bool AppSettings::detectViaLlm() const
-{
-    return m_settings->value(QStringLiteral("Translation/DetectViaLLM"), false).toBool();
-}
-
-void AppSettings::setDetectViaLlm(bool enabled)
-{
-    m_settings->setValue(QStringLiteral("Translation/DetectViaLLM"), enabled);
-}
-
 QString AppSettings::detectProvider() const
 {
     return m_settings->value(QStringLiteral("LocalAI/DetectProvider"), QStringLiteral("ollama")).toString();
@@ -816,56 +830,33 @@ void AppSettings::setLocalAiDisableThinking(const QString &providerId, bool disa
     m_settings->setValue(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/DisableThinking"), disable);
 }
 
-int AppSettings::localAiVisionTimeout(const QString &providerId) const
-{
-    return m_settings->value(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/VisionTimeout"), defaultLocalAiTimeout()).toInt();
-}
-
-void AppSettings::setLocalAiVisionTimeout(const QString &providerId, int seconds)
-{
-    m_settings->setValue(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/VisionTimeout"), seconds);
-}
-
-bool AppSettings::localAiDisableVisionThinking(const QString &providerId) const
-{
-    return m_settings->value(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/DisableVisionThinking"), false).toBool();
-}
-
-void AppSettings::setLocalAiDisableVisionThinking(const QString &providerId, bool disable)
-{
-    m_settings->setValue(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/DisableVisionThinking"), disable);
-}
-
-bool AppSettings::isVisionEnabled(const QString &providerId) const
-{
-    return m_settings->value(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/VisionEnabled"), false).toBool();
-}
-
-void AppSettings::setVisionEnabled(const QString &providerId, bool enabled)
-{
-    m_settings->setValue(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/VisionEnabled"), enabled);
-}
-
-QString AppSettings::localVisionModel(const QString &providerId) const
-{
-    return m_settings->value(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/VisionModel")).toString();
-}
-
-void AppSettings::setLocalVisionModel(const QString &providerId, const QString &model)
-{
-    m_settings->setValue(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/VisionModel"), model);
-}
-
-QString AppSettings::defaultVisionPrompt()
+QString AppSettings::defaultLocalAiPrompt()
 {
     return QStringLiteral(
-        "Replace EVERY piece of text visible in the image with its {target_lang} ({target_code}) translation. "
-        "Do NOT show original text — no arrows, no \"->\", no showing both languages. "
-        "Preserve the original formatting (line breaks, paragraphs). "
-        "Output only the translated text, nothing else.");
-}
-
-QString AppSettings::defaultLocalAiPrompt()
+        "You are a professional translator. Determine the source language of "
+        "the text yourself, then translate it into {target_lang} ({target_code}), "
+        "accurately conveying its meaning and nuances while adhering to "
+        "{target_lang} grammar, vocabulary, and cultural sensitivities.\n"
+        "Produce only the {target_lang} translation, without any additional "
+        "explanations or commentary. Translate the following text into "
+        "{target_lang}:\n\n\n{text}");
+}
+
+// The default translation prompt as it stood before the current one replaced
+// it. It asserted a specific source language into every request ("You are a
+// professional {source_lang} ... translate the following {source_lang} text"),
+// which the model does not need and can get wrong.
+//
+// It has to stay named here because the settings dialog used to persist the
+// prompt on every keystroke, so anyone who has merely *opened* the LocalAI page
+// on a master build has this string stored under their model - and a stored
+// prompt wins over the default. Left alone, upgrading would go on sending the
+// old prompt while the settings page showed it as the current default.
+//
+// Byte-identical to this means nobody typed it: it is that auto-save, not a
+// customization, so it reads back as "nothing stored". Anything else, however
+// similar, is the user's own text and is returned untouched.
+static QString supersededSourceLangPrompt()
 {
     return QStringLiteral(
         "You are a professional {source_lang} ({source_code}) to {target_lang} ({target_code}) translator. "
@@ -877,30 +868,16 @@ QString AppSettings::defaultLocalAiPrompt()
 
 QString AppSettings::localAiPrompt(const QString &model) const
 {
-    QString key = model;
-    key.replace(QLatin1Char('/'), QLatin1Char('_'));
-    return m_settings->value(QStringLiteral("LocalAI/Prompts/") + key, defaultLocalAiPrompt()).toString();
+    const QString stored = m_settings->value(QStringLiteral("LocalAI/Prompts/") + promptKey(model)).toString();
+    if (stored.isEmpty() || stored == supersededSourceLangPrompt()) {
+        return defaultLocalAiPrompt();
+    }
+    return stored;
 }
 
 void AppSettings::setLocalAiPrompt(const QString &model, const QString &prompt)
 {
-    QString key = model;
-    key.replace(QLatin1Char('/'), QLatin1Char('_'));
-    m_settings->setValue(QStringLiteral("LocalAI/Prompts/") + key, prompt);
-}
-
-QString AppSettings::localVisionPrompt(const QString &model) const
-{
-    QString key = model;
-    key.replace(QLatin1Char('/'), QLatin1Char('_'));
-    return m_settings->value(QStringLiteral("LocalAI/VisionPrompts/") + key, defaultVisionPrompt()).toString();
-}
-
-void AppSettings::setLocalVisionPrompt(const QString &model, const QString &prompt)
-{
-    QString key = model;
-    key.replace(QLatin1Char('/'), QLatin1Char('_'));
-    m_settings->setValue(QStringLiteral("LocalAI/VisionPrompts/") + key, prompt);
+    m_settings->setValue(QStringLiteral("LocalAI/Prompts/") + promptKey(model), prompt);
 }
 
 QNetworkProxy::ProxyType AppSettings::proxyType() const
@@ -1288,6 +1265,101 @@ bool AppSettings::defaultConvertLineBreaks()
     return true;
 }
 
+AppSettings::OcrEngine AppSettings::ocrEngine() const
+{
+    return static_cast<OcrEngine>(m_settings->value(QStringLiteral("OCR/Engine"), static_cast<int>(defaultOcrEngine())).toInt());
+}
+
+void AppSettings::setOcrEngine(OcrEngine engine)
+{
+    m_settings->setValue(QStringLiteral("OCR/Engine"), static_cast<int>(engine));
+}
+
+AppSettings::OcrEngine AppSettings::defaultOcrEngine()
+{
+    return OcrEngine::Tesseract;
+}
+
+QString AppSettings::ocrLlmProvider() const
+{
+    return m_settings->value(QStringLiteral("OcrLlm/Provider"), QStringLiteral("ollama")).toString();
+}
+
+void AppSettings::setOcrLlmProvider(const QString &providerId)
+{
+    m_settings->setValue(QStringLiteral("OcrLlm/Provider"), providerId);
+}
+
+QString AppSettings::ocrLlmUrl(const QString &providerId) const
+{
+    return m_settings->value(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/Url"), defaultLocalProviderUrl(providerId)).toString();
+}
+
+void AppSettings::setOcrLlmUrl(const QString &providerId, const QString &url)
+{
+    m_settings->setValue(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/Url"), url);
+}
+
+QString AppSettings::ocrLlmApiKey(const QString &providerId) const
+{
+    return m_settings->value(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/ApiKey")).toString();
+}
+
+void AppSettings::setOcrLlmApiKey(const QString &providerId, const QString &apiKey)
+{
+    m_settings->setValue(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/ApiKey"), apiKey);
+}
+
+QString AppSettings::ocrLlmModel(const QString &providerId) const
+{
+    return m_settings->value(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/Model")).toString();
+}
+
+void AppSettings::setOcrLlmModel(const QString &providerId, const QString &model)
+{
+    m_settings->setValue(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/Model"), model);
+}
+
+QStringList AppSettings::ocrLlmModels(const QString &providerId) const
+{
+    return m_settings->value(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/Models")).toStringList();
+}
+
+void AppSettings::setOcrLlmModels(const QString &providerId, const QStringList &models)
+{
+    m_settings->setValue(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/Models"), models);
+}
+
+QStringList AppSettings::ocrLlmVisionModels(const QString &providerId) const
+{
+    return m_settings->value(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/VisionModels")).toStringList();
+}
+
+void AppSettings::setOcrLlmVisionModels(const QString &providerId, const QStringList &models)
+{
+    m_settings->setValue(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/VisionModels"), models);
+}
+
+QString AppSettings::ocrLlmPrompt(const QString &model) const
+{
+    return m_settings->value(QStringLiteral("OcrLlm/Prompts/") + promptKey(model)).toString();
+}
+
+void AppSettings::setOcrLlmPrompt(const QString &model, const QString &prompt)
+{
+    m_settings->setValue(QStringLiteral("OcrLlm/Prompts/") + promptKey(model), prompt);
+}
+
+int AppSettings::ocrLlmTimeout(const QString &providerId) const
+{
+    return m_settings->value(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/Timeout"), defaultLocalAiTimeout()).toInt();
+}
+
+void AppSettings::setOcrLlmTimeout(const QString &providerId, int seconds)
+{
+    m_settings->setValue(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/Timeout"), seconds);
+}
+
 QByteArray AppSettings::ocrLanguagesPath() const
 {
     return m_settings->value(QStringLiteral("OCR/LanguagesPath"), defaultOcrLanguagesPath()).toByteArray();
diff --git a/src/settings/appsettings.h b/src/settings/appsettings.h
index f0a0cdde..0730d1f8 100644
--- a/src/settings/appsettings.h
+++ b/src/settings/appsettings.h
@@ -138,6 +138,10 @@ public:
     void setCustomIconPath(const QString &path);
     static QString defaultCustomIconPath();
 
+    bool isShowStatusBar() const;
+    void setShowStatusBar(bool visible);
+    static bool defaultShowStatusBar();
+
     // Translation settings
     bool isSourceTranslitEnabled() const;
     void setSourceTranslitEnabled(bool enable);
@@ -219,8 +223,6 @@ public:
     QString localProviderApiKey(const QString &id) const;
     void setLocalProviderApiKey(const QString &id, const QString &apiKey);
 
-    bool detectViaLlm() const;
-    void setDetectViaLlm(bool enabled);
     QString detectProvider() const;
     void setDetectProvider(const QString &id);
     QString detectModel() const;
@@ -232,21 +234,9 @@ public:
     bool localAiDisableThinking(const QString &providerId) const;
     void setLocalAiDisableThinking(const QString &providerId, bool disable);
 
-    int localAiVisionTimeout(const QString &providerId) const;
-    void setLocalAiVisionTimeout(const QString &providerId, int seconds);
-    bool localAiDisableVisionThinking(const QString &providerId) const;
-    void setLocalAiDisableVisionThinking(const QString &providerId, bool disable);
-
     QString localAiPrompt(const QString &model) const;
     void setLocalAiPrompt(const QString &model, const QString &prompt);
     static QString defaultLocalAiPrompt();
-    QString localVisionPrompt(const QString &model) const;
-    void setLocalVisionPrompt(const QString &model, const QString &prompt);
-    static QString defaultVisionPrompt();
-    QString localVisionModel(const QString &providerId) const;
-    void setLocalVisionModel(const QString &providerId, const QString &model);
-    bool isVisionEnabled(const QString &providerId) const;
-    void setVisionEnabled(const QString &providerId, bool enabled);
 
     static constexpr qint64 maxSourceImagePixels = 100LL * 1000 * 1000;
     static constexpr int maxSourceImageDimension = 2048;
@@ -368,6 +358,41 @@ public:
     static QKeySequence defaultCopyTranslationShortcut();
 
     // OCR settings
+    enum class OcrEngine {
+        Tesseract,
+        Llm
+    };
+    OcrEngine ocrEngine() const;
+    void setOcrEngine(OcrEngine engine);
+    static OcrEngine defaultOcrEngine();
+
+    // Vision-model OCR engine. Its provider ids are the same kinds as the
+    // LocalAI translation backend's (localProviderIds()), but every value
+    // lives under its own "OcrLlm/" group: the endpoint that transcribes your
+    // screenshots is not the endpoint that translates your text, and changing
+    // one must never move the other. Url falls back to the same per-kind
+    // default the translation backend uses, so a fresh install still points
+    // at localhost rather than nowhere.
+    QString ocrLlmProvider() const;
+    void setOcrLlmProvider(const QString &providerId);
+    QString ocrLlmUrl(const QString &providerId) const;
+    void setOcrLlmUrl(const QString &providerId, const QString &url);
+    QString ocrLlmApiKey(const QString &providerId) const;
+    void setOcrLlmApiKey(const QString &providerId, const QString &apiKey);
+    QString ocrLlmModel(const QString &providerId) const;
+    void setOcrLlmModel(const QString &providerId, const QString &model);
+    // Full model list from the last probe, and the subset the server proved
+    // vision-capable. A model absent from the vision list is "unproven", not
+    // "unsupported" - most OpenAI-compatible /v1/models responses carry no
+    // capability information at all, so nothing is ever hidden on that basis.
+    QStringList ocrLlmModels(const QString &providerId) const;
+    void setOcrLlmModels(const QString &providerId, const QStringList &models);
+    QStringList ocrLlmVisionModels(const QString &providerId) const;
+    void setOcrLlmVisionModels(const QString &providerId, const QStringList &models);
+    QString ocrLlmPrompt(const QString &model) const;
+    void setOcrLlmPrompt(const QString &model, const QString &prompt);
+    int ocrLlmTimeout(const QString &providerId) const;
+    void setOcrLlmTimeout(const QString &providerId, int seconds);
     bool isConvertLineBreaks() const;
     void setConvertLineBreaks(bool convert);
     static bool defaultConvertLineBreaks();
diff --git a/src/settings/settingsdialog.cpp b/src/settings/settingsdialog.cpp
index 7f4fbd64..8ff2562e 100644
--- a/src/settings/settingsdialog.cpp
+++ b/src/settings/settingsdialog.cpp
@@ -16,11 +16,13 @@
 #include "screenwatcher.h"
 #include "trayicon.h"
 #include "autostartmanager/abstractautostartmanager.h"
-#include "ocr/ocr.h"
+#include "llm/openaiendpoint.h"
+#include "llm/visionmodelprobe.h"
+#include "ocr/llmocr.h"
+#include "ocr/tesseractocr.h"
 #include "shortcutsmodel/shortcutitem.h"
 #include "shortcutsmodel/shortcutsmodel.h"
 #include "translator/atranslationprovider.h"
-#include "translator/localaitranslationprovider.h"
 #include "tts/attsprovider.h"
 
 #include <QButtonGroup>
@@ -109,13 +111,14 @@ SettingsDialog::SettingsDialog(MainWindow *parent)
         }
     }
 
-    ui->ocrLanguagesListWidget->addLanguages(parent->ocr()->availableLanguages());
+    ui->ocrLanguagesListWidget->addLanguages(parent->tesseractOcr()->availableLanguages());
 
     // Set all available instances
     ui->mozhiUrlComboBox->addItems(InstancePinger::instances());
     connect(ui->mozhiUrlComboBox, &QComboBox::currentTextChanged, this, &SettingsDialog::mozhiInstanceChanged);
 
     buildLocalAiTabs();
+    buildOcrEngineUi();
 
     // Adjust tab widgets to fit the scroll area viewport (sidebar occupies ~200 px).
     connect(ui->pagesStackedWidget, &QStackedWidget::currentChanged, this, [this](int /*idx*/) {
@@ -252,6 +255,7 @@ void SettingsDialog::accept()
 
     settings.setTrayIconType(static_cast<AppSettings::IconType>(ui->trayIconComboBox->currentIndex()));
     settings.setCustomIconPath(ui->customTrayIconEdit->text());
+    settings.setShowStatusBar(ui->showStatusBarCheckBox->isChecked());
     // Translation settings
     const ATranslationProvider::ProviderBackend currentBackend = settings.translationProviderBackend();
     const ATranslationProvider::ProviderBackend newBackend = ui->translationProviderComboBox->currentData().value<ATranslationProvider::ProviderBackend>();
@@ -289,7 +293,7 @@ void SettingsDialog::accept()
 
     // LocalAI settings
     saveLocalAiSettings();
-    settings.setDetectViaLlm(ui->detectViaLlmCheckBox->isChecked());
+    saveOcrEngineSettings();
     settings.setDetectProvider(ui->detectProviderComboBox->currentData().toString());
     settings.setDetectModel(ui->detectModelComboBox->currentText());
 
@@ -363,42 +367,6 @@ void SettingsDialog::buildLocalAiTabs()
         grid->addLayout(urlLayout, 0, 0, Qt::AlignVCenter);
         grid->addWidget(refresh, 0, 1, Qt::AlignRight | Qt::AlignVCenter);
 
-        auto *toggleWidget = new QWidget(outer);
-        auto *toggleHL = new QHBoxLayout(toggleWidget);
-        toggleHL->setContentsMargins(0, 0, 0, 0);
-        toggleHL->setSpacing(0);
-
-        auto *visionToggle = new QPushButton(tr("Vision"), outer);
-        visionToggle->setCheckable(true);
-        visionToggle->setObjectName(QStringLiteral("visionToggle"));
-        auto *textToggle = new QPushButton(tr("Text"), outer);
-        textToggle->setCheckable(true);
-        textToggle->setObjectName(QStringLiteral("textToggle"));
-        textToggle->setChecked(true);
-        toggleWidget->setStyleSheet(QStringLiteral(
-            "QPushButton#visionToggle:checked, QPushButton#textToggle:checked{font-weight:bold;font-style:normal}"
-            "QPushButton#visionToggle:not(:checked), QPushButton#textToggle:not(:checked){font-weight:normal;font-style:italic}"));
-
-        auto *modeGroup = new QButtonGroup(outer);
-        modeGroup->setExclusive(true);
-        modeGroup->addButton(visionToggle, 0);
-        modeGroup->addButton(textToggle, 1);
-
-        auto *helpBtn = new QPushButton(tr("?"), outer);
-        helpBtn->setFixedWidth(helpBtn->fontMetrics().horizontalAdvance(QStringLiteral(" ?? ")));
-        helpBtn->setToolTip(tr("How to use this tab"));
-
-        toggleHL->addWidget(visionToggle);
-        toggleHL->addWidget(textToggle);
-        toggleHL->addWidget(helpBtn);
-        grid->addWidget(toggleWidget, 1, 1, Qt::AlignRight | Qt::AlignVCenter);
-
-        auto *debugCheck = new QCheckBox(tr("Show both"), outer);
-        debugCheck->setToolTip(tr("Debug: show Text and Vision blocks simultaneously"));
-        debugCheck->setVisible(false);
-        grid->addWidget(debugCheck, 1, 0, Qt::AlignLeft | Qt::AlignVCenter);
-
-        auto *stack = new QStackedWidget(outer);
         LocalProviderTab t;
 
         {
@@ -442,62 +410,10 @@ void SettingsDialog::buildLocalAiTabs()
             connect(resetBtn, &QPushButton::clicked, this, [this, id]() {
                 m_localTabs[id].text.prompt->setPlainText(AppSettings::defaultLocalAiPrompt());
             });
+            t.text.promptModel.clear();
         }
 
-        {
-            t.vision.page = new QWidget();
-            auto *vl = new QVBoxLayout(t.vision.page);
-            vl->setContentsMargins(0, 0, 0, 0);
-            vl->setSpacing(8);
-            auto *modelRow = new QHBoxLayout();
-            t.vision.modelLabel = new QLabel(tr("Vision model:"), t.vision.page);
-            t.vision.model = new QComboBox(t.vision.page);
-            t.vision.model->setObjectName(QStringLiteral("localAiVisionModelCombo"));
-            t.vision.model->setEditable(true);
-            t.vision.model->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
-            modelRow->addWidget(t.vision.modelLabel);
-            modelRow->addWidget(t.vision.model);
-            modelRow->addSpacing(12);
-            auto *visionTimeoutLabel = new QLabel(tr("Timeout:"), t.vision.page);
-            t.vision.timeout = new QSpinBox(t.vision.page);
-            t.vision.timeout->setRange(60, 3600);
-            t.vision.timeout->setSingleStep(30);
-            t.vision.timeout->setSuffix(QStringLiteral(" s"));
-            t.vision.timeout->setToolTip(tr("Maximum time to wait for a translation or detection response."));
-            modelRow->addWidget(visionTimeoutLabel);
-            modelRow->addWidget(t.vision.timeout);
-            vl->addLayout(modelRow);
-            if (id == QLatin1String("ollama")) {
-                t.vision.disableThinking = new QCheckBox(tr("Disable reasoning"), t.vision.page);
-                vl->addWidget(t.vision.disableThinking);
-            }
-            auto *sep = new QFrame(t.vision.page);
-            sep->setFrameShape(QFrame::HLine);
-            sep->setFrameShadow(QFrame::Sunken);
-            vl->addWidget(sep);
-            auto *hint = new QLabel(tr("Placeholders: %1").arg(QStringLiteral("{source_lang} {source_code} {target_lang} {target_code}")), t.vision.page);
-            hint->setWordWrap(true);
-            vl->addWidget(hint);
-            auto *resetBtn = new QPushButton(tr("Reset Vision prompt"), t.vision.page);
-            vl->addWidget(resetBtn);
-            t.vision.prompt = new QPlainTextEdit(t.vision.page);
-            vl->addWidget(t.vision.prompt, 1);
-            connect(resetBtn, &QPushButton::clicked, this, [this, id]() {
-                m_localTabs[id].vision.prompt->setPlainText(AppSettings::defaultVisionPrompt());
-            });
-        }
-
-        stack->addWidget(t.vision.page);
-        stack->addWidget(t.text.page);
-        stack->setCurrentIndex(1);
-        grid->addWidget(stack, 2, 0, 1, 2);
-
-        auto *debugContainer = new QWidget(outer);
-        auto *debugLayout = new QVBoxLayout(debugContainer);
-        debugLayout->setContentsMargins(0, 0, 0, 0);
-        debugLayout->setSpacing(12);
-        debugContainer->hide();
-        grid->addWidget(debugContainer, 2, 0, 1, 2);
+        grid->addWidget(t.text.page, 2, 0, 1, 2);
 
         grid->setColumnStretch(0, 1);
         grid->setColumnStretch(1, 0);
@@ -508,144 +424,344 @@ void SettingsDialog::buildLocalAiTabs()
         t.url = url;
         t.apiKey = apiKey;
         t.refresh = refresh;
-        t.visionToggle = visionToggle;
-        t.textToggle = textToggle;
-        t.modeGroup = modeGroup;
-        t.stack = stack;
-        t.debugContainer = debugContainer;
-        t.debugCheckBox = debugCheck;
         m_localTabs.insert(id, t);
 
-        connect(helpBtn, &QPushButton::clicked, this, [this]() {
-            const QString helpText = tr(
-                                         "<h3>How the LocalAI tab works</h3>"
-                                         "<p><b>Text / Vision modes:</b> Each provider has two independent "
-                                         "blocks — <i>Text</i> for translating text and <i>Vision</i> for "
-                                         "translating text from images (drag &amp; drop or paste an image "
-                                         "into the source field). Switch with the [Vision] [Text] buttons.</p>"
-                                         "<p><b>Prompts are per-model:</b> <u>Each model has its own prompt.</u> "
-                                         "When you change the model, its saved prompt is loaded. "
-                                         "Your edits are saved automatically as you type — no need to click Save.</p>"
-                                         "<p><b>Default text prompt:</b><br>"
-                                         "<code>%1</code></p>"
-                                         "<p><b>Default vision prompt:</b><br>"
-                                         "<code>%2</code></p>"
-                                         "<p><b>Example — describe a photo (vision mode):</b><br>"
-                                         "<code>You are a visual analyst. Examine the image and provide "
-                                         "a detailed description in {target_lang} ({target_code}). "
-                                         "Include: scene type, subjects, colors, lighting, notable details. "
-                                         "If there is text, translate it. Output only the description, "
-                                         "3–6 sentences.</code></p>"
-                                         "<p><b>Placeholders:</b><br>"
-                                         "<code>{source_lang}</code> — source language name<br>"
-                                         "<code>{source_code}</code> — source language ISO code<br>"
-                                         "<code>{target_lang}</code> — target language name<br>"
-                                         "<code>{target_code}</code> — target language ISO code<br>"
-                                         "<code>{text}</code> — the input text (text mode only)</p>"
-                                         "<p><b>Disable reasoning:</b> Skips thinking tokens on supported "
-                                         "models. Only shown for Ollama providers.</p>"
-                                         "<p><b>Timeout:</b> Maximum time to wait for a response.</p>")
-                                         .arg(AppSettings::defaultLocalAiPrompt().toHtmlEscaped(),
-                                              AppSettings::defaultVisionPrompt().toHtmlEscaped());
-            auto *msg = new QMessageBox(QMessageBox::Information, tr("LocalAI Tab Help"),
-                                        helpText, QMessageBox::Ok, this);
-            msg->setMinimumWidth(680);
-            msg->exec();
-        });
-
-        connect(modeGroup, &QButtonGroup::idClicked, this, [this, id](int mode) {
-            LocalProviderTab &tab = m_localTabs[id];
-            if (tab.debugCheckBox && tab.debugCheckBox->isChecked()) {
-                return;
-            }
-            tab.stack->setCurrentIndex(mode == 0 ? 0 : 1);
-        });
-
-        connect(debugCheck, &QCheckBox::toggled, this, [this, id](bool checked) {
-            LocalProviderTab &tab = m_localTabs[id];
-            if (checked) {
-                const int idx = tab.stack->currentIndex();
-                tab.stack->removeWidget(tab.text.page);
-                tab.stack->removeWidget(tab.vision.page);
-                auto *debugLayout = tab.debugContainer->layout();
-                debugLayout->addWidget(tab.text.page);
-                debugLayout->addWidget(tab.vision.page);
-                tab.text.page->show();
-                tab.vision.page->show();
-                tab.stack->hide();
-                tab.debugContainer->show();
-                if (idx == 0) {
-                    tab.visionToggle->setChecked(true);
-                }
-            } else {
-                auto *debugLayout = tab.debugContainer->layout();
-                debugLayout->removeWidget(tab.text.page);
-                debugLayout->removeWidget(tab.vision.page);
-                tab.stack->addWidget(tab.vision.page);
-                tab.stack->addWidget(tab.text.page);
-                tab.stack->setCurrentIndex(tab.modeGroup->checkedId() == 0 ? 0 : 1);
-                tab.debugContainer->hide();
-                tab.stack->show();
-            }
-        });
-
         connect(refresh, &QPushButton::clicked, this, [this, id]() {
             refreshLocalModels(id);
         });
 
-        connect(url, &QLineEdit::textChanged, this, [this, id](const QString &text) {
-            AppSettings().setLocalProviderUrl(id, text);
-        });
-
-        connect(apiKey, &QLineEdit::textChanged, this, [this, id](const QString &text) {
-            AppSettings().setLocalProviderApiKey(id, text);
-        });
-
-        // Text page signal handlers
-        connect(t.text.model, &QComboBox::currentTextChanged, this, [this, id](const QString &m) {
-            LocalProviderTab &tab = m_localTabs[id];
-            AppSettings().setLocalProviderModel(id, m);
-            const QString p = AppSettings().localAiPrompt(m);
-            tab.text.prompt->setPlainText(p);
+        // URL and key are written by saveLocalAiSettings() on accept, so
+        // Cancel actually cancels them. refreshLocalModels() reads the widget
+        // directly, so probing an unsaved URL still works.
+
+        // Text page signal handlers. The model combo is editable, so its
+        // currentTextChanged fires per keystroke - binding a settings write to
+        // it saved a model (and a prompt) under every prefix of what was being
+        // typed. textActivated only fires when a model is actually picked from
+        // the list; everything else is written by saveLocalAiSettings() when
+        // the dialog is accepted.
+        //
+        // Deliberately not QLineEdit::editingFinished: it also fires on
+        // focus-out, including the focus-out that ~QDialog() triggers while
+        // hiding itself - by then SettingsDialog's own members (m_localTabs
+        // among them) have already been destroyed, and the handler crashes.
+        connect(t.text.model, &QComboBox::textActivated, this, [this, id](const QString &m) {
+            showLocalAiPromptFor(id, m);
         });
         connect(t.text.timeout, qOverload<int>(&QSpinBox::valueChanged), this, [this, id](int val) {
             AppSettings().setLocalAiTimeout(id, val);
         });
-        connect(t.text.prompt, &QPlainTextEdit::textChanged, this, [this, id]() {
-            LocalProviderTab &tab = m_localTabs[id];
-            const QString curModel = tab.text.model->currentText();
-            if (!curModel.isEmpty()) {
-                AppSettings().setLocalAiPrompt(curModel, tab.text.prompt->toPlainText());
-            }
-        });
         if (t.text.disableThinking) {
             connect(t.text.disableThinking, &QCheckBox::toggled, this, [this, id](bool checked) {
                 AppSettings().setLocalAiDisableThinking(id, checked);
             });
         }
+    }
+}
 
-        // Vision page signal handlers
-        connect(t.vision.model, &QComboBox::currentTextChanged, this, [this, id](const QString &m) {
-            LocalProviderTab &tab = m_localTabs[id];
-            AppSettings().setLocalVisionModel(id, m);
-            const QString p = AppSettings().localVisionPrompt(m);
-            tab.vision.prompt->setPlainText(p);
-        });
-        connect(t.vision.timeout, qOverload<int>(&QSpinBox::valueChanged), this, [this, id](int val) {
-            AppSettings().setLocalAiVisionTimeout(id, val);
-        });
-        connect(t.vision.prompt, &QPlainTextEdit::textChanged, this, [this, id]() {
-            LocalProviderTab &tab = m_localTabs[id];
-            const QString curModel = tab.vision.model->currentText();
-            if (!curModel.isEmpty()) {
-                AppSettings().setLocalVisionPrompt(curModel, tab.vision.prompt->toPlainText());
+void SettingsDialog::buildOcrEngineUi()
+{
+    auto *engineRow = new QWidget(ui->ocrPage);
+    auto *engineLayout = new QHBoxLayout(engineRow);
+    engineLayout->setContentsMargins(0, 0, 0, 0);
+
+    auto *engineLabel = new QLabel(tr("OCR engine:"), engineRow);
+    m_ocrEngineCombo = new QComboBox(engineRow);
+    m_ocrEngineCombo->setObjectName(QStringLiteral("ocrEngineCombo"));
+    m_ocrEngineCombo->addItem(tr("Tesseract"), static_cast<int>(AppSettings::OcrEngine::Tesseract));
+    m_ocrEngineCombo->addItem(tr("Vision model (LLM)"), static_cast<int>(AppSettings::OcrEngine::Llm));
+    engineLayout->addWidget(engineLabel);
+    engineLayout->addWidget(m_ocrEngineCombo);
+    engineLayout->addStretch(1);
+
+    m_ocrEngineStack = new QStackedWidget(ui->ocrPage);
+
+    auto *tesseractPage = new QWidget(m_ocrEngineStack);
+    auto *tesseractLayout = new QVBoxLayout(tesseractPage);
+    tesseractLayout->setContentsMargins(0, 0, 0, 0);
+    auto *tesseractNote = new QLabel(tr("Tesseract runs locally. Configure languages and parameters below."), tesseractPage);
+    tesseractNote->setWordWrap(true);
+    tesseractLayout->addWidget(tesseractNote);
+    m_ocrEngineStack->addWidget(tesseractPage);
+
+    auto *llmPage = new QWidget(m_ocrEngineStack);
+    auto *llmLayout = new QVBoxLayout(llmPage);
+    llmLayout->setContentsMargins(0, 0, 0, 0);
+    llmLayout->setSpacing(8);
+
+    auto *providerRow = new QHBoxLayout();
+    auto *providerLabel = new QLabel(tr("Provider:"), llmPage);
+    m_ocrLlmProviderCombo = new QComboBox(llmPage);
+    m_ocrLlmProviderCombo->setObjectName(QStringLiteral("ocrLlmProviderCombo"));
+    for (const QString &id : AppSettings::localProviderIds()) {
+        m_ocrLlmProviderCombo->addItem(AppSettings::localProviderDisplayName(id), id);
+    }
+    providerRow->addWidget(providerLabel);
+    providerRow->addWidget(m_ocrLlmProviderCombo);
+    providerRow->addSpacing(12);
+    auto *timeoutLabel = new QLabel(tr("Timeout:"), llmPage);
+    m_ocrLlmTimeoutSpin = new QSpinBox(llmPage);
+    m_ocrLlmTimeoutSpin->setRange(60, 3600);
+    m_ocrLlmTimeoutSpin->setSingleStep(30);
+    m_ocrLlmTimeoutSpin->setSuffix(QStringLiteral(" s"));
+    m_ocrLlmTimeoutSpin->setToolTip(tr("Maximum time to wait for a transcription response."));
+    providerRow->addWidget(timeoutLabel);
+    providerRow->addWidget(m_ocrLlmTimeoutSpin);
+    providerRow->addStretch(1);
+    llmLayout->addLayout(providerRow);
+
+    // The OCR endpoint is configured here in full. It is deliberately not the
+    // translation backend's endpoint: transcribing a screenshot and
+    // translating a sentence are different jobs, often on different hosts and
+    // certainly on different models.
+    auto *urlRow = new QHBoxLayout();
+    auto *urlLabel = new QLabel(tr("URL:"), llmPage);
+    m_ocrLlmUrlEdit = new QLineEdit(llmPage);
+    m_ocrLlmUrlEdit->setObjectName(QStringLiteral("ocrLlmUrlEdit"));
+    auto *apiKeyLabel = new QLabel(tr("Key:"), llmPage);
+    m_ocrLlmApiKeyEdit = new QLineEdit(llmPage);
+    m_ocrLlmApiKeyEdit->setObjectName(QStringLiteral("ocrLlmApiKeyEdit"));
+    m_ocrLlmApiKeyEdit->setEchoMode(QLineEdit::Password);
+    m_ocrLlmApiKeyEdit->setPlaceholderText(tr("optional, e.g. for a cloud endpoint"));
+    m_ocrLlmRefreshButton = new QPushButton(tr("Refresh models"), llmPage);
+    m_ocrLlmRefreshButton->setObjectName(QStringLiteral("ocrLlmRefreshButton"));
+    urlRow->addWidget(urlLabel);
+    urlRow->addWidget(m_ocrLlmUrlEdit);
+    urlRow->addWidget(apiKeyLabel);
+    urlRow->addWidget(m_ocrLlmApiKeyEdit);
+    urlRow->addWidget(m_ocrLlmRefreshButton);
+    llmLayout->addLayout(urlRow);
+
+    auto *modelRow = new QHBoxLayout();
+    auto *modelLabel = new QLabel(tr("Model:"), llmPage);
+    m_ocrLlmModelCombo = new QComboBox(llmPage);
+    m_ocrLlmModelCombo->setObjectName(QStringLiteral("ocrLlmModelCombo"));
+    m_ocrLlmModelCombo->setEditable(true);
+    m_ocrLlmModelCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+    modelRow->addWidget(modelLabel);
+    modelRow->addWidget(m_ocrLlmModelCombo);
+    llmLayout->addLayout(modelRow);
+
+    m_ocrLlmCapabilityHint = new QLabel(llmPage);
+    m_ocrLlmCapabilityHint->setWordWrap(true);
+    llmLayout->addWidget(m_ocrLlmCapabilityHint);
+
+    m_ocrLlmResetPromptButton = new QPushButton(tr("Reset OCR prompt"), llmPage);
+    llmLayout->addWidget(m_ocrLlmResetPromptButton);
+    m_ocrLlmPromptEdit = new QPlainTextEdit(llmPage);
+    m_ocrLlmPromptEdit->setPlaceholderText(LlmOcr::defaultPrompt());
+    llmLayout->addWidget(m_ocrLlmPromptEdit, 1);
+    m_ocrEngineStack->addWidget(llmPage);
+
+    if (auto *ocrLayout = ui->ocrPage->findChild<QVBoxLayout *>(QStringLiteral("ocrLayout"))) {
+        ocrLayout->insertWidget(0, engineRow);
+        ocrLayout->insertWidget(1, m_ocrEngineStack);
+    }
+
+    connect(m_ocrEngineCombo, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
+        m_ocrEngineStack->setCurrentIndex(index);
+        updateOcrEngineVisibility(m_ocrEngineCombo->itemData(index).toInt());
+    });
+    connect(m_ocrLlmProviderCombo, &QComboBox::currentIndexChanged, this, [this]() {
+        loadOcrLlmProvider(m_ocrLlmProviderCombo->currentData().toString());
+    });
+    // Persist on commit, never per keystroke: an editable combo emits
+    // currentTextChanged for every character typed, which used to write a
+    // settings key (and a prompt) for each prefix of a model name. See
+    // buildLocalAiTabs() for why editingFinished is not used either.
+    connect(m_ocrLlmModelCombo, &QComboBox::textActivated, this, [this](const QString &model) {
+        showOcrPromptFor(model);
+    });
+    connect(m_ocrLlmRefreshButton, &QPushButton::clicked, this, &SettingsDialog::refreshOcrModels);
+    connect(m_ocrLlmResetPromptButton, &QPushButton::clicked, this, [this]() {
+        m_ocrLlmPromptEdit->setPlainText(QString());
+    });
+}
+
+// Lists every model the last probe returned, grouped by whether the server
+// vouched for image support. Nothing is filtered out: most OpenAI-compatible
+// endpoints report no capabilities at all, so hiding the unvouched ones would
+// hide every working model behind such an endpoint.
+void SettingsDialog::populateOcrModelCombo(const QString &id)
+{
+    const AppSettings settings;
+    const QStringList all = settings.ocrLlmModels(id);
+    const QStringList vision = settings.ocrLlmVisionModels(id);
+    const QString current = m_ocrLlmModelCombo->currentText().isEmpty() ? settings.ocrLlmModel(id)
+                                                                        : m_ocrLlmModelCombo->currentText();
+
+    QStringList proven;
+    QStringList unproven;
+    for (const QString &model : all) {
+        (vision.contains(model) ? proven : unproven).append(model);
+    }
+
+    QSignalBlocker blocker(m_ocrLlmModelCombo);
+    m_ocrLlmModelCombo->clear();
+
+    const auto addHeader = [this](const QString &text) {
+        m_ocrLlmModelCombo->addItem(text);
+        const int row = m_ocrLlmModelCombo->count() - 1;
+        m_ocrLlmModelCombo->setItemData(row, QVariant(), Qt::UserRole - 1); // non-selectable
+    };
+
+    const bool grouped = !proven.isEmpty() && !unproven.isEmpty();
+    if (grouped) {
+        addHeader(tr("— Server reports image support —"));
+    }
+    m_ocrLlmModelCombo->addItems(proven);
+    if (grouped) {
+        addHeader(tr("— Image support not reported —"));
+    }
+    m_ocrLlmModelCombo->addItems(unproven);
+
+    if (!current.isEmpty() && m_ocrLlmModelCombo->findText(current) < 0) {
+        m_ocrLlmModelCombo->insertItem(0, current);
+    }
+    m_ocrLlmModelCombo->setCurrentText(current);
+    blocker.unblock();
+
+    widenComboPopup(m_ocrLlmModelCombo);
+
+    if (all.isEmpty()) {
+        m_ocrLlmCapabilityHint->setText(tr("No models loaded yet — press \"Refresh models\", or type a model name."));
+    } else if (!VisionModelProbe::reportsCapabilities(id)) {
+        m_ocrLlmCapabilityHint->setText(tr("This endpoint does not report model capabilities, so image support cannot be verified here. "
+                                           "Pick a model you know accepts images."));
+    } else if (proven.isEmpty()) {
+        m_ocrLlmCapabilityHint->setText(tr("The server reported no image-capable models. A model listed here may still work, "
+                                           "but recognition will fail if it cannot accept images."));
+    } else {
+        m_ocrLlmCapabilityHint->setText(tr("%n model(s) reported as image-capable.", nullptr, static_cast<int>(proven.size())));
+    }
+}
+
+void SettingsDialog::loadOcrLlmProvider(const QString &id)
+{
+    storeOcrPromptEdits();
+
+    const AppSettings settings;
+    {
+        QSignalBlocker urlBlocker(m_ocrLlmUrlEdit);
+        m_ocrLlmUrlEdit->setText(settings.ocrLlmUrl(id));
+        m_ocrLlmUrlEdit->setPlaceholderText(AppSettings::defaultLocalProviderUrl(id));
+        QSignalBlocker keyBlocker(m_ocrLlmApiKeyEdit);
+        m_ocrLlmApiKeyEdit->setText(settings.ocrLlmApiKey(id));
+        QSignalBlocker timeoutBlocker(m_ocrLlmTimeoutSpin);
+        m_ocrLlmTimeoutSpin->setValue(settings.ocrLlmTimeout(id));
+        QSignalBlocker modelBlocker(m_ocrLlmModelCombo);
+        m_ocrLlmModelCombo->setCurrentText(settings.ocrLlmModel(id));
+    }
+    populateOcrModelCombo(id);
+
+    m_ocrLlmPromptModel.clear();
+    showOcrPromptFor(m_ocrLlmModelCombo->currentText());
+}
+
+// Files whatever is in the prompt editor under the model it was written for,
+// before the selection moves on to a different model.
+void SettingsDialog::storeOcrPromptEdits()
+{
+    if (m_ocrLlmPromptModel.isEmpty()) {
+        return;
+    }
+    AppSettings settings;
+    const QString edited = m_ocrLlmPromptEdit->toPlainText();
+    if (edited != settings.ocrLlmPrompt(m_ocrLlmPromptModel)) {
+        settings.setOcrLlmPrompt(m_ocrLlmPromptModel, edited);
+    }
+}
+
+void SettingsDialog::showOcrPromptFor(const QString &model)
+{
+    if (model == m_ocrLlmPromptModel) {
+        return;
+    }
+    storeOcrPromptEdits();
+    m_ocrLlmPromptModel = model;
+    m_ocrLlmPromptEdit->setPlainText(AppSettings().ocrLlmPrompt(model));
+}
+
+void SettingsDialog::refreshOcrModels()
+{
+    const QString id = m_ocrLlmProviderCombo->currentData().toString();
+    QString base = m_ocrLlmUrlEdit->text().trimmed();
+    if (base.isEmpty()) {
+        base = AppSettings::defaultLocalProviderUrl(id);
+    }
+    if (base.isEmpty()) {
+        QMessageBox::warning(this, tr("Vision model"), tr("Enter the endpoint URL first."));
+        return;
+    }
+
+    if (m_ocrLlmProbe == nullptr) {
+        m_ocrLlmProbe = new VisionModelProbe(this);
+        connect(m_ocrLlmProbe, &VisionModelProbe::finished, this, [this](const QStringList &all, const QStringList &vision) {
+            m_ocrLlmRefreshButton->setEnabled(true);
+            const QString probedId = m_ocrLlmProviderCombo->currentData().toString();
+            if (all.isEmpty()) {
+                QMessageBox::information(this, AppSettings::localProviderDisplayName(probedId), tr("No models found."));
+                return;
             }
+            AppSettings settings;
+            settings.setOcrLlmModels(probedId, all);
+            settings.setOcrLlmVisionModels(probedId, vision);
+            populateOcrModelCombo(probedId);
         });
-        if (t.vision.disableThinking) {
-            connect(t.vision.disableThinking, &QCheckBox::toggled, this, [this, id](bool checked) {
-                AppSettings().setLocalAiDisableVisionThinking(id, checked);
-            });
-        }
+        connect(m_ocrLlmProbe, &VisionModelProbe::failed, this, [this](const QString &error) {
+            m_ocrLlmRefreshButton->setEnabled(true);
+            const QString probedId = m_ocrLlmProviderCombo->currentData().toString();
+            QMessageBox::warning(this,
+                                 AppSettings::localProviderDisplayName(probedId),
+                                 tr("Could not reach %1 at %2:\n%3")
+                                     .arg(AppSettings::localProviderDisplayName(probedId), m_ocrLlmUrlEdit->text(), error));
+        });
+    }
+
+    m_ocrLlmRefreshButton->setEnabled(false);
+    m_ocrLlmProbe->probe(id, base, m_ocrLlmApiKeyEdit->text());
+}
+
+void SettingsDialog::updateOcrEngineVisibility(int engineValue)
+{
+    const bool isTesseract = (engineValue == static_cast<int>(AppSettings::OcrEngine::Tesseract));
+    ui->languagesGroupBox->setVisible(isTesseract);
+    ui->ocrParametersGroupBox->setVisible(isTesseract);
+    // screenCaptureGroupBox is engine-agnostic and stays visible always.
+}
+
+void SettingsDialog::loadOcrEngineSettings()
+{
+    const AppSettings settings;
+    const int index = m_ocrEngineCombo->findData(static_cast<int>(settings.ocrEngine()));
+    if (index >= 0) {
+        m_ocrEngineCombo->setCurrentIndex(index);
+    }
+    const QString providerId = settings.ocrLlmProvider();
+    const int providerIndex = m_ocrLlmProviderCombo->findData(providerId);
+    if (providerIndex >= 0) {
+        QSignalBlocker blocker(m_ocrLlmProviderCombo);
+        m_ocrLlmProviderCombo->setCurrentIndex(providerIndex);
+    }
+    loadOcrLlmProvider(m_ocrLlmProviderCombo->currentData().toString());
+    updateOcrEngineVisibility(static_cast<int>(settings.ocrEngine()));
+}
+
+void SettingsDialog::saveOcrEngineSettings()
+{
+    AppSettings settings;
+    const QString id = m_ocrLlmProviderCombo->currentData().toString();
+    const QString model = m_ocrLlmModelCombo->currentText();
+    settings.setOcrEngine(static_cast<AppSettings::OcrEngine>(m_ocrEngineCombo->currentData().toInt()));
+    settings.setOcrLlmProvider(id);
+    settings.setOcrLlmUrl(id, m_ocrLlmUrlEdit->text().trimmed());
+    settings.setOcrLlmApiKey(id, m_ocrLlmApiKeyEdit->text());
+    settings.setOcrLlmModel(id, model);
+    settings.setOcrLlmTimeout(id, m_ocrLlmTimeoutSpin->value());
+    // The editor's contents belong to the model that is about to be used - a
+    // model typed by hand and never picked from the list included.
+    if (!model.isEmpty()) {
+        settings.setOcrLlmPrompt(model, m_ocrLlmPromptEdit->toPlainText());
     }
 }
 
@@ -660,18 +776,6 @@ void SettingsDialog::loadLocalAiSettings()
         QSignalBlocker apiKeyBlocker(tab.apiKey);
         tab.apiKey->setText(settings.localProviderApiKey(id));
 
-        const bool isVision = settings.isVisionEnabled(id);
-        {
-            QSignalBlocker blocker(tab.modeGroup);
-            if (isVision) {
-                tab.visionToggle->setChecked(true);
-                tab.stack->setCurrentIndex(0);
-            } else {
-                tab.textToggle->setChecked(true);
-                tab.stack->setCurrentIndex(1);
-            }
-        }
-
         QStringList models = settings.localProviderModels(id);
         const QString textModel = settings.localProviderModel(id);
         if (!textModel.isEmpty() && !models.contains(textModel)) {
@@ -685,28 +789,12 @@ void SettingsDialog::loadLocalAiSettings()
         }
         widenComboPopup(tab.text.model);
 
-        const QString visionModel = settings.localVisionModel(id);
-        if (!visionModel.isEmpty() && !models.contains(visionModel)) {
-            models.prepend(visionModel);
-        }
-        {
-            QSignalBlocker b(tab.vision.model);
-            tab.vision.model->clear();
-            tab.vision.model->addItems(models);
-            tab.vision.model->setCurrentText(visionModel);
-        }
-        widenComboPopup(tab.vision.model);
-
-        tab.text.prompt->setPlainText(settings.localAiPrompt(textModel));
-        tab.vision.prompt->setPlainText(settings.localVisionPrompt(visionModel));
+        tab.text.promptModel.clear();
+        showLocalAiPromptFor(id, textModel);
         tab.text.timeout->setValue(settings.localAiTimeout(id));
-        tab.vision.timeout->setValue(settings.localAiVisionTimeout(id));
         if (tab.text.disableThinking) {
             tab.text.disableThinking->setChecked(settings.localAiDisableThinking(id));
         }
-        if (tab.vision.disableThinking) {
-            tab.vision.disableThinking->setChecked(settings.localAiDisableVisionThinking(id));
-        }
     }
 
     const int di = ui->detectProviderComboBox->findData(settings.detectProvider());
@@ -716,7 +804,6 @@ void SettingsDialog::loadLocalAiSettings()
             ui->detectProviderComboBox->setCurrentIndex(di);
         }
     }
-    ui->detectViaLlmCheckBox->setChecked(settings.detectViaLlm());
     populateDetectModels();
     {
         QSignalBlocker blocker(ui->detectModelComboBox);
@@ -732,28 +819,45 @@ void SettingsDialog::saveLocalAiSettings()
         const LocalProviderTab &tab = m_localTabs[id];
         settings.setLocalProviderUrl(id, tab.url->text());
         settings.setLocalProviderApiKey(id, tab.apiKey->text());
-        settings.setVisionEnabled(id, tab.modeGroup->checkedId() == 0);
         settings.setLocalProviderModel(id, tab.text.model->currentText());
-        settings.setLocalVisionModel(id, tab.vision.model->currentText());
         settings.setLocalAiTimeout(id, tab.text.timeout->value());
-        settings.setLocalAiVisionTimeout(id, tab.vision.timeout->value());
         if (tab.text.disableThinking) {
             settings.setLocalAiDisableThinking(id, tab.text.disableThinking->isChecked());
         }
-        if (tab.vision.disableThinking) {
-            settings.setLocalAiDisableVisionThinking(id, tab.vision.disableThinking->isChecked());
-        }
-        const QString tModel = tab.text.model->currentText();
-        if (!tModel.isEmpty()) {
-            settings.setLocalAiPrompt(tModel, tab.text.prompt->toPlainText());
-        }
-        const QString vModel = tab.vision.model->currentText();
-        if (!vModel.isEmpty()) {
-            settings.setLocalVisionPrompt(vModel, tab.vision.prompt->toPlainText());
+        const QString model = tab.text.model->currentText();
+        if (!model.isEmpty()) {
+            settings.setLocalAiPrompt(model, tab.text.prompt->toPlainText());
         }
     }
 }
 
+// Same commit-on-switch discipline as the OCR prompt: whatever is in the
+// editor belongs to the model it was typed for, not to whichever model gets
+// selected next.
+void SettingsDialog::storeLocalAiPromptEdits(const QString &id)
+{
+    LocalProviderTab &tab = m_localTabs[id];
+    if (tab.text.promptModel.isEmpty()) {
+        return;
+    }
+    AppSettings settings;
+    const QString edited = tab.text.prompt->toPlainText();
+    if (edited != settings.localAiPrompt(tab.text.promptModel)) {
+        settings.setLocalAiPrompt(tab.text.promptModel, edited);
+    }
+}
+
+void SettingsDialog::showLocalAiPromptFor(const QString &id, const QString &model)
+{
+    LocalProviderTab &tab = m_localTabs[id];
+    if (model == tab.text.promptModel) {
+        return;
+    }
+    storeLocalAiPromptEdits(id);
+    tab.text.promptModel = model;
+    tab.text.prompt->setPlainText(AppSettings().localAiPrompt(model));
+}
+
 void SettingsDialog::populateDetectModels()
 {
     const QString id = ui->detectProviderComboBox->currentData().toString();
@@ -785,8 +889,8 @@ void SettingsDialog::refreshLocalModels(const QString &id)
         refreshButton->setEnabled(false);
     }
 
-    QNetworkRequest request(QUrl(base + QStringLiteral("/v1/models")));
-    LocalAiTranslationProvider::setAuthHeaders(request, AppSettings::localProviderIsAnthropic(id), m_localTabs[id].apiKey->text());
+    QNetworkRequest request(QUrl(OpenAiEndpoint::modelsUrl(base)));
+    OpenAiEndpoint::setAuthHeaders(request, AppSettings::localProviderIsAnthropic(id), m_localTabs[id].apiKey->text());
 
     auto *manager = new QNetworkAccessManager(this);
     manager->setTransferTimeout(5000);
@@ -834,17 +938,6 @@ void SettingsDialog::refreshLocalModels(const QString &id)
         }
         widenComboPopup(tab.text.model);
 
-        const QString curVision = tab.vision.model->currentText();
-        {
-            QSignalBlocker blocker(tab.vision.model);
-            tab.vision.model->clear();
-            tab.vision.model->addItems(names);
-            if (!curVision.isEmpty()) {
-                tab.vision.model->setCurrentText(curVision);
-            }
-        }
-        widenComboPopup(tab.vision.model);
-
         if (ui->detectProviderComboBox->currentData().toString() == id) {
             populateDetectModels();
         }
@@ -936,7 +1029,7 @@ void SettingsDialog::selectOcrLanguagesPath()
 void SettingsDialog::onOcrLanguagesPathChanged(const QString &path)
 {
     ui->ocrLanguagesListWidget->clear();
-    ui->ocrLanguagesListWidget->addLanguages(Ocr::availableLanguages(path));
+    ui->ocrLanguagesListWidget->addLanguages(TesseractOcr::availableLanguages(path));
 }
 
 #ifdef WITH_PIPER_TTS
@@ -1064,6 +1157,7 @@ void SettingsDialog::restoreDefaults()
 
     ui->trayIconComboBox->setCurrentIndex(AppSettings::defaultTrayIconType());
     ui->customTrayIconEdit->setText(AppSettings::defaultCustomIconPath());
+    ui->showStatusBarCheckBox->setChecked(AppSettings::defaultShowStatusBar());
 
     // Translation settings
     const int defaultTranslationBackendIndex = ui->translationProviderComboBox->findData(QVariant::fromValue(AppSettings().defaultTranslationProviderBackend()));
@@ -1180,6 +1274,7 @@ void SettingsDialog::loadSettings()
 
     ui->trayIconComboBox->setCurrentIndex(settings.trayIconType());
     ui->customTrayIconEdit->setText(settings.customIconPath());
+    ui->showStatusBarCheckBox->setChecked(settings.isShowStatusBar());
 
     // Translation settings
     // Translation provider backend
@@ -1216,6 +1311,7 @@ void SettingsDialog::loadSettings()
 
     // LocalAI
     loadLocalAiSettings();
+    loadOcrEngineSettings();
 
     // OCR
     ui->convertLineBreaksCheckBox->setChecked(settings.isConvertLineBreaks());
diff --git a/src/settings/settingsdialog.h b/src/settings/settingsdialog.h
index 47fc9bb9..a27fd385 100644
--- a/src/settings/settingsdialog.h
+++ b/src/settings/settingsdialog.h
@@ -32,6 +32,7 @@ class QMediaPlaylist;
 class QLabel;
 class QToolButton;
 class ShortcutItem;
+class VisionModelProbe;
 #ifdef WITH_PORTABLE_MODE
 class QCheckBox;
 #endif
@@ -100,6 +101,27 @@ private:
 
     Ui::SettingsDialog *ui;
 
+    // OCR engine selection (Tesseract / LLM) — dynamic widgets on the OCR page.
+    // The LLM engine carries a complete endpoint of its own (URL, key, model
+    // list); it shares nothing but the provider *kinds* with the LocalAI
+    // translation backend.
+    QComboBox *m_ocrEngineCombo = nullptr;
+    QStackedWidget *m_ocrEngineStack = nullptr;
+    QComboBox *m_ocrLlmProviderCombo = nullptr;
+    QLineEdit *m_ocrLlmUrlEdit = nullptr;
+    QLineEdit *m_ocrLlmApiKeyEdit = nullptr;
+    QPushButton *m_ocrLlmRefreshButton = nullptr;
+    QComboBox *m_ocrLlmModelCombo = nullptr;
+    QSpinBox *m_ocrLlmTimeoutSpin = nullptr;
+    QLabel *m_ocrLlmCapabilityHint = nullptr;
+    QPlainTextEdit *m_ocrLlmPromptEdit = nullptr;
+    QPushButton *m_ocrLlmResetPromptButton = nullptr;
+    VisionModelProbe *m_ocrLlmProbe = nullptr;
+    // Which model the prompt editor is currently showing text for, so a
+    // model switch can file the edits under the model they were written for
+    // instead of under whatever is selected afterwards.
+    QString m_ocrLlmPromptModel;
+
     // LocalAI settings (Ollama / FastFlowLM / LM Studio) — dynamic sub-tabs.
     struct LocalProviderMode {
         QWidget *page = nullptr;
@@ -108,23 +130,28 @@ private:
         QSpinBox *timeout = nullptr;
         QCheckBox *disableThinking = nullptr;
         QPlainTextEdit *prompt = nullptr;
+        QString promptModel;
     };
     struct LocalProviderTab {
         QLineEdit *url = nullptr;
         QLineEdit *apiKey = nullptr;
         QPushButton *refresh = nullptr;
-        QPushButton *visionToggle = nullptr;
-        QPushButton *textToggle = nullptr;
-        QButtonGroup *modeGroup = nullptr;
-        QStackedWidget *stack = nullptr;
-        QWidget *debugContainer = nullptr;
-        QCheckBox *debugCheckBox = nullptr;
         LocalProviderMode text;
-        LocalProviderMode vision;
     };
     void buildLocalAiTabs();
+    void buildOcrEngineUi();
+    void loadOcrEngineSettings();
+    void saveOcrEngineSettings();
+    void updateOcrEngineVisibility(int engineValue);
+    void loadOcrLlmProvider(const QString &id);
+    void populateOcrModelCombo(const QString &id);
+    void refreshOcrModels();
+    void showOcrPromptFor(const QString &model);
+    void storeOcrPromptEdits();
     void loadLocalAiSettings();
     void saveLocalAiSettings();
+    void showLocalAiPromptFor(const QString &id, const QString &model);
+    void storeLocalAiPromptEdits(const QString &id);
     void refreshLocalModels(const QString &id);
     void populateDetectModels();
 
diff --git a/src/settings/settingsdialog.ui b/src/settings/settingsdialog.ui
index 47e376ed..562147b7 100644
--- a/src/settings/settingsdialog.ui
+++ b/src/settings/settingsdialog.ui
@@ -626,8 +626,27 @@
               </layout>
              </widget>
             </item>
-            <item>
-             <spacer name="interfacePageSpacer">
+             <item>
+              <widget class="QGroupBox" name="statusBarGroupBox">
+               <property name="title">
+                <string>Status bar</string>
+               </property>
+               <layout class="QVBoxLayout" name="statusBarLayout">
+                <item>
+                 <widget class="QCheckBox" name="showStatusBarCheckBox">
+                  <property name="toolTip">
+                   <string>&lt;html&gt;&lt;head/&lt;body&gt;&lt;p&gt;Show what the application is currently doing at the bottom of the main and pop-up windows&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+                  </property>
+                  <property name="text">
+                   <string>Show status bar</string>
+                  </property>
+                 </widget>
+                </item>
+               </layout>
+              </widget>
+             </item>
+             <item>
+              <spacer name="interfacePageSpacer">
               <property name="orientation">
                <enum>Qt::Vertical</enum>
               </property>
@@ -729,38 +748,35 @@
                    </property>
                   </widget>
                  </item>
-                 <item row="4" column="0" colspan="2">
-                  <widget class="QCheckBox" name="detectViaLlmCheckBox">
-                   <property name="toolTip">
-                    <string>Detect the source language using a LocalAI model instead of the built-in heuristic. Choose the provider and model below.</string>
-                   </property>
-                   <property name="text">
-                    <string>Detect language via LLM (LocalAI)</string>
-                   </property>
-                  </widget>
-                 </item>
-                 <item row="5" column="0">
+                 <item row="4" column="0">
                   <widget class="QLabel" name="detectProviderLabel">
                    <property name="text">
                     <string>Detection provider:</string>
                    </property>
                   </widget>
                  </item>
-                 <item row="5" column="1">
-                  <widget class="QComboBox" name="detectProviderComboBox"/>
+                 <item row="4" column="1">
+                  <widget class="QComboBox" name="detectProviderComboBox">
+                   <property name="toolTip">
+                    <string>Provider and model used to detect the source language when it is set to Auto. If no detection model is selected, translation falls back to the configured primary language above.</string>
+                   </property>
+                  </widget>
                  </item>
-                 <item row="6" column="0">
+                 <item row="5" column="0">
                   <widget class="QLabel" name="detectModelLabel">
                    <property name="text">
                     <string>Detection model:</string>
                    </property>
                   </widget>
                  </item>
-                 <item row="6" column="1">
+                 <item row="5" column="1">
                   <widget class="QComboBox" name="detectModelComboBox">
                    <property name="editable">
                     <bool>true</bool>
                    </property>
+                   <property name="toolTip">
+                    <string>Provider and model used to detect the source language when it is set to Auto. If no detection model is selected, translation falls back to the configured primary language above.</string>
+                   </property>
                   </widget>
                  </item>
                 </layout>
diff --git a/src/statusstrip.cpp b/src/statusstrip.cpp
new file mode 100644
index 00000000..8271d0ff
--- /dev/null
+++ b/src/statusstrip.cpp
@@ -0,0 +1,212 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#include "statusstrip.h"
+#include "ui_statusstrip.h"
+
+#include <QEvent>
+#include <QLabel>
+#include <QPalette>
+#include <QTimer>
+
+using namespace std::chrono_literals;
+
+StatusStrip::StatusStrip(QWidget *parent)
+    : QWidget(parent)
+    , ui(new Ui::StatusStrip)
+    , m_ellipsisTimer(new QTimer(this))
+{
+    ui->setupUi(this);
+
+    m_readyLabel = new QLabel(tr("Ready"), this);
+    m_readyLabel->setObjectName(QStringLiteral("readyLabel"));
+    ui->stripLayout->addWidget(m_readyLabel);
+    static const char *const segmentNames[] = {"snippingSegmentLabel", "ocrSegmentLabel", "translationSegmentLabel", "ttsSegmentLabel"};
+    for (int i = 0; i < ModuleStatus::moduleCount(); ++i) {
+        auto *label = new QLabel(this);
+        // Named for automated UI testing (findChild) - QLabel reports nothing
+        // to AT-SPI without an accessible name either.
+        label->setObjectName(QString::fromLatin1(segmentNames[i]));
+        // The layout has zero spacing; give every segment after the first a
+        // little air so simultaneous segments don't mash together.
+        if (i > 0)
+            label->setContentsMargins(9, 0, 0, 0);
+        m_moduleLabels[static_cast<size_t>(i)] = label;
+        ui->stripLayout->addWidget(label);
+    }
+
+    // Busy messages are stored dot-free in the model; the view owns the
+    // animation, and the timer runs only while something is actually Busy.
+    m_ellipsisTimer->setInterval(400ms);
+    connect(m_ellipsisTimer, &QTimer::timeout, this, &StatusStrip::renderDots);
+
+    renderModules();
+}
+
+StatusStrip::~StatusStrip()
+{
+    delete ui;
+}
+
+void StatusStrip::setModel(ModuleStatus *model)
+{
+    if (m_model != nullptr)
+        disconnect(m_model, &ModuleStatus::changed, this, &StatusStrip::renderModules);
+    m_model = model;
+    if (model != nullptr)
+        connect(model, &ModuleStatus::changed, this, &StatusStrip::renderModules);
+    renderModules();
+}
+
+void StatusStrip::setHideWhenIdle(bool hide)
+{
+    m_hideWhenIdle = hide;
+    updateVisibility();
+}
+
+void StatusStrip::setShown(bool shown)
+{
+    m_shown = shown;
+    renderModules();
+}
+
+void StatusStrip::changeEvent(QEvent *event)
+{
+    switch (event->type()) {
+    case QEvent::LanguageChange:
+        m_readyLabel->setText(tr("Ready"));
+        renderModules(); // messages are tr()'d on demand by the model
+        break;
+    case QEvent::PaletteChange:
+    case QEvent::ApplicationPaletteChange:
+        renderModules(); // the error colour is derived from the palette
+        break;
+    default:
+        QWidget::changeEvent(event);
+    }
+}
+
+void StatusStrip::renderModules()
+{
+    if (m_model != nullptr) {
+        bool anyActive = false;
+        for (int i = 0; i < ModuleStatus::moduleCount(); ++i) {
+            const auto module = static_cast<ModuleStatus::Module>(i);
+            QLabel *label = m_moduleLabels[static_cast<size_t>(i)];
+            const auto activity = m_model->activity(module);
+
+            if (!m_model->isAvailable(module) || activity == ModuleStatus::Activity::Idle) {
+                label->hide();
+                label->clear();
+                label->setAccessibleName({});
+                label->setToolTip({});
+                continue;
+            }
+
+            anyActive = true;
+            const bool busy = activity == ModuleStatus::Activity::Busy;
+            label->setText(m_model->message(module) + (busy ? dots(m_dotCount) : QString()));
+            // The accessible name carries the dot-free message: the animated
+            // ellipsis must not rename the widget for AT-SPI/UIA exact-match
+            // lookups (and screen readers) four times a second.
+            label->setAccessibleName(m_model->message(module));
+            label->setToolTip(m_model->detail(module));
+
+            QPalette labelPalette = palette();
+            labelPalette.setColor(label->foregroundRole(), busy ? labelPalette.color(QPalette::WindowText) : errorTextColor(this));
+            label->setPalette(labelPalette);
+            label->show();
+        }
+        m_readyLabel->setVisible(!anyActive);
+        if (!anyActive) {
+            // Same identity discipline as the segments below: a label that
+            // isn't shown must not keep announcing (or keep a name-matchable
+            // identity) through the AT-SPI tree.
+            m_readyLabel->setText(tr("Ready"));
+            m_readyLabel->setAccessibleName(tr("Ready"));
+        } else {
+            m_readyLabel->clear();
+            m_readyLabel->setAccessibleName({});
+        }
+
+        if (m_model->isBusy())
+            m_ellipsisTimer->start();
+        else {
+            m_ellipsisTimer->stop();
+            m_dotCount = 0;
+        }
+    } else {
+        for (QLabel *label : m_moduleLabels)
+            label->hide();
+        m_readyLabel->setText(tr("Ready"));
+        m_readyLabel->setAccessibleName(tr("Ready"));
+        m_readyLabel->show();
+    }
+
+    updateVisibility();
+}
+
+void StatusStrip::renderDots()
+{
+    if (m_model == nullptr)
+        return;
+
+    m_dotCount = (m_dotCount + 1) % 4;
+    for (int i = 0; i < ModuleStatus::moduleCount(); ++i) {
+        const auto module = static_cast<ModuleStatus::Module>(i);
+        if (m_model->isAvailable(module) && m_model->activity(module) == ModuleStatus::Activity::Busy)
+            m_moduleLabels[static_cast<size_t>(i)]->setText(m_model->message(module) + dots(m_dotCount));
+    }
+}
+
+bool StatusStrip::hasActiveSegments() const
+{
+    if (m_model == nullptr)
+        return false;
+
+    for (int i = 0; i < ModuleStatus::moduleCount(); ++i) {
+        const auto module = static_cast<ModuleStatus::Module>(i);
+        if (m_model->isAvailable(module) && m_model->activity(module) != ModuleStatus::Activity::Idle)
+            return true;
+    }
+    return false;
+}
+
+void StatusStrip::updateVisibility()
+{
+    const bool visible = m_shown && (!m_hideWhenIdle || hasActiveSegments());
+    if (!visible) {
+        // Hide and de-identify the labels themselves, not just this widget:
+        // the AT-SPI bridge never prunes interfaces, so a label keeps its
+        // name-matchable identity (and keeps announcing to screen readers)
+        // unless it is cleared and hidden itself - hiding an ancestor alone
+        // does nothing. This covers a strip hidden via its parent
+        // (QStatusBar::setVisible(false) from the ShowStatusBar setting)
+        // and the hide-while-idle pop-up at rest.
+        m_readyLabel->clear();
+        m_readyLabel->setAccessibleName({});
+        m_readyLabel->hide();
+        for (QLabel *label : m_moduleLabels)
+            label->hide();
+    }
+    setVisible(visible);
+}
+
+QColor StatusStrip::errorTextColor(const QWidget *widget)
+{
+    // QPalette has no error role; blend the window text colour toward red so
+    // the result stays legible on both light and dark themes.
+    const QColor base = widget->palette().color(QPalette::WindowText);
+    QColor result;
+    result.setRed(static_cast<int>(base.red() * 0.35 + 255 * 0.65));
+    result.setGreen(static_cast<int>(base.green() * 0.35));
+    result.setBlue(static_cast<int>(base.blue() * 0.35));
+    return result;
+}
+
+QString StatusStrip::dots(int count)
+{
+    return QString(count, QLatin1Char('.'));
+}
diff --git a/src/statusstrip.h b/src/statusstrip.h
new file mode 100644
index 00000000..82f4b3a9
--- /dev/null
+++ b/src/statusstrip.h
@@ -0,0 +1,66 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef STATUSSTRIP_H
+#define STATUSSTRIP_H
+
+#include "modulestatus.h"
+
+#include <QWidget>
+
+#include <array>
+
+class QLabel;
+class QTimer;
+
+namespace Ui
+{
+class StatusStrip;
+} // namespace Ui
+
+// The strip at the bottom of the windows naming what is currently running.
+// One label per module plus a "Ready" label, all created up front and
+// shown/hidden per ModuleStatus::activity() - no add/remove churn on state
+// changes. Only active modules are shown; "Ready" appears when nothing runs.
+class StatusStrip : public QWidget
+{
+    Q_OBJECT
+    Q_DISABLE_COPY(StatusStrip)
+
+public:
+    explicit StatusStrip(QWidget *parent = nullptr);
+    ~StatusStrip() override;
+
+    void setModel(ModuleStatus *model);
+    // The pop-up window hides the whole strip at rest so the resting pop-up
+    // is unchanged; the main window keeps showing "Ready".
+    void setHideWhenIdle(bool hide);
+    // Interface/ShowStatusBar pick-up: a strip hidden by the setting stays
+    // hidden whatever the model does.
+    void setShown(bool shown);
+
+protected:
+    void changeEvent(QEvent *event) override;
+
+private:
+    void renderModules();
+    void renderDots();
+    bool hasActiveSegments() const;
+    void updateVisibility();
+
+    static QColor errorTextColor(const QWidget *widget);
+    static QString dots(int count);
+
+    Ui::StatusStrip *ui;
+    ModuleStatus *m_model = nullptr;
+    QTimer *m_ellipsisTimer;
+    QLabel *m_readyLabel = nullptr;
+    std::array<QLabel *, ModuleStatus::moduleCount()> m_moduleLabels{};
+    int m_dotCount = 0;
+    bool m_hideWhenIdle = false;
+    bool m_shown = true;
+};
+
+#endif // STATUSSTRIP_H
diff --git a/src/statusstrip.ui b/src/statusstrip.ui
new file mode 100644
index 00000000..be815871
--- /dev/null
+++ b/src/statusstrip.ui
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>StatusStrip</class>
+ <widget class="QWidget" name="StatusStrip">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>120</width>
+    <height>22</height>
+   </rect>
+  </property>
+  <layout class="QHBoxLayout" name="stripLayout">
+   <property name="spacing">
+    <number>0</number>
+   </property>
+   <property name="leftMargin">
+    <number>0</number>
+   </property>
+   <property name="topMargin">
+    <number>0</number>
+   </property>
+   <property name="rightMargin">
+    <number>0</number>
+   </property>
+   <property name="bottomMargin">
+    <number>0</number>
+   </property>
+  </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/src/translator/atranslationprovider.h b/src/translator/atranslationprovider.h
index 72d8ddcd..196c605e 100644
--- a/src/translator/atranslationprovider.h
+++ b/src/translator/atranslationprovider.h
@@ -50,16 +50,6 @@ public:
     virtual void abort();
     virtual void reset();
     virtual void finish();
-    virtual void setSourceImage(const QByteArray & /*imageData*/)
-    {
-    }
-    virtual void clearSourceImage()
-    {
-    }
-    virtual bool hasSourceImage() const
-    {
-        return false;
-    }
     virtual QVector<Language> supportedSourceLanguages() = 0;
     virtual QVector<Language> supportedDestinationLanguages() = 0;
     virtual bool supportsAutodetection() const = 0;
@@ -93,6 +83,10 @@ protected:
 
 signals:
     void stateChanged(State newState);
+    // A real (asynchronous) language detection has begun. Copy's
+    // detectLanguage() is synchronous and never emits this. Terminates via
+    // languageDetected() or any stateChanged() (abort/cancel safety net).
+    void detectionStarted();
     void languageDetected(const Language &detectedLanguage, bool isTranslationContext = true);
     void engineChanged(int engineIndex);
 };
diff --git a/src/translator/localaitranslationprovider.cpp b/src/translator/localaitranslationprovider.cpp
index 1f927df9..71b2d357 100644
--- a/src/translator/localaitranslationprovider.cpp
+++ b/src/translator/localaitranslationprovider.cpp
@@ -6,12 +6,14 @@
 #include "localaitranslationprovider.h"
 
 #include "provideroptions.h"
+#include "llm/openaiendpoint.h"
 #include "settings/appsettings.h"
 
 #include <QFile>
 #include <QJsonArray>
 #include <QJsonDocument>
 #include <QJsonObject>
+#include <QLocale>
 #include <QNetworkAccessManager>
 #include <QNetworkReply>
 #include <QNetworkRequest>
@@ -30,7 +32,6 @@ LocalAiTranslationProvider::LocalAiTranslationProvider(QObject *parent)
     , m_url(AppSettings::defaultLocalProviderUrl(QStringLiteral("ollama")))
     , m_model(AppSettings::defaultLocalProviderModel(QStringLiteral("ollama")))
     , m_prompt(AppSettings::defaultLocalAiPrompt())
-    , m_detectViaLlm(false)
     , m_detectUrl(AppSettings::defaultLocalProviderUrl(QStringLiteral("ollama")))
     , m_detectModel()
     , m_sourceWasAuto(false)
@@ -47,28 +48,6 @@ QString LocalAiTranslationProvider::getProviderType() const
     return QStringLiteral("LocalAiTranslationProvider");
 }
 
-QString LocalAiTranslationProvider::completionsUrl(const QString &baseUrl, bool isAnthropic)
-{
-    QString base = baseUrl;
-    while (base.endsWith(QLatin1Char('/'))) {
-        base.chop(1);
-    }
-    return base + (isAnthropic ? QStringLiteral("/v1/messages") : QStringLiteral("/v1/chat/completions"));
-}
-
-void LocalAiTranslationProvider::setAuthHeaders(QNetworkRequest &request, bool isAnthropic, const QString &apiKey)
-{
-    if (apiKey.isEmpty()) {
-        return;
-    }
-    if (isAnthropic) {
-        request.setRawHeader("x-api-key", apiKey.toUtf8());
-        request.setRawHeader("anthropic-version", "2023-06-01");
-    } else {
-        request.setRawHeader("Authorization", "Bearer " + apiKey.toUtf8());
-    }
-}
-
 // ── Supported languages ───────────────────────────────────────
 
 QVector<Language> LocalAiTranslationProvider::supportedSourceLanguages()
@@ -91,28 +70,25 @@ bool LocalAiTranslationProvider::supportsAutodetection() const
 
 Language LocalAiTranslationProvider::detectLanguage(const QString &text)
 {
-    Q_UNUSED(text);
+    if (!m_detectModel.isEmpty()) {
+        // Real detection is possible: fire it async through the same
+        // machinery translate() uses for auto-source detection. This is a
+        // detect-only call (no translate follow-up), so make sure that flag
+        // is false regardless of what any in-flight translate()-triggered
+        // detection left it as - onDetectFinished() branches on it.
+        m_detectThenTranslate = false;
+        sendDetection(text);
+        return Language(QLocale::system()); // placeholder; real result via languageDetected
+    }
+
+    // No detection model configured: no real detection is possible right
+    // now, fall back to the configured primary language.
     const Language lang = AppSettings().primaryLanguage();
     sourceLanguage = lang;
     emit languageDetected(lang, false);
     return lang;
 }
 
-void LocalAiTranslationProvider::setSourceImage(const QByteArray &imageData)
-{
-    m_imageData = imageData;
-}
-
-void LocalAiTranslationProvider::clearSourceImage()
-{
-    m_imageData.clear();
-}
-
-bool LocalAiTranslationProvider::hasSourceImage() const
-{
-    return !m_imageData.isEmpty();
-}
-
 // ── Prompt & formatting ───────────────────────────────────────
 
 QString LocalAiTranslationProvider::languageDisplayName(const QString &code)
@@ -172,8 +148,10 @@ void LocalAiTranslationProvider::translate(const QString &inputText, const Langu
 
     m_sourceWasAuto = (sourceLang == Language::autoLanguage());
 
-    // Model-based detection (opt-in): detect first, then translate.
-    if (m_sourceWasAuto && m_detectViaLlm && !m_detectModel.isEmpty()) {
+    // Detect the source language whenever a detect provider/model is
+    // actually configured (the capability genuinely exists) - the same
+    // machinery detectLanguage() uses.
+    if (m_sourceWasAuto && !m_detectModel.isEmpty()) {
         m_detectThenTranslate = true;
         m_pendingDstCode = dstCode;
         sendDetection(inputText);
@@ -187,7 +165,7 @@ void LocalAiTranslationProvider::translate(const QString &inputText, const Langu
     }
     const QString srcCode = srcLang.toCode();
 
-    if (srcCode == dstCode && !hasSourceImage()) {
+    if (srcCode == dstCode) {
         result = formatResult(inputText);
         error = TranslationError::NoError;
         state = State::Processed;
@@ -210,56 +188,15 @@ void LocalAiTranslationProvider::sendTranslation(const QString &srcCode, const Q
         m_reply = nullptr;
     }
 
-    const bool isVision = !m_imageData.isEmpty();
-
     QJsonObject message;
     message.insert(QStringLiteral("role"), QStringLiteral("user"));
-
-    if (isVision) {
-        const QString srcName = languageDisplayName(srcCode);
-        const QString dstName = languageDisplayName(dstCode);
-        QString vp = m_visionPrompt.isEmpty() ? AppSettings::defaultVisionPrompt() : m_visionPrompt;
-        vp.replace(QStringLiteral("{source_lang}"), srcName);
-        vp.replace(QStringLiteral("{source_code}"), srcCode);
-        vp.replace(QStringLiteral("{target_lang}"), dstName);
-        vp.replace(QStringLiteral("{target_code}"), dstCode);
-
-        QJsonArray content;
-        QJsonObject textPart;
-        textPart.insert(QStringLiteral("type"), QStringLiteral("text"));
-        textPart.insert(QStringLiteral("text"), vp);
-        content.append(textPart);
-
-        QJsonObject imagePart;
-        const QString base64Data = QString::fromLatin1(m_imageData.toBase64());
-        if (m_isAnthropic) {
-            imagePart.insert(QStringLiteral("type"), QStringLiteral("image"));
-            QJsonObject source;
-            source.insert(QStringLiteral("type"), QStringLiteral("base64"));
-            source.insert(QStringLiteral("media_type"), QStringLiteral("image/jpeg"));
-            source.insert(QStringLiteral("data"), base64Data);
-            imagePart.insert(QStringLiteral("source"), source);
-        } else {
-            imagePart.insert(QStringLiteral("type"), QStringLiteral("image_url"));
-            QJsonObject imageUrl;
-            imageUrl.insert(QStringLiteral("url"), QStringLiteral("data:image/jpeg;base64,") + base64Data);
-            imagePart.insert(QStringLiteral("image_url"), imageUrl);
-        }
-        content.append(imagePart);
-
-        message.insert(QStringLiteral("content"), content);
-    } else {
-        const QString prompt = buildPrompt(srcCode, dstCode, text);
-        message.insert(QStringLiteral("content"), prompt);
-    }
+    message.insert(QStringLiteral("content"), buildPrompt(srcCode, dstCode, text));
 
     QJsonArray messages;
     messages.append(message);
 
-    const QString model = isVision ? m_visionModel : m_model;
-
     QJsonObject body;
-    body.insert(QStringLiteral("model"), model);
+    body.insert(QStringLiteral("model"), m_model);
     body.insert(QStringLiteral("messages"), messages);
     body.insert(QStringLiteral("temperature"), 0.0);
     if (m_isAnthropic) {
@@ -268,15 +205,15 @@ void LocalAiTranslationProvider::sendTranslation(const QString &srcCode, const Q
         body.insert(QStringLiteral("max_tokens"), 4096);
     } else {
         body.insert(QStringLiteral("stream"), false);
-        if ((isVision ? m_visionDisableThinking : m_disableThinking)) {
+        if (m_disableThinking) {
             body.insert(QStringLiteral("reasoning_effort"), QStringLiteral("none"));
         }
     }
 
-    QNetworkRequest request(QUrl(completionsUrl(m_url, m_isAnthropic)));
+    QNetworkRequest request(QUrl(OpenAiEndpoint::completionsUrl(m_url, m_isAnthropic)));
     request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
-    setAuthHeaders(request, m_isAnthropic, m_apiKey);
-    m_network->setTransferTimeout((isVision ? m_visionTimeout : m_timeout) * 1000);
+    OpenAiEndpoint::setAuthHeaders(request, m_isAnthropic, m_apiKey);
+    m_network->setTransferTimeout(m_timeout * 1000);
 
     m_reply = m_network->post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
     connect(m_reply, &QNetworkReply::finished, this, &LocalAiTranslationProvider::onReplyFinished);
@@ -303,6 +240,8 @@ static QString detectPrompt()
 
 void LocalAiTranslationProvider::sendDetection(const QString &text)
 {
+    emit detectionStarted();
+
     if (m_detectReply != nullptr) {
         m_detectReply->disconnect(this);
         m_detectReply->abort();
@@ -333,36 +272,15 @@ void LocalAiTranslationProvider::sendDetection(const QString &text)
         }
     }
 
-    QNetworkRequest request(QUrl(completionsUrl(m_detectUrl, m_detectIsAnthropic)));
+    QNetworkRequest request(QUrl(OpenAiEndpoint::completionsUrl(m_detectUrl, m_detectIsAnthropic)));
     request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
-    setAuthHeaders(request, m_detectIsAnthropic, m_detectApiKey);
+    OpenAiEndpoint::setAuthHeaders(request, m_detectIsAnthropic, m_detectApiKey);
     m_network->setTransferTimeout(m_timeout * 1000);
 
     m_detectReply = m_network->post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
     connect(m_detectReply, &QNetworkReply::finished, this, &LocalAiTranslationProvider::onDetectFinished);
 }
 
-static QString extractContent(const QByteArray &data, bool isAnthropic)
-{
-    const QJsonObject obj = QJsonDocument::fromJson(data).object();
-    if (isAnthropic) {
-        const QJsonArray blocks = obj.value(QStringLiteral("content")).toArray();
-        for (const QJsonValue &block : blocks) {
-            const QJsonObject blockObj = block.toObject();
-            if (blockObj.value(QStringLiteral("type")).toString() == QLatin1String("text")) {
-                return blockObj.value(QStringLiteral("text")).toString();
-            }
-        }
-        return QString();
-    }
-    const QJsonArray choices = obj.value(QStringLiteral("choices")).toArray();
-    if (!choices.isEmpty()) {
-        return choices.first().toObject().value(QStringLiteral("message")).toObject().value(QStringLiteral("content")).toString();
-    }
-    // Fallback for Ollama native shape, just in case.
-    return obj.value(QStringLiteral("message")).toObject().value(QStringLiteral("content")).toString();
-}
-
 void LocalAiTranslationProvider::onReplyFinished()
 {
     QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
@@ -395,7 +313,7 @@ void LocalAiTranslationProvider::onReplyFinished()
         return;
     }
 
-    const QString translated = extractContent(reply->readAll(), m_isAnthropic);
+    const QString translated = OpenAiEndpoint::extractContent(reply->readAll(), m_isAnthropic);
     if (translated.isEmpty()) {
         error = TranslationError::Custom;
         errorString = QStringLiteral("LocalAI returned an empty response");
@@ -445,7 +363,7 @@ void LocalAiTranslationProvider::onDetectFinished()
 
     QString code;
     if (reply->error() == QNetworkReply::NoError) {
-        const QString resp = extractContent(reply->readAll(), m_detectIsAnthropic).toLower();
+        const QString resp = OpenAiEndpoint::extractContent(reply->readAll(), m_detectIsAnthropic).toLower();
         static const QRegularExpression re(QStringLiteral("\\b[a-z]{2,3}([-][A-Za-z0-9]+)*\\b"));
         QRegularExpressionMatchIterator it = re.globalMatch(resp);
         if (it.hasNext()) {
@@ -464,7 +382,7 @@ void LocalAiTranslationProvider::onDetectFinished()
         const QString srcCode = detected.toCode();
         emit languageDetected(detected, true);
 
-        if (srcCode == m_pendingDstCode && !hasSourceImage()) {
+        if (srcCode == m_pendingDstCode) {
             result = formatResult(m_pendingText);
             error = TranslationError::NoError;
             state = State::Processed;
@@ -524,9 +442,6 @@ void LocalAiTranslationProvider::applyOptions(const ProviderOptions &options)
     if (options.hasOption("is_anthropic")) {
         m_isAnthropic = options.getOption("is_anthropic").toBool();
     }
-    if (options.hasOption("detect_via_llm")) {
-        m_detectViaLlm = options.getOption("detect_via_llm").toBool();
-    }
     if (options.hasOption("detect_url")) {
         m_detectUrl = options.getOption("detect_url").toString();
     }
@@ -539,21 +454,6 @@ void LocalAiTranslationProvider::applyOptions(const ProviderOptions &options)
     if (options.hasOption("detect_is_anthropic")) {
         m_detectIsAnthropic = options.getOption("detect_is_anthropic").toBool();
     }
-    if (options.hasOption("vision_enabled")) {
-        m_visionEnabled = options.getOption("vision_enabled").toBool();
-    }
-    if (options.hasOption("vision_model")) {
-        m_visionModel = options.getOption("vision_model").toString();
-    }
-    if (options.hasOption("vision_prompt")) {
-        m_visionPrompt = options.getOption("vision_prompt").toString();
-    }
-    if (options.hasOption("vision_disable_thinking")) {
-        m_visionDisableThinking = options.getOption("vision_disable_thinking").toBool();
-    }
-    if (options.hasOption("vision_timeout")) {
-        m_visionTimeout = options.getOption("vision_timeout").toInt();
-    }
 }
 
 std::unique_ptr<ProviderOptions> LocalAiTranslationProvider::getDefaultOptions() const
@@ -566,16 +466,10 @@ std::unique_ptr<ProviderOptions> LocalAiTranslationProvider::getDefaultOptions()
     options->setOption("timeout", AppSettings::defaultLocalAiTimeout());
     options->setOption("api_key", QString());
     options->setOption("is_anthropic", false);
-    options->setOption("detect_via_llm", false);
     options->setOption("detect_url", AppSettings::defaultLocalProviderUrl(QStringLiteral("ollama")));
     options->setOption("detect_model", QString());
     options->setOption("detect_api_key", QString());
     options->setOption("detect_is_anthropic", false);
-    options->setOption("vision_enabled", false);
-    options->setOption("vision_model", QString());
-    options->setOption("vision_prompt", AppSettings::defaultVisionPrompt());
-    options->setOption("vision_disable_thinking", false);
-    options->setOption("vision_timeout", AppSettings::defaultLocalAiTimeout());
     return options;
 }
 
@@ -584,10 +478,8 @@ QStringList LocalAiTranslationProvider::getAvailableOptions() const
     return {QStringLiteral("url"), QStringLiteral("model"), QStringLiteral("prompt"),
             QStringLiteral("disable_thinking"), QStringLiteral("timeout"),
             QStringLiteral("api_key"), QStringLiteral("is_anthropic"),
-            QStringLiteral("detect_via_llm"), QStringLiteral("detect_url"), QStringLiteral("detect_model"),
-            QStringLiteral("detect_api_key"), QStringLiteral("detect_is_anthropic"),
-            QStringLiteral("vision_enabled"), QStringLiteral("vision_model"), QStringLiteral("vision_prompt"),
-            QStringLiteral("vision_disable_thinking"), QStringLiteral("vision_timeout")};
+            QStringLiteral("detect_url"), QStringLiteral("detect_model"),
+            QStringLiteral("detect_api_key"), QStringLiteral("detect_is_anthropic")};
 }
 
 ProviderUIRequirements LocalAiTranslationProvider::getUIRequirements() const
diff --git a/src/translator/localaitranslationprovider.h b/src/translator/localaitranslationprovider.h
index b08616e4..c841305c 100644
--- a/src/translator/localaitranslationprovider.h
+++ b/src/translator/localaitranslationprovider.h
@@ -15,7 +15,6 @@
 
 class QNetworkAccessManager;
 class QNetworkReply;
-class QNetworkRequest;
 
 // Translation backend for local, OpenAI-compatible servers (Ollama,
 // FastFlowLM, LM Studio) as well as arbitrary remote endpoints: a custom
@@ -42,10 +41,6 @@ public:
     Language detectLanguage(const QString &text) override;
     void abort() override;
 
-    void setSourceImage(const QByteArray &imageData) override;
-    void clearSourceImage() override;
-    bool hasSourceImage() const override;
-
     void applyOptions(const ProviderOptions &options) override;
     std::unique_ptr<ProviderOptions> getDefaultOptions() const override;
     QStringList getAvailableOptions() const override;
@@ -54,10 +49,6 @@ public:
 
     void saveOptionToSettings(const QString &optionKey, const QVariant &value) override;
 
-    // Shared with SettingsDialog's "Refresh models" probe, which hits the
-    // same /v1/models endpoint and needs the same auth shape.
-    static void setAuthHeaders(QNetworkRequest &request, bool isAnthropic, const QString &apiKey);
-
 public slots:
     void translate(const QString &inputText, const Language &translationLanguage, const Language &sourceLanguage) override;
 
@@ -69,7 +60,6 @@ private:
     QString buildPrompt(const QString &srcCode, const QString &dstCode, const QString &text) const;
     static QString languageDisplayName(const QString &code);
     static QString formatResult(const QString &text);
-    static QString completionsUrl(const QString &baseUrl, bool isAnthropic);
     void sendDetection(const QString &text);
     void sendTranslation(const QString &srcCode, const QString &dstCode, const QString &text);
 
@@ -86,7 +76,6 @@ private:
     QString m_apiKey;
 
     // Detection (independent provider/model)
-    bool m_detectViaLlm;
     QString m_detectUrl;
     QString m_detectModel;
     bool m_detectIsAnthropic = false;
@@ -100,14 +89,6 @@ private:
     QString m_pendingDstCode;
 
     int m_timeout = 300;
-
-    // Vision
-    bool m_visionEnabled = false;
-    QString m_visionModel;
-    QString m_visionPrompt;
-    bool m_visionDisableThinking = false;
-    int m_visionTimeout = 300;
-    QByteArray m_imageData;
 };
 
 #endif // LOCALAITRANSLATIONPROVIDER_H
diff --git a/src/translator/mozhitranslationprovider.cpp b/src/translator/mozhitranslationprovider.cpp
index ca8c3b58..89552fda 100644
--- a/src/translator/mozhitranslationprovider.cpp
+++ b/src/translator/mozhitranslationprovider.cpp
@@ -58,6 +58,7 @@ Language MozhiTranslationProvider::detectLanguage(const QString &text)
 {
     qDebug() << "MozhiTranslationProvider::detectLanguage - text:" << text.left(50) << "current state:" << static_cast<int>(state);
     m_isDetecting = true;
+    emit detectionStarted();
     m_translator->detectLanguage(text, m_engine);
 
     return QLocale::system();
@@ -169,6 +170,13 @@ void MozhiTranslationProvider::onTranslationFinished()
                      << "new state:" << static_cast<int>(state);
             emit languageDetected(detectedLanguage, false); // Detection context only
             emit stateChanged(state);
+        } else {
+            // A failed detection must still emit a terminal languageDetected
+            // (with the same placeholder detectLanguage() returns), or a
+            // "Detecting language" status would strand forever. The state
+            // machine is untouched: state stays Ready either way.
+            qDebug() << "MozhiTranslationProvider::onTranslationFinished - detection failed";
+            emit languageDetected(Language(QLocale::system()), false);
         }
         return;
     }
diff --git a/src/translator/translationlogic.cpp b/src/translator/translationlogic.cpp
new file mode 100644
index 00000000..ba8c5ba8
--- /dev/null
+++ b/src/translator/translationlogic.cpp
@@ -0,0 +1,38 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#include "translationlogic.h"
+
+namespace TranslationLogic
+{
+
+bool sameLanguage(const Language &a, const Language &b)
+{
+    if (a == b) {
+        return true;
+    }
+    if (a.hasQLocaleEquivalent() && b.hasQLocaleEquivalent()) {
+        return a.toQLocale().language() == b.toQLocale().language();
+    }
+    return false;
+}
+
+Language preferredDestination(const Language &source,
+                              const Language &primary,
+                              const Language &secondary,
+                              const Language &fallback)
+{
+    const Language primaryLang = (primary == Language::autoLanguage()) ? fallback : primary;
+    if (!sameLanguage(primaryLang, source)) {
+        return primaryLang;
+    }
+    const Language secondaryLang = (secondary == Language::autoLanguage()) ? fallback : secondary;
+    if (!sameLanguage(secondaryLang, source)) {
+        return secondaryLang;
+    }
+    return fallback;
+}
+
+} // namespace TranslationLogic
\ No newline at end of file
diff --git a/src/translator/translationlogic.h b/src/translator/translationlogic.h
new file mode 100644
index 00000000..66bc93c7
--- /dev/null
+++ b/src/translator/translationlogic.h
@@ -0,0 +1,38 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef TRANSLATIONLOGIC_H
+#define TRANSLATIONLOGIC_H
+
+#include "language.h"
+
+#include <QLocale>
+
+// The auto (target / source) language rule, shared by every translation
+// backend so none of them can drift. This has regressed repeatedly; the
+// canonical behaviour lives here and is pinned by tests/test_translationlogic.cpp.
+namespace TranslationLogic
+{
+
+// True when the two languages are the same for translation purposes: identical
+// locale, or the same base language ignoring script/territory (en == en_US).
+// A non-locale language only matches itself exactly.
+bool sameLanguage(const Language &a, const Language &b);
+
+// The destination a backend should pick when the user asked for "auto".
+// source is the (already known) source language; primary/secondary come from
+// the settings; fallback (normally the system locale) is used when a primary
+// or secondary isn't configured.
+//   - source != primary   -> primary
+//   - source == primary   -> secondary
+//   - source equals both  -> fallback
+Language preferredDestination(const Language &source,
+                              const Language &primary,
+                              const Language &secondary,
+                              const Language &fallback);
+
+} // namespace TranslationLogic
+
+#endif // TRANSLATIONLOGIC_H
\ No newline at end of file
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index c2d6b2a4..0f973761 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -101,6 +101,16 @@ target_link_libraries(test_localai_provider PRIVATE
 )
 add_test(NAME LocalAiProviderTest COMMAND test_localai_provider)
 
+# Canonical auto-destination language rule - pure, headless (see gotcha list).
+add_executable(test_translationlogic
+    test_translationlogic.cpp
+)
+target_link_libraries(test_translationlogic PRIVATE
+    ${PROJECT_NAME}-lib
+    Qt6::Test
+)
+add_test(NAME TranslationLogicTest COMMAND test_translationlogic)
+
 # LocalAI settings dialog tests (gotchas #6/#7) - needs a display (real
 # MainWindow + SettingsDialog).
 add_executable(test_settingsdialog_localai
@@ -113,6 +123,103 @@ target_link_libraries(test_settingsdialog_localai PRIVATE
 )
 add_test(NAME SettingsDialogLocalAiTest COMMAND test_settingsdialog_localai)
 
+# LlmOcr (vision-model OCR engine) contract tests - headless OK.
+add_executable(test_llmocr
+    test_llmocr.cpp
+)
+target_link_libraries(test_llmocr PRIVATE
+    ${PROJECT_NAME}-lib
+    test-support
+    Qt6::Test
+)
+add_test(NAME LlmOcrTest COMMAND test_llmocr)
+
+# Live LlmOcr regression against a real, locally-running Ollama + vision-OCR
+# model - see test_llmocr_live.cpp for why MockHttpServer can't stand in for
+# this one. Headless OK; self-skips (not fails) when Ollama/the model isn't
+# available, so it is safe in the default test run on machines without it.
+add_executable(test_llmocr_live
+    test_llmocr_live.cpp
+)
+target_link_libraries(test_llmocr_live PRIVATE
+    ${PROJECT_NAME}-lib
+    Qt6::Test
+)
+add_test(NAME LlmOcrLiveTest COMMAND test_llmocr_live)
+
+# ModuleStatus aggregator contract - headless OK (nothing is ever shown).
+add_executable(test_modulestatus
+    test_modulestatus.cpp
+)
+target_link_libraries(test_modulestatus PRIVATE
+    ${PROJECT_NAME}-lib
+    Qt6::Test
+)
+add_test(NAME ModuleStatusTest COMMAND test_modulestatus)
+
+# StatusStrip view contract - headless OK: visibility is asserted through
+# isHidden() on never-shown widgets, so no window exposure is needed.
+add_executable(test_statusstrip
+    test_statusstrip.cpp
+)
+target_link_libraries(test_statusstrip PRIVATE
+    ${PROJECT_NAME}-lib
+    Qt6::Test
+)
+add_test(NAME StatusStripTest COMMAND test_statusstrip)
+
+# SnippingArea terminal-signal regression guard (empty selection used to emit
+# nothing at all) - key/mouse events on a never-shown widget, headless OK.
+add_executable(test_snippingarea_terminal
+    test_snippingarea_terminal.cpp
+)
+target_link_libraries(test_snippingarea_terminal PRIVATE
+    ${PROJECT_NAME}-lib
+    Qt6::Test
+)
+add_test(NAME SnippingAreaTerminalTest COMMAND test_snippingarea_terminal)
+
+# Module status through a real MainWindow (Copy backend, no network): busy
+# observation during the synchronous cascade, sticky errors through
+# MainWindow's own reset wiring, the pop-up strip, and the ShowStatusBar
+# pick-up - needs a display.
+add_executable(test_mainwindow_status
+    test_mainwindow_status.cpp
+)
+target_compile_definitions(test_mainwindow_status PRIVATE BUILD_TESTING)
+target_link_libraries(test_mainwindow_status PRIVATE
+    ${PROJECT_NAME}-lib
+    Qt6::Test
+)
+add_test(NAME MainWindowStatusTest COMMAND test_mainwindow_status)
+
+# Everyday MainWindow features end-to-end, offline: swap, clear, copy
+# buttons, abort, paste-image OCR chain, Escape-cancel, ShowStatusBar
+# settings round-trip - LocalAI translation and LLM OCR both pointed at
+# MockHttpServer - needs a display.
+add_executable(test_mainwindow_features
+    test_mainwindow_features.cpp
+)
+target_compile_definitions(test_mainwindow_features PRIVATE BUILD_TESTING)
+target_link_libraries(test_mainwindow_features PRIVATE
+    ${PROJECT_NAME}-lib
+    test-support
+    Qt6::Test
+)
+add_test(NAME MainWindowFeaturesTest COMMAND test_mainwindow_features)
+
+# LocalAI prompt resolution: stored-vs-default precedence, including the
+# superseded default that master still persists - headless OK.
+add_executable(test_appsettings_prompts
+    test_appsettings_prompts.cpp
+)
+target_link_libraries(test_appsettings_prompts PRIVATE
+    ${PROJECT_NAME}-lib
+    test-support
+    Qt6::Test
+)
+add_test(NAME AppSettingsPromptsTest COMMAND test_appsettings_prompts)
+
 # Generic ATranslationProvider contract - runs the same invariants (state
 # machine, MainWindow's finish()/reset() cascades, the Q_UNREACHABLE()-
 # guarded Processed/NoError pairing) against every backend automatically.
@@ -132,7 +239,7 @@ add_test(NAME TranslationProviderContractTest COMMAND test_translation_provider_
 # When using static ONNX Runtime, also configure test executables that link
 # the app library (they pull in Piper TTS transitively through it).
 if(WITH_PIPER_TTS AND ONNXRuntime_USE_STATIC)
-    foreach(_test_target IN ITEMS test_popupwindow_lifetime test_snippingarea_flags test_ttsprovider_isolation test_translation test_localai_provider test_settingsdialog_localai test_translation_provider_contract)
+    foreach(_test_target IN ITEMS test_popupwindow_lifetime test_snippingarea_flags test_ttsprovider_isolation test_translation test_localai_provider test_settingsdialog_localai test_llmocr test_llmocr_live test_translationlogic test_modulestatus test_statusstrip test_snippingarea_terminal test_mainwindow_status test_mainwindow_features test_appsettings_prompts test_translation_provider_contract)
         configure_onnxruntime_static(${_test_target})
         add_dependencies(${_test_target} onnxruntime_ready)
     endforeach()
@@ -149,7 +256,7 @@ endif()
 # configuration (see build.zsh's build-test/build-test-nopiper) still builds
 # and runs them.
 if(NOT BUILD_TESTING)
-    foreach(_test_target IN ITEMS test_helper_window test_dbus_static_init test_popupwindow_lifetime test_snippingarea_flags test_ttsprovider_isolation test_translation test_localai_provider test_settingsdialog_localai test_translation_provider_contract)
+    foreach(_test_target IN ITEMS test_helper_window test_dbus_static_init test_popupwindow_lifetime test_snippingarea_flags test_ttsprovider_isolation test_translation test_localai_provider test_settingsdialog_localai test_llmocr test_llmocr_live test_translationlogic test_modulestatus test_statusstrip test_snippingarea_terminal test_mainwindow_status test_mainwindow_features test_appsettings_prompts test_translation_provider_contract)
         set_target_properties(${_test_target} PROPERTIES EXCLUDE_FROM_ALL TRUE)
     endforeach()
     if(TARGET test_singleapplication_recovery)
diff --git a/tests/test_appsettings_prompts.cpp b/tests/test_appsettings_prompts.cpp
new file mode 100644
index 00000000..2c9f02be
--- /dev/null
+++ b/tests/test_appsettings_prompts.cpp
@@ -0,0 +1,117 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// localAiPrompt() has to distinguish "the user wrote this prompt" from "an
+// older build's settings dialog auto-saved its default here", because only the
+// former may override the current default. Getting that wrong is what made
+// ollama keep showing the superseded source-language prompt while a model added
+// later showed the new one.
+
+#include "testisolation.h"
+#include "settings/appsettings.h"
+
+#include <QCoreApplication>
+#include <QSettings>
+#include <QTest>
+
+namespace
+{
+
+// Byte-for-byte the default that master still ships, and that the old dialog
+// persisted under every model whose page was opened.
+QString supersededPrompt()
+{
+    return QStringLiteral(
+        "You are a professional {source_lang} ({source_code}) to {target_lang} ({target_code}) translator. "
+        "Your goal is to accurately convey the meaning and nuances of the original {source_lang} text "
+        "while adhering to {target_lang} grammar, vocabulary, and cultural sensitivities.\n"
+        "Produce only the {target_lang} translation, without any additional explanations or commentary. "
+        "Please translate the following {source_lang} text into {target_lang}:\n\n\n{text}");
+}
+
+void storePrompt(const QString &model, const QString &prompt)
+{
+    QSettings settings;
+    settings.setValue(QStringLiteral("LocalAI/Prompts/") + model, prompt);
+    settings.sync();
+}
+
+} // namespace
+
+class AppSettingsPromptsTest : public QObject
+{
+    Q_OBJECT
+
+private slots:
+    void init()
+    {
+        QSettings settings;
+        settings.remove(QStringLiteral("LocalAI"));
+        settings.sync();
+    }
+
+    void testUnsetPromptIsTheCurrentDefault()
+    {
+        const QString prompt = AppSettings().localAiPrompt(QStringLiteral("never-configured"));
+        QCOMPARE(prompt, AppSettings::defaultLocalAiPrompt());
+    }
+
+    // The reported bug, at the level it actually lives: a stored copy of the
+    // superseded default must not win over the current default.
+    void testSupersededDefaultDoesNotOverrideCurrentDefault()
+    {
+        storePrompt(QStringLiteral("translategemma_12b_128k:latest"), supersededPrompt());
+
+        const QString prompt = AppSettings().localAiPrompt(QStringLiteral("translategemma_12b_128k:latest"));
+        QCOMPARE(prompt, AppSettings::defaultLocalAiPrompt());
+        QVERIFY(!prompt.contains(QStringLiteral("{source_lang}")));
+    }
+
+    void testEmptyStoredPromptFallsBackToDefault()
+    {
+        storePrompt(QStringLiteral("half-typed"), QString());
+
+        QCOMPARE(AppSettings().localAiPrompt(QStringLiteral("half-typed")), AppSettings::defaultLocalAiPrompt());
+    }
+
+    void testCustomPromptIsReturnedUntouched()
+    {
+        const QString mine = QStringLiteral("Translate into {target_lang}. Be terse. {text}");
+        storePrompt(QStringLiteral("glm-4.5"), mine);
+
+        QCOMPARE(AppSettings().localAiPrompt(QStringLiteral("glm-4.5")), mine);
+    }
+
+    // A prompt that merely resembles the superseded default - the user's own
+    // edit of it - is still the user's, and must survive.
+    void testEditedSupersededPromptIsRespected()
+    {
+        const QString edited = supersededPrompt() + QStringLiteral("\nKeep proper nouns untranslated.");
+        storePrompt(QStringLiteral("my-model"), edited);
+
+        QCOMPARE(AppSettings().localAiPrompt(QStringLiteral("my-model")), edited);
+    }
+
+    // A '/' in a model name would otherwise open a nested QSettings group,
+    // filing the prompt under a key the reader never looks at.
+    void testSlashInModelNameRoundTrips()
+    {
+        AppSettings settings;
+        const QString mine = QStringLiteral("Transcribe, do not translate. {text}");
+        settings.setLocalAiPrompt(QStringLiteral("openai/gpt-oss"), mine);
+
+        QCOMPARE(settings.localAiPrompt(QStringLiteral("openai/gpt-oss")), mine);
+    }
+};
+
+int main(int argc, char *argv[])
+{
+    isolateTestSettings();
+    QCoreApplication app(argc, argv);
+    AppSettingsPromptsTest test;
+    return QTest::qExec(&test, argc, argv);
+}
+
+#include "test_appsettings_prompts.moc"
diff --git a/tests/test_llmocr.cpp b/tests/test_llmocr.cpp
new file mode 100644
index 00000000..893e2897
--- /dev/null
+++ b/tests/test_llmocr.cpp
@@ -0,0 +1,338 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// LlmOcr (vision-model OCR engine) contract: image in -> transcription out,
+// never a translation. Pins the wire shape (multimodal chat-completions with
+// an image part, transcription prompt) and that the OCR path is fully
+// decoupled from LocalAiTranslationProvider.
+
+#include "mockhttpserver.h"
+#include "provideroptions.h"
+#include "ocr/llmocr.h"
+#include "translator/localaitranslationprovider.h"
+
+#include <QCoreApplication>
+#include <QImage>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QSignalSpy>
+#include <QTest>
+
+using Response = MockHttpServer::Response;
+
+namespace
+{
+
+QByteArray chatCompletionJson(const QString &content)
+{
+    QJsonObject message;
+    message.insert(QStringLiteral("role"), QStringLiteral("assistant"));
+    message.insert(QStringLiteral("content"), content);
+    QJsonObject choice;
+    choice.insert(QStringLiteral("message"), message);
+    QJsonArray choices;
+    choices.append(choice);
+    QJsonObject body;
+    body.insert(QStringLiteral("choices"), choices);
+    return QJsonDocument(body).toJson(QJsonDocument::Compact);
+}
+
+} // namespace
+
+class LlmOcrTest : public QObject
+{
+    Q_OBJECT
+
+private slots:
+    void testRecognizeSendsMultimodalTranscriptionRequest()
+    {
+        MockHttpServer server;
+        Response response;
+        response.status = 200;
+        response.body = chatCompletionJson(QStringLiteral("Hello world"));
+        server.queueResponse(response);
+
+        LlmOcr ocr;
+        ocr.setEndpoint(server.baseUrl(), false, QString());
+        ocr.setModel(QStringLiteral("vision-model"));
+
+        QImage image(8, 8, QImage::Format_RGB32);
+        image.fill(Qt::white);
+
+        QSignalSpy spy(&ocr, &LlmOcr::recognized);
+        ocr.recognize(image, 96);
+
+        QVERIFY(spy.wait(5000));
+        QCOMPARE(spy.constFirst().at(0).toString(), QStringLiteral("Hello world"));
+
+        QCOMPARE(server.requestCount(), 1);
+        QVERIFY(server.requestPath(0).endsWith(QStringLiteral("/v1/chat/completions")));
+
+        const QJsonObject body = QJsonDocument::fromJson(server.requestBody(0)).object();
+        QCOMPARE(body.value(QStringLiteral("model")).toString(), QStringLiteral("vision-model"));
+
+        const QJsonArray messages = body.value(QStringLiteral("messages")).toArray();
+        QCOMPARE(messages.size(), 1);
+        const QJsonArray content = messages.at(0).toObject().value(QStringLiteral("content")).toArray();
+        QCOMPARE(content.size(), 2);
+        QCOMPARE(content.at(0).toObject().value(QStringLiteral("type")).toString(), QStringLiteral("text"));
+        QCOMPARE(content.at(1).toObject().value(QStringLiteral("type")).toString(), QStringLiteral("image_url"));
+
+        const QString prompt = content.at(0).toObject().value(QStringLiteral("text")).toString();
+        QVERIFY(prompt.contains(QStringLiteral("Transcribe"), Qt::CaseInsensitive));
+    }
+
+    void testCollapseRepeatedTranscription()
+    {
+        // The repeat-prone-local-model signature: the completed transcription
+        // re-emitted verbatim, blank-line separated. Collapses to one copy.
+        const QString block = QStringLiteral("System Monitor\nCPU 12% Memory 1.2GiB\nNotifications");
+        const QString repeated = block + QStringLiteral("\n\n") + block + QStringLiteral("\n\n") + block;
+        QCOMPARE(LlmOcr::collapseRepeatedTranscription(repeated), block);
+
+        // A single clean transcription is left untouched.
+        QCOMPARE(LlmOcr::collapseRepeatedTranscription(block), block);
+
+        // Empty / whitespace-only trims to empty.
+        QCOMPARE(LlmOcr::collapseRepeatedTranscription(QString()), QString());
+        QCOMPARE(LlmOcr::collapseRepeatedTranscription(QStringLiteral(" ")), QString());
+
+        // A single line looped to runaway length collapses.
+        QCOMPARE(LlmOcr::collapseRepeatedTranscription(QStringLiteral("no\nno\nno\nno")), QStringLiteral("no"));
+
+        // A multi-line block repeated twice now collapses too (2-copy bar,
+        // user decision): a 2-line refrain poem doubled verbatim is the known,
+        // accepted edge.
+        const QString twoCopies = block + QStringLiteral("\n\n") + block;
+        QCOMPARE(LlmOcr::collapseRepeatedTranscription(twoCopies), block);
+
+        // A short poem repeating one line three times stays intact (single
+        // lines only read as runaway at four or more copies).
+        const QString triple = QStringLiteral("Water\nWater\nWater");
+        QCOMPARE(LlmOcr::collapseRepeatedTranscription(triple), triple);
+
+        // A poem that repeats its first stanza verbatim at the end (not an
+        // exact tiling of the whole response) keeps every line.
+        const QString enveloped = QStringLiteral("Once upon a time\nIn a land far away\nOnce upon a time");
+        QCOMPARE(LlmOcr::collapseRepeatedTranscription(enveloped), enveloped);
+
+        // The real runaway signature ends mid-line when the token budget cuts
+        // generation: full verbatim copies plus a partial block whose last
+        // line is a prefix of the block's next line.
+        const QString truncated = block + QStringLiteral("\n\n") + block + QStringLiteral("\n\n") + block
+            + QStringLiteral("\n\nSystem Monitor\nCPU 12%");
+        QCOMPARE(LlmOcr::collapseRepeatedTranscription(truncated), block);
+
+        // A single line repeated, ending in a line the token budget cut
+        // mid-word (period=1 case) collapses to one line too.
+        const QString singleLineLoop = QStringLiteral("Try integrating using the Ollama web service to create a locally-run personal code assistant.\n")
+            + QStringLiteral("Try integrating using the Ollama web service to create a locally-run personal code assistant.\n")
+            + QStringLiteral("Try integrating using the Ollama web service to create a locally-run personal code assistant.\n")
+            + QStringLiteral("Try integrating using the Ollama web service to create a locally-run");
+        QCOMPARE(LlmOcr::collapseRepeatedTranscription(singleLineLoop),
+                 QStringLiteral("Try integrating using the Ollama web service to create a locally-run personal code assistant."));
+    }
+
+    // A reasoning-capable vision model left with thinking on can spell out
+    // its chain of thought inline before the final answer.
+    // LocalAiTranslationProvider already asks such models to stop reasoning
+    // via reasoning_effort; LlmOcr should offer the same knob for parity,
+    // even though the concrete "text doubled in the source edit" bug this
+    // engine shipped with (see testStopSequenceIsSentToPreventRunawayRepeat
+    // below) turned out to be a different failure mode entirely - a small
+    // local model looping on its own output with no clean stop token.
+    void testDisableThinkingSendsReasoningEffortNone()
+    {
+        MockHttpServer server;
+        Response response;
+        response.body = chatCompletionJson(QStringLiteral("Hello world"));
+        server.queueResponse(response);
+
+        LlmOcr ocr;
+        ocr.setEndpoint(server.baseUrl(), false, QString());
+        ocr.setModel(QStringLiteral("glm-ocr"));
+        ocr.setDisableThinking(true);
+
+        QImage image(4, 4, QImage::Format_RGB32);
+        image.fill(Qt::black);
+
+        QSignalSpy spy(&ocr, &LlmOcr::recognized);
+        ocr.recognize(image, 96);
+        QVERIFY(spy.wait(5000));
+
+        const QJsonObject body = QJsonDocument::fromJson(server.requestBody(0)).object();
+        QCOMPARE(body.value(QStringLiteral("reasoning_effort")).toString(), QStringLiteral("none"));
+    }
+
+    void testThinkingLeftAloneByDefault()
+    {
+        MockHttpServer server;
+        Response response;
+        response.body = chatCompletionJson(QStringLiteral("Hello world"));
+        server.queueResponse(response);
+
+        LlmOcr ocr;
+        ocr.setEndpoint(server.baseUrl(), false, QString());
+        ocr.setModel(QStringLiteral("glm-ocr"));
+
+        QImage image(4, 4, QImage::Format_RGB32);
+        image.fill(Qt::black);
+
+        QSignalSpy spy(&ocr, &LlmOcr::recognized);
+        ocr.recognize(image, 96);
+        QVERIFY(spy.wait(5000));
+
+        const QJsonObject body = QJsonDocument::fromJson(server.requestBody(0)).object();
+        QVERIFY(!body.contains(QStringLiteral("reasoning_effort")));
+    }
+
+    // The actual reported bug, reproduced live against a real Ollama +
+    // glm-ocr in testLiveOllamaGlmOcrDoesNotRepeatTranscription (see
+    // test_llmocr_live.cpp): a small local vision model finishes the correct
+    // transcription, then - having no clean stop token for "done" - wraps it
+    // in a markdown fence and loops re-emitting it until it runs out of
+    // token budget. A stop sequence on the fence marker cuts generation the
+    // instant that starts. This test only pins that the wire shape carries
+    // the stop sequence; the live test proves it actually works.
+    void testStopSequenceIsSentToPreventRunawayRepeat()
+    {
+        MockHttpServer server;
+        Response response;
+        response.body = chatCompletionJson(QStringLiteral("Hello world"));
+        server.queueResponse(response);
+
+        LlmOcr ocr;
+        ocr.setEndpoint(server.baseUrl(), false, QString());
+        ocr.setModel(QStringLiteral("glm-ocr"));
+
+        QImage image(4, 4, QImage::Format_RGB32);
+        image.fill(Qt::black);
+
+        QSignalSpy spy(&ocr, &LlmOcr::recognized);
+        ocr.recognize(image, 96);
+        QVERIFY(spy.wait(5000));
+
+        const QJsonObject body = QJsonDocument::fromJson(server.requestBody(0)).object();
+        const QJsonArray stop = body.value(QStringLiteral("stop")).toArray();
+        QCOMPARE(stop.size(), 1);
+        QCOMPARE(stop.first().toString(), QStringLiteral("```"));
+    }
+
+    void testCustomPromptIsSent()
+    {
+        MockHttpServer server;
+        Response response;
+        response.body = chatCompletionJson(QStringLiteral("text"));
+        server.queueResponse(response);
+
+        LlmOcr ocr;
+        ocr.setEndpoint(server.baseUrl(), false, QString());
+        ocr.setModel(QStringLiteral("m"));
+        ocr.setPrompt(QStringLiteral("CUSTOM OCR PROMPT"));
+
+        QImage image(4, 4, QImage::Format_RGB32);
+        image.fill(Qt::black);
+
+        QSignalSpy spy(&ocr, &LlmOcr::recognized);
+        ocr.recognize(image, 96);
+        QVERIFY(spy.wait(5000));
+
+        const QJsonObject body = QJsonDocument::fromJson(server.requestBody(0)).object();
+        const QJsonArray content = body.value(QStringLiteral("messages")).toArray().at(0).toObject().value(QStringLiteral("content")).toArray();
+        QCOMPARE(content.at(0).toObject().value(QStringLiteral("text")).toString(), QStringLiteral("CUSTOM OCR PROMPT"));
+    }
+
+    void testHttpErrorEmitsFailed()
+    {
+        MockHttpServer server;
+        Response response;
+        response.status = 500;
+        response.body = QByteArrayLiteral("{}");
+        server.queueResponse(response);
+
+        LlmOcr ocr;
+        ocr.setEndpoint(server.baseUrl(), false, QString());
+        ocr.setModel(QStringLiteral("m"));
+
+        QImage image(4, 4, QImage::Format_RGB32);
+        image.fill(Qt::black);
+
+        QSignalSpy spy(&ocr, &LlmOcr::failed);
+        ocr.recognize(image, 96);
+        QVERIFY(spy.wait(5000));
+        QVERIFY(!spy.constFirst().at(0).toString().isEmpty());
+    }
+
+    void testTimeoutEmitsFailed()
+    {
+        MockHttpServer server;
+        Response hangResponse;
+        hangResponse.hang = true;
+        server.queueResponse(hangResponse);
+
+        LlmOcr ocr;
+        ocr.setEndpoint(server.baseUrl(), false, QString());
+        ocr.setModel(QStringLiteral("m"));
+        ocr.setTimeout(1);
+
+        QImage image(4, 4, QImage::Format_RGB32);
+        image.fill(Qt::black);
+
+        QSignalSpy spy(&ocr, &LlmOcr::failed);
+        ocr.recognize(image, 96);
+        QVERIFY(spy.wait(5000));
+        QVERIFY(spy.constFirst().at(0).toString().contains(QStringLiteral("timed out"), Qt::CaseInsensitive));
+    }
+
+    void testIsConfiguredRequiresUrlAndModel()
+    {
+        LlmOcr ocr;
+        QVERIFY(!ocr.isConfigured());
+        ocr.setEndpoint(QStringLiteral("http://localhost:11434"), false, QString());
+        QVERIFY(!ocr.isConfigured());
+        ocr.setModel(QStringLiteral("m"));
+        QVERIFY(ocr.isConfigured());
+    }
+
+    // The OCR engine talks to the endpoint it was given, not to whatever the
+    // translation provider happens to be configured with. Both are pointed at
+    // mock servers here; only the OCR one may receive the request.
+    void testUsesItsOwnEndpointNotTheTranslationProviders()
+    {
+        MockHttpServer ocrServer;
+        MockHttpServer translationServer;
+        Response response;
+        response.status = 200;
+        response.body = chatCompletionJson(QStringLiteral("transcribed"));
+        ocrServer.queueResponse(response);
+
+        LocalAiTranslationProvider translationProvider;
+        auto options = translationProvider.getDefaultOptions();
+        options->setOption("url", translationServer.baseUrl());
+        options->setOption("model", QStringLiteral("translation-model"));
+        translationProvider.applyOptions(*options);
+
+        LlmOcr ocr;
+        ocr.setEndpoint(ocrServer.baseUrl(), false, QString());
+        ocr.setModel(QStringLiteral("vision-model"));
+
+        QImage image(8, 8, QImage::Format_RGB32);
+        image.fill(Qt::white);
+
+        QSignalSpy spy(&ocr, &LlmOcr::recognized);
+        ocr.recognize(image, 96);
+        QVERIFY(spy.wait(5000));
+
+        QCOMPARE(ocrServer.requestCount(), 1);
+        QCOMPARE(translationServer.requestCount(), 0);
+        // And it asked the vision model, not the translation model.
+        QVERIFY(ocrServer.requestBody(0).contains("vision-model"));
+        QVERIFY(!ocrServer.requestBody(0).contains("translation-model"));
+    }
+};
+
+QTEST_MAIN(LlmOcrTest)
+#include "test_llmocr.moc"
\ No newline at end of file
diff --git a/tests/test_llmocr_live.cpp b/tests/test_llmocr_live.cpp
new file mode 100644
index 00000000..b8159323
--- /dev/null
+++ b/tests/test_llmocr_live.cpp
@@ -0,0 +1,132 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// Live regression test for the "OCR text repeats until it runs out of
+// tokens" bug (see llmocr.cpp's stop-sequence comment): drives the real
+// LlmOcr class against a real, locally-running Ollama instance and a real
+// vision-OCR model, instead of MockHttpServer's canned responses. The bug
+// this guards against is a small model's own decoding pathology (looping
+// on its output with no clean stop token) - a mocked response can echo
+// whatever string a test hands it, but it cannot reproduce that failure
+// mode, so only a real model can prove the fix actually holds.
+//
+// Any developer can run this: `ollama pull glm-ocr` (or point
+// CROW_TEST_OLLAMA_MODEL at whatever vision-capable model is already
+// installed) and `ollama serve`, then run the LlmOcrLiveTest ctest target
+// directly. It SKIPS - never fails - when Ollama or the model isn't
+// reachable, so it never blocks CI or a machine without Ollama.
+
+#include "llm/visionmodelprobe.h"
+#include "ocr/llmocr.h"
+
+#include <QFont>
+#include <QImage>
+#include <QPainter>
+#include <QSignalSpy>
+#include <QTest>
+
+namespace
+{
+
+QString ollamaUrl()
+{
+    const QByteArray fromEnv = qgetenv("CROW_TEST_OLLAMA_URL");
+    return fromEnv.isEmpty() ? QStringLiteral("http://localhost:11434") : QString::fromLocal8Bit(fromEnv);
+}
+
+QString ollamaModel()
+{
+    const QByteArray fromEnv = qgetenv("CROW_TEST_OLLAMA_MODEL");
+    return fromEnv.isEmpty() ? QStringLiteral("glm-ocr:latest") : QString::fromLocal8Bit(fromEnv);
+}
+
+// A document-like image with real, unambiguous text. The near-blank
+// single-word case is exactly the pathological input that first pushed this
+// model into its repeat loop during investigation of the reported bug.
+QImage renderTextImage(const QString &text)
+{
+    QImage image(560, 200, QImage::Format_RGB32);
+    image.fill(Qt::white);
+    QPainter painter(&image);
+    painter.setPen(Qt::black);
+    QFont font = painter.font();
+    font.setPointSize(16);
+    painter.setFont(font);
+    painter.drawText(image.rect().adjusted(16, 16, -16, -16), Qt::TextWordWrap, text);
+    painter.end();
+    return image;
+}
+
+} // namespace
+
+class LlmOcrLiveTest : public QObject
+{
+    Q_OBJECT
+
+private slots:
+    void initTestCase()
+    {
+        VisionModelProbe probe;
+        QSignalSpy finishedSpy(&probe, &VisionModelProbe::finished);
+        QSignalSpy failedSpy(&probe, &VisionModelProbe::failed);
+        probe.probe(QStringLiteral("ollama"), ollamaUrl(), QString());
+
+        if (!finishedSpy.wait(6000) || !failedSpy.isEmpty()) {
+            QSKIP(qPrintable(QStringLiteral("Ollama not reachable at %1 - run `ollama serve` to enable this test").arg(ollamaUrl())));
+        }
+
+        const QStringList allModels = finishedSpy.constFirst().at(0).toStringList();
+        const QString model = ollamaModel();
+        if (!allModels.contains(model)) {
+            QSKIP(qPrintable(QStringLiteral("Model \"%1\" not installed in Ollama - run `ollama pull %1` to enable this test").arg(model)));
+        }
+    }
+
+    // The actual reported bug: a small local vision model (this one via
+    // Ollama) finishes the real transcription, then - lacking a clean stop
+    // token for the task - wraps it in a markdown fence and loops
+    // re-emitting it until it exhausts its token budget, so the same text
+    // lands in the source edit more than once. Reproduces it end to end
+    // against the real model and asserts the fix (a stop sequence on the
+    // fence marker, see llmocr.cpp) holds: one clean transcription, no
+    // repeated block, no fence noise.
+    void testLiveOllamaDoesNotRepeatTranscription()
+    {
+        const QString phrase = QStringLiteral("The quick brown fox jumps over the lazy dog. Invoice #4471 Total 128.50 EUR.");
+        const QImage image = renderTextImage(phrase);
+
+        LlmOcr ocr;
+        ocr.setEndpoint(ollamaUrl(), false, QString());
+        ocr.setModel(ollamaModel());
+        ocr.setTimeout(120);
+
+        QSignalSpy recognizedSpy(&ocr, &LlmOcr::recognized);
+        QSignalSpy failedSpy(&ocr, &LlmOcr::failed);
+        ocr.recognize(image, 96);
+
+        QVERIFY2(recognizedSpy.wait(120000) || !failedSpy.isEmpty(), "LlmOcr neither recognized nor failed within the timeout");
+        if (!failedSpy.isEmpty()) {
+            QFAIL(qPrintable(QStringLiteral("LlmOcr reported failure: %1").arg(failedSpy.constFirst().at(0).toString())));
+        }
+
+        const QString recognized = recognizedSpy.constFirst().at(0).toString();
+        QVERIFY2(!recognized.isEmpty(), "recognized text was empty");
+
+        // The regression this test exists for: the model repeating its own
+        // output. In practice that repeat is introduced by exactly this
+        // markdown fence marker, so its presence alone is a solid signal.
+        QVERIFY2(!recognized.contains(QStringLiteral("```")),
+                 qPrintable(QStringLiteral("recognized text still contains fenced repeat noise: %1").arg(recognized)));
+
+        // A distinctive substring from the source text must appear exactly
+        // once - more than once is the duplicated-text bug this test guards
+        // against.
+        const QString needle = QStringLiteral("Invoice #4471");
+        QCOMPARE(recognized.count(needle), 1);
+    }
+};
+
+QTEST_MAIN(LlmOcrLiveTest)
+#include "test_llmocr_live.moc"
diff --git a/tests/test_localai_provider.cpp b/tests/test_localai_provider.cpp
index 8844123d..d4527bb1 100644
--- a/tests/test_localai_provider.cpp
+++ b/tests/test_localai_provider.cpp
@@ -17,6 +17,8 @@
 #include "mockhttpserver.h"
 #include "provideroptions.h"
 #include "testisolation.h"
+#include "llm/openaiendpoint.h"
+#include "settings/appsettings.h"
 #include "translator/atranslationprovider.h"
 #include "translator/localaitranslationprovider.h"
 
@@ -163,7 +165,7 @@ private slots:
 
     // Gotcha #4: buildPrompt() round-trips the language code through
     // QLocale, which silently collapses an unrecognized/custom code to the
-    // C locale, corrupting the prompt with "You are a professional C ...".
+    // C locale, corrupting the prompt with "... professional C ...".
     // Fixed by 797fedec on localai-backend-salvage.
     void testCustomLanguageCodeDoesNotCorruptPrompt()
     {
@@ -173,6 +175,15 @@ private slots:
         server.queueJson(200, chatCompletionJson(QStringLiteral("translated")));
 
         auto provider = makeProvider(server.baseUrl());
+        ProviderOptions opts = *provider->getDefaultOptions();
+        opts.setOption(QStringLiteral("url"), server.baseUrl());
+        // The default prompt no longer renders {source_lang} (it lets the
+        // model infer the source language itself) - use a custom prompt
+        // that does, to keep exercising buildPrompt()'s
+        // languageDisplayName()/QLocale-collapse regression coverage.
+        opts.setOption(QStringLiteral("prompt"), QStringLiteral("Translate this {source_lang} text into {target_lang}: {text}"));
+        provider->applyOptions(opts);
+
         provider->translate(QStringLiteral("Hello"), Language(QStringLiteral("es")), Language(QStringLiteral("zzq")));
 
         QVERIFY(QTest::qWaitFor([&server]() {
@@ -181,7 +192,8 @@ private slots:
                                 5000));
         const QByteArray body = server.requestBody(0);
 
-        QVERIFY2(!body.contains("professional C "), qUtf8Printable(QStringLiteral("Prompt corrupted to C-locale for a custom language code:\n") + QString::fromUtf8(body)));
+        QVERIFY2(body.contains("Zzqian"), qUtf8Printable(QStringLiteral("Custom language name not substituted:\n") + QString::fromUtf8(body)));
+        QVERIFY2(!body.contains("this C text"), qUtf8Printable(QStringLiteral("Prompt corrupted to C-locale for a custom language code:\n") + QString::fromUtf8(body)));
     }
 
     // Gotcha #5: the detection regex must take the FIRST match in the
@@ -195,7 +207,6 @@ private slots:
         auto provider = makeProvider(server.baseUrl());
         ProviderOptions opts = *provider->getDefaultOptions();
         opts.setOption(QStringLiteral("url"), server.baseUrl());
-        opts.setOption(QStringLiteral("detect_via_llm"), true);
         opts.setOption(QStringLiteral("detect_url"), server.baseUrl());
         opts.setOption(QStringLiteral("detect_model"), QStringLiteral("some-model"));
         provider->applyOptions(opts);
@@ -207,6 +218,132 @@ private slots:
         const Language detected = qvariant_cast<Language>(detectSpy.constFirst().at(0));
         QCOMPARE(detected.toCode(), QStringLiteral("en"));
     }
+
+    // detectLanguage() must be honest about supportsAutodetection() == true:
+    // when a detect provider/model is actually configured, it should fire a
+    // real async detection call (mirroring MozhiTranslationProvider), not
+    // silently substitute the primary language.
+    void testDetectLanguageWithDetectModelConfiguredHitsNetworkAndEmitsRealResult()
+    {
+        MockHttpServer server;
+        server.queueJson(200, chatCompletionJson(QStringLiteral("de - German text")));
+
+        auto provider = makeProvider(server.baseUrl());
+        ProviderOptions opts = *provider->getDefaultOptions();
+        opts.setOption(QStringLiteral("url"), server.baseUrl());
+        opts.setOption(QStringLiteral("detect_url"), server.baseUrl());
+        opts.setOption(QStringLiteral("detect_model"), QStringLiteral("some-model"));
+        provider->applyOptions(opts);
+
+        QSignalSpy detectSpy(provider.get(), &ATranslationProvider::languageDetected);
+        provider->detectLanguage(QStringLiteral("Guten Tag"));
+
+        QVERIFY(detectSpy.wait(5000));
+        const Language detected = qvariant_cast<Language>(detectSpy.constFirst().at(0));
+        QCOMPARE(detected.toCode(), QStringLiteral("de"));
+        QCOMPARE(server.requestCount(), 1);
+        QVERIFY(server.requestPath(0).contains(QStringLiteral("/v1/chat/completions")));
+    }
+
+    // Without a configured detect model, no real detection is possible:
+    // detectLanguage() must fall back to the primary language synchronously
+    // and must not make a network call.
+    void testDetectLanguageWithoutDetectModelFallsBackToPrimaryLanguageSynchronously()
+    {
+        AppSettings().setPrimaryLanguage(Language(QStringLiteral("fr")));
+
+        MockHttpServer server;
+        auto provider = makeProvider(server.baseUrl());
+
+        QSignalSpy detectSpy(provider.get(), &ATranslationProvider::languageDetected);
+        const Language detected = provider->detectLanguage(QStringLiteral("Bonjour"));
+
+        QCOMPARE(detected.toCode(), QStringLiteral("fr"));
+        QCOMPARE(detectSpy.count(), 1);
+        const Language emitted = qvariant_cast<Language>(detectSpy.constFirst().at(0));
+        QCOMPARE(emitted.toCode(), QStringLiteral("fr"));
+        QCOMPARE(server.requestCount(), 0);
+    }
+
+    // URL normalization: users paste either a base URL (SDK style) or a full
+    // endpoint URL (docs style). completionsUrl()/modelsUrl() must accept
+    // both without doubling or mangling the path.
+    void testCompletionsUrlNormalizesBaseUrlVsEndpoint()
+    {
+        // Base URLs get the kind-specific suffix appended.
+        QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("http://localhost:11434"), false),
+                 QStringLiteral("http://localhost:11434/v1/chat/completions"));
+        QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("http://localhost:11434/v1"), false),
+                 QStringLiteral("http://localhost:11434/v1/chat/completions"));
+        QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("https://api.example.com/"), true),
+                 QStringLiteral("https://api.example.com/v1/messages"));
+
+        // Full endpoint URLs are used as typed - no doubling.
+        QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("https://api.example.com/v1/chat/completions"), false),
+                 QStringLiteral("https://api.example.com/v1/chat/completions"));
+        QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("https://api.example.com/v1/messages"), true),
+                 QStringLiteral("https://api.example.com/v1/messages"));
+
+        // Cross-kind endpoint URLs that ARE recognized suffixes are still
+        // honored as typed (user knows best) - only the kind of the suffix
+        // matters for recognition, not the isAnthropic flag passed in.
+        QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("https://api.example.com/v1/messages"), false),
+                 QStringLiteral("https://api.example.com/v1/messages"));
+
+        // A base that already carries its own path is the complete SDK base
+        // (z.ai's base is ".../paas/v4", not "/v1"): only the kind suffix is
+        // appended - never a second version segment.
+        QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("https://api.z.ai/api/coding/paas/v4"), false),
+                 QStringLiteral("https://api.z.ai/api/coding/paas/v4/chat/completions"));
+
+        // "/api/chat" (Ollama's native, non-OpenAI-compatible endpoint) is
+        // intentionally NOT recognized as a complete endpoint here - this
+        // codebase only ever builds OpenAI-compatible request bodies, so
+        // treating it as "already complete" would silently point at an
+        // endpoint expecting a different wire shape. It's now treated as an
+        // ordinary path segment and gets the OpenAI suffix appended,
+        // producing an obviously-broken URL rather than a silently-wrong
+        // one - a saved "/api/chat" URL should be changed to
+        // "/v1/chat/completions" instead.
+        QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("http://host:8080/api/chat"), false),
+                 QStringLiteral("http://host:8080/api/chat/chat/completions"));
+
+        // Trailing slashes are still stripped first, same doubled-path result.
+        QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("http://host:8080/api/chat/"), false),
+                 QStringLiteral("http://host:8080/api/chat/chat/completions"));
+        QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("http://host:8080///"), false),
+                 QStringLiteral("http://host:8080/v1/chat/completions"));
+    }
+
+    void testModelsUrlDerivesBaseFromEndpointUrl()
+    {
+        // Plain base URLs go straight to /v1/models.
+        QCOMPARE(OpenAiEndpoint::modelsUrl(QStringLiteral("http://localhost:11434")),
+                 QStringLiteral("http://localhost:11434/v1/models"));
+        QCOMPARE(OpenAiEndpoint::modelsUrl(QStringLiteral("https://api.example.com/v1")),
+                 QStringLiteral("https://api.example.com/v1/models"));
+
+        // Full endpoint URLs have their path stripped back to the base first.
+        QCOMPARE(OpenAiEndpoint::modelsUrl(QStringLiteral("https://api.example.com/v1/chat/completions")),
+                 QStringLiteral("https://api.example.com/v1/models"));
+        QCOMPARE(OpenAiEndpoint::modelsUrl(QStringLiteral("https://api.example.com/v1/messages")),
+                 QStringLiteral("https://api.example.com/v1/models"));
+        QCOMPARE(OpenAiEndpoint::modelsUrl(QStringLiteral("http://host:8080/v1/models")),
+                 QStringLiteral("http://host:8080/v1/models"));
+
+        // "/api/chat" is intentionally not a recognized suffix (see
+        // completionsUrl's test above for why) - it's treated as an
+        // ordinary path segment the probe lives under.
+        QCOMPARE(OpenAiEndpoint::modelsUrl(QStringLiteral("http://host:8080/api/chat")),
+                 QStringLiteral("http://host:8080/api/chat/models"));
+
+        // A path-bearing SDK base keeps its own version segment; the probe is
+        // "<base>/models", never ".../v4/v1/models" (z.ai's base is "/v4").
+        QCOMPARE(OpenAiEndpoint::modelsUrl(QStringLiteral("https://api.z.ai/api/coding/paas/v4")),
+                 QStringLiteral("https://api.z.ai/api/coding/paas/v4/models"));
+        QCOMPARE(OpenAiEndpoint::modelsUrl(QStringLiteral("http://host:8080/some/custom/path")),
+                 QStringLiteral("http://host:8080/some/custom/path/models"));
+    }
 };
 
 int main(int argc, char *argv[])
diff --git a/tests/test_mainwindow_features.cpp b/tests/test_mainwindow_features.cpp
new file mode 100644
index 00000000..e013b33f
--- /dev/null
+++ b/tests/test_mainwindow_features.cpp
@@ -0,0 +1,468 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// GUI-level feature tests through a real MainWindow: swap, clear, the copy
+// buttons, abort, the paste-image OCR chain, Escape-cancels-image, and the
+// Interface/ShowStatusBar settings round-trip. Everything runs offline - the
+// LocalAI translation backend and the LLM OCR engine are pointed at
+// MockHttpServer on 127.0.0.1 via the same settings keys the settings dialog
+// writes, so these exercise the full MainWindow -> provider -> UI path with
+// deterministic canned responses (and hang=true for the abort cases, the
+// only way to hold Processing/recognizing open long enough to interact).
+
+#include "language.h"
+#include "languagebuttonswidget.h"
+#include "mainwindow.h"
+#include "mockhttpserver.h"
+#include "modulestatus.h"
+#include "singleapplication.h"
+#include "sourcetextedit.h"
+#include "testisolation.h"
+#include "ocr/llmocr.h"
+#include "settings/appsettings.h"
+#include "settings/settingsdialog.h"
+#include "translator/atranslationprovider.h"
+#include "translator/localaitranslationprovider.h"
+#include "tts/attsprovider.h"
+
+#include <QApplication>
+#include <QCheckBox>
+#include <QClipboard>
+#include <QKeyEvent>
+#include <QLocale>
+#include <QTest>
+#include <QTextEdit>
+#include <QToolButton>
+
+#include <algorithm>
+
+namespace
+{
+QByteArray chatCompletionJson(const QString &content)
+{
+    return QStringLiteral(R"({"choices":[{"message":{"role":"assistant","content":"%1"}}]})").arg(content).toUtf8();
+}
+
+MockHttpServer::Response hangResponse()
+{
+    MockHttpServer::Response response;
+    response.hang = true;
+    return response;
+}
+
+// Drives the LocalAI backend (Translation/Backend=2) at the mock server. The
+// provider id must be one of AppSettings::localProviderIds() so the engine
+// combo population in updateProviderUI() stays consistent.
+void pinLocalAiSettings(const MockHttpServer &server)
+{
+    AppSettings settings;
+    settings.setShowPrivacyPopup(false);
+    settings.setTranslationProviderBackend(ATranslationProvider::ProviderBackend::LocalAI);
+    settings.setTTSProviderBackend(ATTSProvider::ProviderBackend::None);
+    settings.setActiveLocalProvider(QStringLiteral("openai_custom"));
+    settings.setLocalProviderUrl(QStringLiteral("openai_custom"), server.baseUrl());
+    settings.setLocalProviderModel(QStringLiteral("openai_custom"), QStringLiteral("mock-model"));
+    settings.setLocalAiTimeout(QStringLiteral("openai_custom"), 300);
+    settings.setForceSourceAutodetect(false);
+    settings.setForceTranslationAutodetect(false);
+
+    const Language english(QLocale::English);
+    const Language spanish(QLocale::Spanish);
+    settings.setLanguages(AppSettings::Source, {english});
+    settings.setLanguages(AppSettings::Translation, {spanish});
+    settings.setCheckedButton(AppSettings::Source, 0);
+    settings.setCheckedButton(AppSettings::Translation, 0);
+}
+
+void pinLlmOcrSettings(const MockHttpServer &server)
+{
+    AppSettings settings;
+    settings.setOcrEngine(AppSettings::OcrEngine::Llm);
+    settings.setOcrLlmProvider(QStringLiteral("openai_custom"));
+    settings.setOcrLlmUrl(QStringLiteral("openai_custom"), server.baseUrl());
+    settings.setOcrLlmModel(QStringLiteral("openai_custom"), QStringLiteral("mock-model"));
+}
+
+// Same delivery rationale as test_translation.cpp's typeText(): synthesized
+// platform key events can't inject into this window on Wayland, manually
+// constructed QKeyEvents through sendEvent() exercise the real handlers.
+void sendKey(QWidget *widget, int key, const QString &text = {})
+{
+    QKeyEvent press(QEvent::KeyPress, key, Qt::NoModifier, text);
+    QKeyEvent release(QEvent::KeyRelease, key, Qt::NoModifier, text);
+    QApplication::sendEvent(widget, &press);
+    QApplication::sendEvent(widget, &release);
+}
+
+void typeText(QWidget *widget, const QString &text)
+{
+    for (const QChar &ch : text)
+        sendKey(widget, Qt::Key_unknown, QString(ch));
+}
+
+QImage solidImage()
+{
+    QImage image(64, 32, QImage::Format_ARGB32);
+    image.fill(Qt::white);
+    return image;
+}
+} // namespace
+
+class MainWindowFeaturesTest : public QObject
+{
+    Q_OBJECT
+
+private slots:
+    void initTestCase();
+    void cleanupTestCase();
+    void cleanup();
+
+    void testSwapSwapsTextsAndLanguages();
+    void testClearButtonClearsBothEdits();
+    void testCopyButtons();
+    void testAbortReturnsToReadyWithoutError();
+    void testPasteImageRecognizeAndAutoTranslate();
+    void testEscapeCancelsSourceImage();
+    void testShowStatusBarSettingsRoundTrip();
+    void testRecognizedTextActivatesUiWithoutKeystroke();
+    void testRecognizedTextAutoTranslatesWhenEnabled();
+
+private:
+    static void waitForTranslateButton(MainWindow &window);
+};
+
+void MainWindowFeaturesTest::initTestCase()
+{
+    if (qgetenv("DISPLAY").isEmpty() && qgetenv("WAYLAND_DISPLAY").isEmpty()) {
+        QSKIP("No display server available - skipping GUI tests");
+    }
+
+    Q_INIT_RESOURCE(engines);
+    Q_INIT_RESOURCE(icon_theme);
+}
+
+void MainWindowFeaturesTest::cleanupTestCase()
+{
+    // Leave the shared isolated settings in the Copy/None state the rest of
+    // the suite expects, whatever the last test happened to pin.
+    AppSettings settings;
+    settings.setTranslationProviderBackend(ATranslationProvider::ProviderBackend::Copy);
+    settings.setTTSProviderBackend(ATTSProvider::ProviderBackend::None);
+    settings.setOcrEngine(AppSettings::OcrEngine::Tesseract);
+    QApplication::clipboard()->clear();
+}
+
+void MainWindowFeaturesTest::cleanup()
+{
+    QApplication::clipboard()->clear();
+}
+
+void MainWindowFeaturesTest::waitForTranslateButton(MainWindow &window)
+{
+    QVERIFY(QTest::qWaitFor([&window]() {
+        return window.translateButton()->isEnabled();
+    },
+                            3000));
+}
+
+// Swap must exchange the edits' contents AND the language buttons' checked
+// languages - the translation direction has to flip with the text.
+void MainWindowFeaturesTest::testSwapSwapsTextsAndLanguages()
+{
+    MockHttpServer server;
+    server.queueJson(200, chatCompletionJson(QStringLiteral("mocked output")));
+    pinLocalAiSettings(server);
+    pinLlmOcrSettings(server); // keep prepareOcr() usable regardless of engine
+
+    MainWindow window;
+    typeText(window.sourceEdit(), QStringLiteral("Hello"));
+    waitForTranslateButton(window);
+    QTest::mouseClick(window.translateButton(), Qt::LeftButton);
+
+    QVERIFY(QTest::qWaitFor([&window]() {
+        return window.translationEdit()->toPlainText() == QStringLiteral("mocked output");
+    },
+                            5000));
+
+    const Language sourceBefore = window.sourceLanguageButtons()->checkedLanguage();
+    const Language destBefore = window.translationLanguageButtons()->checkedLanguage();
+
+    QTest::mouseClick(window.swapButton(), Qt::LeftButton);
+
+    // The texts exchange places (the old source text follows to the
+    // translation side), and the checked languages flip with them.
+    QCOMPARE(window.sourceEdit()->toPlainText(), QStringLiteral("mocked output"));
+    QCOMPARE(window.translationEdit()->toPlainText(), QStringLiteral("Hello"));
+    QCOMPARE(window.sourceLanguageButtons()->checkedLanguage(), destBefore);
+    QCOMPARE(window.translationLanguageButtons()->checkedLanguage(), sourceBefore);
+}
+
+void MainWindowFeaturesTest::testClearButtonClearsBothEdits()
+{
+    MockHttpServer server;
+    server.queueJson(200, chatCompletionJson(QStringLiteral("mocked output")));
+    pinLocalAiSettings(server);
+
+    MainWindow window;
+    typeText(window.sourceEdit(), QStringLiteral("Hello"));
+    waitForTranslateButton(window);
+    QTest::mouseClick(window.translateButton(), Qt::LeftButton);
+    QVERIFY(QTest::qWaitFor([&window]() {
+        return !window.translationEdit()->toPlainText().isEmpty();
+    },
+                            5000));
+
+    QToolButton *clearButton = window.findChild<QToolButton *>(QStringLiteral("clearButton"));
+    QVERIFY(clearButton != nullptr);
+    QTest::mouseClick(clearButton, Qt::LeftButton);
+
+    QVERIFY(window.sourceEdit()->toPlainText().isEmpty());
+    QVERIFY(window.translationEdit()->toPlainText().isEmpty());
+}
+
+void MainWindowFeaturesTest::testCopyButtons()
+{
+    MockHttpServer server;
+    server.queueJson(200, chatCompletionJson(QStringLiteral("mocked output")));
+    pinLocalAiSettings(server);
+
+    MainWindow window;
+    typeText(window.sourceEdit(), QStringLiteral("Hello"));
+    waitForTranslateButton(window);
+    QTest::mouseClick(window.translateButton(), Qt::LeftButton);
+    QVERIFY(QTest::qWaitFor([&window]() {
+        return !window.translationEdit()->toPlainText().isEmpty();
+    },
+                            5000));
+
+    QTest::mouseClick(window.copySourceButton(), Qt::LeftButton);
+    QCOMPARE(QApplication::clipboard()->text(), QStringLiteral("Hello"));
+
+    QTest::mouseClick(window.copyTranslationButton(), Qt::LeftButton);
+    QCOMPARE(QApplication::clipboard()->text(), QStringLiteral("mocked output"));
+
+    // "Copy all" joins source and translation with a newline.
+    QTest::mouseClick(window.copyAllTranslationButton(), Qt::LeftButton);
+    QCOMPARE(QApplication::clipboard()->text(), QStringLiteral("Hello\nmocked output"));
+}
+
+// Aborting a hung translation must land back at Ready with the strip Idle -
+// not Error: a deliberate abort is the user's own action, not a failure.
+void MainWindowFeaturesTest::testAbortReturnsToReadyWithoutError()
+{
+    MockHttpServer server;
+    server.queueResponse(hangResponse());
+    pinLocalAiSettings(server);
+
+    MainWindow window;
+    typeText(window.sourceEdit(), QStringLiteral("Hello"));
+    waitForTranslateButton(window);
+
+    bool sawBusy = false;
+    connect(window.moduleStatus(), &ModuleStatus::changed, [&]() {
+        sawBusy = sawBusy || window.moduleStatus()->activity(ModuleStatus::Module::Translation) == ModuleStatus::Activity::Busy;
+    });
+
+    QTest::mouseClick(window.translateButton(), Qt::LeftButton);
+    QVERIFY(QTest::qWaitFor([&window]() {
+        return window.findChild<QToolButton *>(QStringLiteral("abortButton"))->isEnabled();
+    },
+                            5000));
+    QVERIFY(sawBusy);
+    QCOMPARE(window.moduleStatus()->activity(ModuleStatus::Module::Translation), ModuleStatus::Activity::Busy);
+
+    QTest::mouseClick(window.findChild<QToolButton *>(QStringLiteral("abortButton")), Qt::LeftButton);
+
+    QVERIFY(QTest::qWaitFor([&window]() {
+        return window.moduleStatus()->activity(ModuleStatus::Module::Translation) == ModuleStatus::Activity::Idle;
+    },
+                            5000));
+    QCOMPARE(window.moduleStatus()->activity(ModuleStatus::Module::Translation), ModuleStatus::Activity::Idle);
+
+    // Existing behavior, deliberately pinned: MainWindow writes the abort
+    // error text into translationEdit (translatorStateChanged's Finished
+    // branch) even though the strip correctly reports Idle, not Error.
+    QVERIFY(window.translationEdit()->toPlainText().contains(QStringLiteral("Error")));
+}
+
+// Paste an image into sourceEdit: recognition starts immediately (dropped
+// image = transcribe it), the recognized text lands in sourceEdit, and with
+// auto-translate on the translation follows - the full offline chain.
+void MainWindowFeaturesTest::testPasteImageRecognizeAndAutoTranslate()
+{
+    MockHttpServer server;
+    server.queueJson(200, chatCompletionJson(QStringLiteral("HELLO FROM IMAGE")));
+    server.queueJson(200, chatCompletionJson(QStringLiteral("mocked translation")));
+    pinLocalAiSettings(server);
+    pinLlmOcrSettings(server);
+
+    AppSettings settings;
+    settings.setAutoTranslateEnabled(true);
+
+    MainWindow window;
+    window.show();
+    QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+    bool sawOcrBusy = false;
+    connect(window.moduleStatus(), &ModuleStatus::changed, [&]() {
+        sawOcrBusy = sawOcrBusy || window.moduleStatus()->activity(ModuleStatus::Module::Ocr) == ModuleStatus::Activity::Busy;
+    });
+
+    QApplication::clipboard()->setImage(solidImage());
+    // The eventFilter handles Ctrl+V for images: matches(QKeySequence::Paste)
+    // needs the Control modifier on the synthesized event.
+    QKeyEvent paste(QEvent::KeyPress, Qt::Key_V, Qt::ControlModifier);
+    QApplication::sendEvent(window.sourceEdit(), &paste);
+
+    QVERIFY(QTest::qWaitFor([&window]() {
+        return window.sourceEdit()->toPlainText() == QStringLiteral("HELLO FROM IMAGE");
+    },
+                            8000));
+    QVERIFY(sawOcrBusy);
+
+    QVERIFY(QTest::qWaitFor([&window]() {
+        return window.translationEdit()->toPlainText() == QStringLiteral("mocked translation");
+    },
+                            8000));
+}
+
+// Escape while recognition is still running cancels it: preview cleared,
+// language buttons re-enabled, strip back to Idle.
+void MainWindowFeaturesTest::testEscapeCancelsSourceImage()
+{
+    MockHttpServer server;
+    server.queueResponse(hangResponse()); // recognition hangs until cancelled
+    pinLocalAiSettings(server);
+    pinLlmOcrSettings(server);
+
+    MainWindow window;
+
+    QApplication::clipboard()->setImage(solidImage());
+    QKeyEvent paste(QEvent::KeyPress, Qt::Key_V, Qt::ControlModifier);
+    QApplication::sendEvent(window.sourceEdit(), &paste);
+
+    QVERIFY(QTest::qWaitFor([&window]() {
+        return window.moduleStatus()->activity(ModuleStatus::Module::Ocr) == ModuleStatus::Activity::Busy;
+    },
+                            5000));
+    QVERIFY(!window.sourceLanguageButtons()->isEnabled());
+
+    sendKey(&window, Qt::Key_Escape);
+
+    QVERIFY(QTest::qWaitFor([&window]() {
+        return window.moduleStatus()->activity(ModuleStatus::Module::Ocr) == ModuleStatus::Activity::Idle;
+    },
+                            5000));
+    QVERIFY(window.sourceEdit()->toPlainText().isEmpty());
+    QVERIFY(window.sourceLanguageButtons()->isEnabled());
+}
+
+// SettingsDialog accept() must persist the checkbox, restoreDefaults() must
+// reset it - the four-point pattern minus the two UI-only touch points.
+void MainWindowFeaturesTest::testShowStatusBarSettingsRoundTrip()
+{
+    MainWindow window;
+
+    {
+        SettingsDialog dialog(&window);
+        QCheckBox *box = dialog.findChild<QCheckBox *>(QStringLiteral("showStatusBarCheckBox"));
+        QVERIFY(box != nullptr);
+        QVERIFY(box->isChecked()); // default true
+
+        box->setChecked(false);
+        dialog.accept();
+    }
+    QCOMPARE(AppSettings().isShowStatusBar(), false);
+
+    {
+        SettingsDialog dialog(&window);
+        QCheckBox *box = dialog.findChild<QCheckBox *>(QStringLiteral("showStatusBarCheckBox"));
+        QVERIFY(box != nullptr);
+        QVERIFY(!box->isChecked());
+
+        // Private slot - reachable through the meta-object by name.
+        QVERIFY(QMetaObject::invokeMethod(&dialog, "restoreDefaults", Qt::DirectConnection));
+        QVERIFY(box->isChecked());
+        dialog.accept();
+    }
+    QCOMPARE(AppSettings().isShowStatusBar(), true);
+}
+
+// The user-visible regression this file guards: text inserted by OCR used to
+// leave the translate button disabled until the user typed something (a
+// space) - SourceTextEdit::replaceText() deliberately suppresses textEdited,
+// and textEdited is what drives every follow-up typed text gets. The
+// recognized handler must run them itself, with no keystroke involved.
+void MainWindowFeaturesTest::testRecognizedTextActivatesUiWithoutKeystroke()
+{
+    MockHttpServer server;
+    server.queueJson(200, chatCompletionJson(QStringLiteral("en"))); // detection response
+    pinLocalAiSettings(server);
+    pinLlmOcrSettings(server);
+    AppSettings settings;
+    settings.setDetectProvider(QStringLiteral("openai_custom"));
+    settings.setDetectModel(QStringLiteral("mock-model"));
+
+    MainWindow window;
+    QCheckBox *autoTranslate = window.findChild<QCheckBox *>(QStringLiteral("autoTranslateCheckBox"));
+    QVERIFY(autoTranslate != nullptr);
+    autoTranslate->setChecked(false); // pin: a prior test may have left it on
+
+    QVERIFY(!window.translateButton()->isEnabled()); // empty source: disabled
+
+    bool sawDetecting = false;
+    connect(window.moduleStatus(), &ModuleStatus::changed, [&]() {
+        sawDetecting = sawDetecting || window.moduleStatus()->message(ModuleStatus::Module::Translation) == QStringLiteral("Detecting language");
+    });
+
+    // Not a keystroke - the real engine signal, exactly as recognize()
+    // delivers it. With auto-translate off, the follow-up is standalone
+    // language detection (updateAutoLocales), which is async against the
+    // mock and must show in the strip while in flight.
+    emit window.ocr()->recognized(QStringLiteral("Hello from OCR"));
+
+    QCOMPARE(window.sourceEdit()->toPlainText(), QStringLiteral("Hello from OCR"));
+    QVERIFY(window.translateButton()->isEnabled());
+    QVERIFY(QTest::qWaitFor([&window]() {
+        return window.moduleStatus()->activity(ModuleStatus::Module::Translation) == ModuleStatus::Activity::Idle;
+    },
+                            5000));
+    QVERIFY(sawDetecting);
+}
+
+// Same path with auto-translate on: the recognized text must chain straight
+// into a translation, like typed text does - no keystroke, no button click.
+void MainWindowFeaturesTest::testRecognizedTextAutoTranslatesWhenEnabled()
+{
+    MockHttpServer server;
+    server.queueJson(200, chatCompletionJson(QStringLiteral("mocked translation")));
+    pinLocalAiSettings(server);
+    pinLlmOcrSettings(server);
+
+    MainWindow window;
+    QCheckBox *autoTranslate = window.findChild<QCheckBox *>(QStringLiteral("autoTranslateCheckBox"));
+    QVERIFY(autoTranslate != nullptr);
+    autoTranslate->setChecked(true);
+
+    emit window.ocr()->recognized(QStringLiteral("Hello from OCR"));
+
+    QVERIFY(QTest::qWaitFor([&window]() {
+        return window.translationEdit()->toPlainText() == QStringLiteral("mocked translation");
+    },
+                            5000));
+    QVERIFY(window.translateButton()->isEnabled());
+}
+
+int main(int argc, char *argv[])
+{
+    isolateTestSettings();
+
+    SingleApplication app(argc, argv, true);
+    MainWindowFeaturesTest tc;
+    QTEST_SET_MAIN_SOURCE_PATH
+    return QTest::qExec(&tc, argc, argv);
+}
+
+#include "test_mainwindow_features.moc"
diff --git a/tests/test_mainwindow_status.cpp b/tests/test_mainwindow_status.cpp
new file mode 100644
index 00000000..48c3458c
--- /dev/null
+++ b/tests/test_mainwindow_status.cpp
@@ -0,0 +1,238 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// GUI-level integration tests for the module status system, driven through a
+// real MainWindow on the Copy backend (fully synchronous, no network):
+// what moduleStatus() reports while a translation cascades through
+// MainWindow's own stateChanged/translationAccepted/resetTranslator wiring,
+// what the pop-up's strip does with it, and the Interface/ShowStatusBar
+// pick-up in loadAppSettings().
+//
+// Copy's translate() is synchronous, so its Busy interval only exists during
+// the emission cascade - observing it requires recording snapshots from a
+// changed() handler connected to the model (a direct connection fires inside
+// the cascade), not sampling after the click returns.
+
+#include "language.h"
+#include "mainwindow.h"
+#include "modulestatus.h"
+#include "popupwindow.h"
+#include "singleapplication.h"
+#include "sourcetextedit.h"
+#include "statusstrip.h"
+#include "testisolation.h"
+#include "settings/appsettings.h"
+#include "translator/atranslationprovider.h"
+#include "tts/attsprovider.h"
+
+#include <QApplication>
+#include <QKeyEvent>
+#include <QLocale>
+#include <QStatusBar>
+#include <QTest>
+
+#include <algorithm>
+#include <vector>
+
+class MainWindowStatusTest : public QObject
+{
+    Q_OBJECT
+
+private slots:
+    void initTestCase();
+    void cleanup();
+
+    void testModelExposedAndTtsUnavailableWithNone();
+    void testBusyObservedDuringTranslateClick();
+    void testStickyErrorThroughRealCascade();
+    void testStickyErrorClearedByNextTranslation();
+    void testPopupStripOnlyVisibleWhileBusy();
+    void testShowStatusBarSettingPickUp();
+
+private:
+    static void pinCopySettings();
+};
+
+void MainWindowStatusTest::initTestCase()
+{
+    if (qgetenv("DISPLAY").isEmpty() && qgetenv("WAYLAND_DISPLAY").isEmpty()) {
+        QSKIP("No display server available - skipping GUI tests");
+    }
+
+    Q_INIT_RESOURCE(engines);
+    Q_INIT_RESOURCE(icon_theme);
+
+    pinCopySettings();
+}
+
+void MainWindowStatusTest::cleanup()
+{
+    // Backends and languages drift per test; re-pin for the next one.
+    pinCopySettings();
+}
+
+void MainWindowStatusTest::pinCopySettings()
+{
+    AppSettings settings;
+    settings.setShowPrivacyPopup(false);
+    settings.setTranslationProviderBackend(ATranslationProvider::ProviderBackend::Copy);
+    settings.setTTSProviderBackend(ATTSProvider::ProviderBackend::None);
+    settings.setWindowMode(AppSettings::MainWindow);
+
+    const Language systemLanguage(QLocale::system());
+    settings.setLanguages(AppSettings::Source, {systemLanguage});
+    settings.setLanguages(AppSettings::Translation, {systemLanguage});
+    settings.setCheckedButton(AppSettings::Source, 0);
+    settings.setCheckedButton(AppSettings::Translation, 0);
+}
+
+void MainWindowStatusTest::testModelExposedAndTtsUnavailableWithNone()
+{
+    MainWindow window;
+    QVERIFY(window.moduleStatus() != nullptr);
+    QVERIFY(!window.moduleStatus()->isBusy());
+
+    // TTS backend None: NoopTTSProvider never emits and the strip omits the
+    // segment entirely.
+    QVERIFY(!window.moduleStatus()->isAvailable(ModuleStatus::Module::Tts));
+
+    // The strip itself lives in the main window's status bar.
+    QStatusBar *bar = window.findChild<QStatusBar *>(QStringLiteral("statusbar"));
+    QVERIFY(bar != nullptr);
+    QVERIFY(bar->findChild<StatusStrip *>() != nullptr);
+}
+
+void MainWindowStatusTest::testBusyObservedDuringTranslateClick()
+{
+    MainWindow window;
+    window.show();
+    QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+    std::vector<ModuleStatus::Activity> seen;
+    std::vector<QString> messages;
+    connect(window.moduleStatus(), &ModuleStatus::changed, [&]() {
+        seen.push_back(window.moduleStatus()->activity(ModuleStatus::Module::Translation));
+        messages.push_back(window.moduleStatus()->message(ModuleStatus::Module::Translation));
+    });
+
+    // Same typeText-then-click flow as test_translation.cpp's
+    // testGuiTranslation; sourceEdit's textEdited is debounced, so wait for
+    // the translate button to arm first.
+    for (const QChar &ch : QStringLiteral("Hello World")) {
+        QKeyEvent press(QEvent::KeyPress, Qt::Key_unknown, Qt::NoModifier, QString(ch));
+        QKeyEvent release(QEvent::KeyRelease, Qt::Key_unknown, Qt::NoModifier, QString(ch));
+        QApplication::sendEvent(window.sourceEdit(), &press);
+        QApplication::sendEvent(window.sourceEdit(), &release);
+    }
+    QVERIFY(QTest::qWaitFor([&window]() {
+        return window.translateButton()->isEnabled();
+    },
+                            3000));
+    QTest::mouseClick(window.translateButton(), Qt::LeftButton);
+
+    QVERIFY(window.translationEdit()->toPlainText() == QStringLiteral("Hello World"));
+    QVERIFY(std::find(seen.begin(), seen.end(), ModuleStatus::Activity::Busy) != seen.end());
+    QVERIFY(std::find(messages.begin(), messages.end(), QStringLiteral("Translating")) != messages.end());
+    QCOMPARE(window.moduleStatus()->activity(ModuleStatus::Module::Translation), ModuleStatus::Activity::Idle);
+    QVERIFY(!window.moduleStatus()->isBusy());
+}
+
+// A Finished-with-error runs the full synchronous cascade -
+// translatorStateChanged() writes the error into translationEdit and emits
+// resetTranslator(), whose reset() lands on Ready in the same call stack -
+// and the model must still report the error afterwards. This is the
+// integration twin of test_modulestatus's stickiness test, through
+// MainWindow's real wiring instead of a bare provider.
+void MainWindowStatusTest::testStickyErrorThroughRealCascade()
+{
+    MainWindow window;
+
+    // translationRequested is a public signal in Qt 6; emitting it drives
+    // MainWindow::handleTranslationRequest exactly like the translate button
+    // does, but with a language pair the Copy backend rejects.
+    emit window.translationRequested(QStringLiteral("hello"),
+                                     Language(QLocale::French),
+                                     Language(QLocale::English));
+
+    QCOMPARE(window.moduleStatus()->activity(ModuleStatus::Module::Translation), ModuleStatus::Activity::Error);
+    QVERIFY(!window.moduleStatus()->detail(ModuleStatus::Module::Translation).isEmpty());
+    QVERIFY(window.translationEdit()->toPlainText().contains(QStringLiteral("Error")));
+}
+
+void MainWindowStatusTest::testStickyErrorClearedByNextTranslation()
+{
+    MainWindow window;
+
+    emit window.translationRequested(QStringLiteral("hello"),
+                                     Language(QLocale::French),
+                                     Language(QLocale::English));
+    QCOMPARE(window.moduleStatus()->activity(ModuleStatus::Module::Translation), ModuleStatus::Activity::Error);
+
+    // A matching pair reaches Processed/NoError; the cascade ends Idle and
+    // the sticky error is gone.
+    emit window.translationRequested(QStringLiteral("hello"),
+                                     Language(QLocale::English),
+                                     Language(QLocale::English));
+    QCOMPARE(window.moduleStatus()->activity(ModuleStatus::Module::Translation), ModuleStatus::Activity::Idle);
+    QVERIFY(window.translationEdit()->toPlainText() == QStringLiteral("hello"));
+}
+
+// The pop-up window's strip mirrors the main window's model and only appears
+// while something is running, so the resting pop-up is unchanged.
+void MainWindowStatusTest::testPopupStripOnlyVisibleWhileBusy()
+{
+    MainWindow window;
+    window.hide();
+
+    PopupWindow popup(&window);
+    StatusStrip *strip = popup.findChild<StatusStrip *>(QStringLiteral("statusStrip"));
+    QVERIFY(strip != nullptr);
+    QVERIFY(strip->isHidden()); // at rest
+
+    // beginScreenCapture() is a public slot on the model; it is the one
+    // moment with no provider signal to hang off.
+    window.moduleStatus()->beginScreenCapture();
+    QVERIFY(!strip->isHidden());
+}
+
+// Interface/ShowStatusBar pick-up happens in loadAppSettings(), which re-runs
+// after the settings dialog is accepted - each new window reads it fresh.
+void MainWindowStatusTest::testShowStatusBarSettingPickUp()
+{
+    AppSettings settings;
+
+    settings.setShowStatusBar(false);
+    {
+        MainWindow window;
+        QStatusBar *bar = window.findChild<QStatusBar *>(QStringLiteral("statusbar"));
+        QVERIFY(bar != nullptr);
+        QVERIFY(bar->isHidden());
+
+        // The setting hides the whole bar; the strip inside goes with it and
+        // nothing the model does can bring it back.
+        window.moduleStatus()->beginScreenCapture();
+        QVERIFY(bar->isHidden());
+    }
+
+    settings.setShowStatusBar(true);
+    {
+        MainWindow window;
+        QStatusBar *bar = window.findChild<QStatusBar *>(QStringLiteral("statusbar"));
+        QVERIFY(bar != nullptr);
+        QVERIFY(!bar->isHidden());
+    }
+}
+
+int main(int argc, char *argv[])
+{
+    isolateTestSettings();
+
+    SingleApplication app(argc, argv, true);
+    MainWindowStatusTest tc;
+    QTEST_SET_MAIN_SOURCE_PATH
+    return QTest::qExec(&tc, argc, argv);
+}
+
+#include "test_mainwindow_status.moc"
diff --git a/tests/test_modulestatus.cpp b/tests/test_modulestatus.cpp
new file mode 100644
index 00000000..6da4254f
--- /dev/null
+++ b/tests/test_modulestatus.cpp
@@ -0,0 +1,539 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// Headless contract tests for the ModuleStatus aggregator: every provider
+// signal is driven directly (they are public), TTS is exercised through the
+// pull-seeded bind path because ATTSProvider::stateChanged is a private
+// signal. Nothing is ever shown, so no display server is needed beyond what
+// QApplication itself requires.
+
+#include "language.h"
+#include "modulestatus.h"
+#include "provideroptions.h"
+#include "ocr/aocrprovider.h"
+#include "ocr/screengrabbers/genericscreengrabber.h"
+#include "ocr/snippingarea.h"
+#include "translator/atranslationprovider.h"
+#include "translator/copytranslationprovider.h"
+#include "tts/attsprovider.h"
+#include "tts/noopttsprovider.h"
+#include "tts/voice.h"
+
+#include <QApplication>
+#include <QImage>
+#include <QPixmap>
+#include <QScreen>
+#include <QSignalSpy>
+#include <QTest>
+#include <QTextToSpeech>
+
+// The smallest AOcrProvider implementation; the model only ever talks to the
+// base-class signals, and driving those directly keeps this test independent
+// of Tesseract/LLM setup.
+class StubOcr : public AOcrProvider
+{
+    Q_OBJECT
+    Q_DISABLE_COPY(StubOcr)
+
+public:
+    using AOcrProvider::AOcrProvider;
+
+    QString engineName() const override
+    {
+        return QStringLiteral("stub");
+    }
+    bool isConfigured() const override
+    {
+        return true;
+    }
+    void recognize(const QImage &, int) override
+    {
+        emit started();
+    }
+    void cancel() override
+    {
+    }
+};
+
+// The smallest ATTSProvider implementation that can report a chosen state;
+// ATTSProvider::stateChanged is private, so pull-seeding is the only
+// observable path for bindTtsProvider() anyway.
+class StubTtsProvider : public ATTSProvider
+{
+    Q_OBJECT
+    Q_DISABLE_COPY(StubTtsProvider)
+
+public:
+    explicit StubTtsProvider(QTextToSpeech::State state, QObject *parent = nullptr)
+        : ATTSProvider(parent)
+        , m_state(state)
+    {
+    }
+
+    QString getProviderType() const override
+    {
+        return QStringLiteral("StubTtsProvider");
+    }
+    void say(const QString &) override
+    {
+    }
+    void stop() override
+    {
+    }
+    void pause() override
+    {
+    }
+    void resume() override
+    {
+    }
+    QTextToSpeech::State state() const override
+    {
+        return m_state;
+    }
+    QTextToSpeech::ErrorReason errorReason() const override
+    {
+        return QTextToSpeech::ErrorReason::NoError;
+    }
+    QString errorString() const override
+    {
+        return QStringLiteral("stub tts failure");
+    }
+    Language language() const override
+    {
+        return Language::autoLanguage();
+    }
+    void setLanguage(const Language &) override
+    {
+    }
+    Voice voice() const override
+    {
+        return {};
+    }
+    void setVoice(const Voice &) override
+    {
+    }
+    QList<Voice> availableVoices() const override
+    {
+        return {};
+    }
+    QList<Voice> findVoices(const Language &) const override
+    {
+        return {};
+    }
+    double rate() const override
+    {
+        return 0.0;
+    }
+    void setRate(double) override
+    {
+    }
+    double pitch() const override
+    {
+        return 0.0;
+    }
+    void setPitch(double) override
+    {
+    }
+    double volume() const override
+    {
+        return 1.0;
+    }
+    void setVolume(double) override
+    {
+    }
+    QList<Language> availableLanguages() const override
+    {
+        return {};
+    }
+    void applyOptions(const ProviderOptions &) override
+    {
+    }
+    std::unique_ptr<ProviderOptions> getDefaultOptions() const override
+    {
+        return std::make_unique<ProviderOptions>();
+    }
+    QStringList getAvailableOptions() const override
+    {
+        return {};
+    }
+    ProviderUIRequirements getUIRequirements() const override
+    {
+        return {};
+    }
+    QStringList availableSpeakers() const override
+    {
+        return {};
+    }
+    QStringList availableSpeakersForVoice(const Voice &) const override
+    {
+        return {};
+    }
+    QString currentSpeaker() const override
+    {
+        return {};
+    }
+    void setSpeaker(const QString &) override
+    {
+    }
+
+private:
+    QTextToSpeech::State m_state;
+};
+
+class ModuleStatusTest : public QObject
+{
+    Q_OBJECT
+
+private slots:
+    void testTranslationStates();
+    void testTranslationErrorStickiness();
+    void testTranslationAbortIsNotAnError();
+    void testDetectionStates();
+    void testOcrStates();
+    void testBothOcrEnginesShareOneSegment();
+    void testCaptureStates();
+    void testTtsPullSeeding();
+    void testTtsNoopUnavailable();
+    void testIsBusy();
+    void testChangedSignal();
+    void testRebindingClearsStickyState();
+
+private:
+    static constexpr auto s_translating = ModuleStatus::Module::Translation;
+    static constexpr auto s_ocr = ModuleStatus::Module::Ocr;
+    static constexpr auto s_snipping = ModuleStatus::Module::Snipping;
+    static constexpr auto s_tts = ModuleStatus::Module::Tts;
+};
+
+// Copy's translate() drives the whole state machine synchronously: success is
+// Processing -> Processed/NoError, a language mismatch is Processing ->
+// Finished/UnsupportedDstLanguage. "Busy" is only observable during the
+// emission itself, so record it from a stateChanged handler connected after
+// the model's own (same-emitter direct connections run in creation order).
+void ModuleStatusTest::testTranslationStates()
+{
+    ModuleStatus status;
+    CopyTranslationProvider translator;
+
+    QSignalSpy changedSpy(&status, &ModuleStatus::changed);
+    status.bindTranslator(&translator);
+    QVERIFY(changedSpy.isEmpty()); // Ready seeds Idle, not Busy
+
+    ModuleStatus::Activity seenWhileProcessing = ModuleStatus::Activity::Idle;
+    QString messageWhileProcessing;
+    connect(&translator, &ATranslationProvider::stateChanged, &translator, [&](ATranslationProvider::State state) {
+        if (state == ATranslationProvider::State::Processing) {
+            seenWhileProcessing = status.activity(s_translating);
+            messageWhileProcessing = status.message(s_translating);
+        }
+    });
+
+    const Language lang(QLocale::system());
+    translator.translate(QStringLiteral("hello"), lang, lang);
+    QCOMPARE(seenWhileProcessing, ModuleStatus::Activity::Busy);
+    QCOMPARE(messageWhileProcessing, QStringLiteral("Translating"));
+
+    // The synchronous cascade to Processed/Finished/Ready leaves it Idle.
+    QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+    QVERIFY(status.message(s_translating).isEmpty());
+
+    translator.reset();
+    QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+}
+
+void ModuleStatusTest::testTranslationErrorStickiness()
+{
+    ModuleStatus status;
+    CopyTranslationProvider translator;
+    status.bindTranslator(&translator);
+
+    // Finished-with-error: the source/destination mismatch path.
+    translator.translate(QStringLiteral("hello"), Language(QLocale::English), Language(QLocale::French));
+    QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Error);
+    QCOMPARE(status.message(s_translating), QStringLiteral("Translation failed"));
+    QVERIFY(!status.detail(s_translating).isEmpty());
+
+    // The synchronous trailing Ready (reset()) must not overwrite the error.
+    translator.reset();
+    QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Error);
+
+    // The next Busy clears it.
+    const Language lang(QLocale::system());
+    translator.translate(QStringLiteral("hello"), lang, lang);
+    QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+
+    translator.reset();
+    QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+}
+
+// reset() on a Processed provider goes through abort(): Finished/Aborted ->
+// Ready. A deliberate abort is not a failure and must never read as Error -
+// not even transiently, during the Finished emission itself.
+void ModuleStatusTest::testTranslationAbortIsNotAnError()
+{
+    ModuleStatus status;
+    CopyTranslationProvider translator;
+    status.bindTranslator(&translator);
+
+    ModuleStatus::Activity seenAtFinished = ModuleStatus::Activity::Idle;
+    connect(&translator, &ATranslationProvider::stateChanged, &translator, [&](ATranslationProvider::State state) {
+        if (state == ATranslationProvider::State::Finished)
+            seenAtFinished = status.activity(s_translating);
+    });
+
+    const Language lang(QLocale::system());
+    translator.translate(QStringLiteral("hello"), lang, lang);
+    QCOMPARE(translator.getState(), ATranslationProvider::State::Processed);
+
+    translator.reset();
+    QCOMPARE(translator.error, ATranslationProvider::TranslationError::NoError); // reset() clears it
+    QCOMPARE(seenAtFinished, ModuleStatus::Activity::Idle);
+    QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+}
+
+// Language detection in the strip: a standalone detection is the only
+// translation-module work in flight and shows as Busy "Detecting language";
+// a detection chained inside a translation must not clobber "Translating";
+// any stateChanged is the abort/cancel safety net; and like any Busy it
+// clears a sticky error.
+void ModuleStatusTest::testDetectionStates()
+{
+    ModuleStatus status;
+    CopyTranslationProvider translator;
+    status.bindTranslator(&translator);
+
+    emit translator.detectionStarted();
+    QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Busy);
+    QCOMPARE(status.message(s_translating), QStringLiteral("Detecting language"));
+
+    emit translator.languageDetected(Language(QLocale::English), false);
+    QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+
+    // Chained detection (detect-then-translate): "Translating" is already
+    // up, so detectionStarted is not new work, and its languageDetected
+    // must not end the translation.
+    emit translator.stateChanged(ATranslationProvider::State::Processing);
+    QCOMPARE(status.message(s_translating), QStringLiteral("Translating"));
+    emit translator.detectionStarted();
+    QCOMPARE(status.message(s_translating), QStringLiteral("Translating"));
+    emit translator.languageDetected(Language(QLocale::English), true);
+    QCOMPARE(status.message(s_translating), QStringLiteral("Translating"));
+
+    // Safety net: a state transition with no languageDetected at all (the
+    // abort path) still demotes the detection Busy.
+    emit translator.stateChanged(ATranslationProvider::State::Ready);
+    QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+
+    // A detection Busy clears a sticky error, like any other Busy.
+    translator.translate(QStringLiteral("hello"), Language(QLocale::English), Language(QLocale::French));
+    QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Error);
+    emit translator.detectionStarted();
+    QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Busy);
+    QCOMPARE(status.message(s_translating), QStringLiteral("Detecting language"));
+    emit translator.languageDetected(Language(QLocale::English), false);
+    QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+}
+
+void ModuleStatusTest::testOcrStates()
+{
+    ModuleStatus status;
+    StubOcr engine;
+    status.bindOcr(&engine, nullptr);
+
+    QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Idle);
+
+    engine.recognize(QImage(), 96);
+    QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Busy);
+    QCOMPARE(status.message(s_ocr), QStringLiteral("Recognizing text"));
+
+    emit engine.canceled();
+    QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Idle);
+
+    emit engine.recognized(QStringLiteral("text"));
+    QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Idle);
+
+    engine.recognize(QImage(), 96);
+    emit engine.failed(QStringLiteral("boom"));
+    QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Error);
+    QCOMPARE(status.message(s_ocr), QStringLiteral("OCR failed"));
+    QCOMPARE(status.detail(s_ocr), QStringLiteral("boom"));
+
+    // Sticky until the next Busy.
+    emit engine.recognized(QStringLiteral("text"));
+    QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Error);
+
+    engine.recognize(QImage(), 96);
+    QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Busy);
+
+    emit engine.recognized(QStringLiteral("text"));
+    QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Idle);
+}
+
+void ModuleStatusTest::testCaptureStates()
+{
+    ModuleStatus status;
+    GenericScreenGrabber grabber;
+    SnippingArea snippingArea;
+    status.bindCapture(&grabber, &snippingArea);
+
+    QCOMPARE(status.activity(s_snipping), ModuleStatus::Activity::Idle);
+
+    status.beginScreenCapture();
+    QCOMPARE(status.activity(s_snipping), ModuleStatus::Activity::Busy);
+    QCOMPARE(status.message(s_snipping), QStringLiteral("Waiting for capture"));
+
+    emit grabber.grabbed({});
+    QCOMPARE(status.activity(s_snipping), ModuleStatus::Activity::Busy);
+    QCOMPARE(status.message(s_snipping), QStringLiteral("Select a region"));
+
+    emit snippingArea.snipped(QPixmap(), 96);
+    QCOMPARE(status.activity(s_snipping), ModuleStatus::Activity::Idle);
+
+    status.beginScreenCapture();
+    emit snippingArea.cancelled();
+    QCOMPARE(status.activity(s_snipping), ModuleStatus::Activity::Idle);
+
+    status.beginScreenCapture();
+    emit grabber.grabbingFailed();
+    QCOMPARE(status.activity(s_snipping), ModuleStatus::Activity::Error);
+    QCOMPARE(status.message(s_snipping), QStringLiteral("Capture failed"));
+
+    // The error survives the next idle transition and clears on the next Busy.
+    emit snippingArea.cancelled();
+    QCOMPARE(status.activity(s_snipping), ModuleStatus::Activity::Error);
+    status.beginScreenCapture();
+    QCOMPARE(status.activity(s_snipping), ModuleStatus::Activity::Busy);
+}
+
+// ATTSProvider::stateChanged is private, so bindTtsProvider()'s pull-seeding
+// is the only path a test can observe.
+void ModuleStatusTest::testTtsPullSeeding()
+{
+    ModuleStatus status;
+
+    StubTtsProvider speaking(QTextToSpeech::Speaking);
+    status.bindTtsProvider(&speaking);
+    QVERIFY(status.isAvailable(s_tts));
+    QCOMPARE(status.activity(s_tts), ModuleStatus::Activity::Busy);
+    QCOMPARE(status.message(s_tts), QStringLiteral("Speaking"));
+
+    StubTtsProvider synthesizing(QTextToSpeech::Synthesizing);
+    status.bindTtsProvider(&synthesizing);
+    QCOMPARE(status.activity(s_tts), ModuleStatus::Activity::Busy);
+    QCOMPARE(status.message(s_tts), QStringLiteral("Preparing speech"));
+
+    StubTtsProvider ready(QTextToSpeech::Ready);
+    status.bindTtsProvider(&ready);
+    QCOMPARE(status.activity(s_tts), ModuleStatus::Activity::Idle);
+
+    StubTtsProvider failed(QTextToSpeech::Error);
+    status.bindTtsProvider(&failed);
+    QCOMPARE(status.activity(s_tts), ModuleStatus::Activity::Error);
+    QCOMPARE(status.detail(s_tts), QStringLiteral("stub tts failure"));
+}
+
+void ModuleStatusTest::testTtsNoopUnavailable()
+{
+    ModuleStatus status;
+    NoopTTSProvider noop;
+
+    QVERIFY(!status.isAvailable(s_tts)); // not bound at all
+    status.bindTtsProvider(&noop);
+    QVERIFY(!status.isAvailable(s_tts));
+    QCOMPARE(status.activity(s_tts), ModuleStatus::Activity::Idle);
+}
+
+void ModuleStatusTest::testIsBusy()
+{
+    ModuleStatus status;
+    QVERIFY(!status.isBusy());
+
+    StubTtsProvider speaking(QTextToSpeech::Speaking);
+    status.bindTtsProvider(&speaking);
+    QVERIFY(status.isBusy());
+
+    StubTtsProvider ready(QTextToSpeech::Ready);
+    status.bindTtsProvider(&ready);
+    QVERIFY(!status.isBusy());
+
+    // An Error is not Busy - the ellipsis timer must not run for it.
+    CopyTranslationProvider translator;
+    status.bindTranslator(&translator);
+    translator.translate(QStringLiteral("hello"), Language(QLocale::English), Language(QLocale::French));
+    QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Error);
+    QVERIFY(!status.isBusy());
+}
+
+void ModuleStatusTest::testChangedSignal()
+{
+    ModuleStatus status;
+    QSignalSpy changedSpy(&status, &ModuleStatus::changed);
+
+    status.beginScreenCapture();
+    QCOMPARE(changedSpy.count(), 1);
+
+    // Redundant transitions must not spam the view into repainting.
+    status.beginScreenCapture();
+    QCOMPARE(changedSpy.count(), 1);
+}
+
+// The strip has a single OCR segment, but both engines are bound (the active
+// one switches per settings read). A terminal from the engine that didn't
+// start the run still clears the segment - the engines never run
+// concurrently, so any terminal ends "recognizing".
+void ModuleStatusTest::testBothOcrEnginesShareOneSegment()
+{
+    ModuleStatus status;
+    StubOcr tesseract;
+    StubOcr llm;
+    status.bindOcr(&tesseract, &llm);
+
+    tesseract.recognize(QImage(), 96);
+    QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Busy);
+
+    emit llm.recognized(QStringLiteral("text"));
+    QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Idle);
+
+    llm.recognize(QImage(), 96);
+    QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Busy);
+
+    emit tesseract.canceled();
+    QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Idle);
+}
+
+// swapTranslator()/swapTTSProvider() rebind mid-flight: a freshly bound
+// provider owes nothing to its predecessor's sticky error or busy state.
+void ModuleStatusTest::testRebindingClearsStickyState()
+{
+    ModuleStatus status;
+
+    CopyTranslationProvider failing;
+    status.bindTranslator(&failing);
+    failing.translate(QStringLiteral("hello"), Language(QLocale::English), Language(QLocale::French));
+    QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Error);
+
+    CopyTranslationProvider fresh;
+    status.bindTranslator(&fresh);
+    QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+    QVERIFY(status.message(s_translating).isEmpty());
+
+    StubTtsProvider speaking(QTextToSpeech::Speaking);
+    status.bindTtsProvider(&speaking);
+    QCOMPARE(status.activity(s_tts), ModuleStatus::Activity::Busy);
+
+    StubTtsProvider ready(QTextToSpeech::Ready);
+    status.bindTtsProvider(&ready);
+    QCOMPARE(status.activity(s_tts), ModuleStatus::Activity::Idle);
+}
+
+int main(int argc, char *argv[])
+{
+    QApplication app(argc, argv);
+    ModuleStatusTest tc;
+    QTEST_SET_MAIN_SOURCE_PATH
+    return QTest::qExec(&tc, argc, argv);
+}
+
+#include "test_modulestatus.moc"
diff --git a/tests/test_settingsdialog_localai.cpp b/tests/test_settingsdialog_localai.cpp
index 13f3b691..78efb9cd 100644
--- a/tests/test_settingsdialog_localai.cpp
+++ b/tests/test_settingsdialog_localai.cpp
@@ -17,6 +17,7 @@
 #include "translator/atranslationprovider.h"
 
 #include <QComboBox>
+#include <QGroupBox>
 #include <QLineEdit>
 #include <QListWidget>
 #include <QPushButton>
@@ -90,9 +91,8 @@ private slots:
         QVERIFY(urlEdit != nullptr);
         urlEdit->setText(server.baseUrl());
 
-        // The tab now has separate Text/Vision model combos (vision-mode UI);
-        // the dialog defaults to the Text sub-page, which is what "Refresh
-        // models" against a plain chat-completions mock exercises.
+        // The tab has a single text-model combo; "Refresh models" against a
+        // plain chat-completions mock exercises the /v1/models probe.
         auto *modelCombo = ollamaPage->findChild<QComboBox *>(QStringLiteral("localAiTextModelCombo"));
         QVERIFY(modelCombo != nullptr);
 
@@ -161,6 +161,52 @@ private slots:
 
         QCOMPARE(restoredItems, originalItems);
     }
+
+    // The OCR settings page's Tesseract-only widgets (languages,
+    // parameters) must only show up when Tesseract is the selected engine;
+    // the engine-agnostic screen-capture options stay visible either way.
+    void testOcrEngineVisibilitySwitchesTesseractSpecificGroups()
+    {
+        AppSettings settings;
+        settings.setTranslationProviderBackend(ATranslationProvider::ProviderBackend::Copy);
+
+        MainWindow window;
+        SettingsDialog dialog(&window);
+        dialog.show();
+        QVERIFY(QTest::qWaitForWindowExposed(&dialog));
+
+        auto *ocrPage = dialog.findChild<QWidget *>(QStringLiteral("ocrPage"));
+        QVERIFY(ocrPage != nullptr);
+        auto *pagesStack = dialog.findChild<QStackedWidget *>(QStringLiteral("pagesStackedWidget"));
+        QVERIFY(pagesStack != nullptr);
+        auto *pagesList = dialog.findChild<QListWidget *>(QStringLiteral("pagesListWidget"));
+        QVERIFY(pagesList != nullptr);
+        pagesList->setCurrentRow(pagesStack->indexOf(ocrPage));
+
+        auto *engineCombo = dialog.findChild<QComboBox *>(QStringLiteral("ocrEngineCombo"));
+        QVERIFY(engineCombo != nullptr);
+        auto *languagesGroupBox = dialog.findChild<QGroupBox *>(QStringLiteral("languagesGroupBox"));
+        QVERIFY(languagesGroupBox != nullptr);
+        auto *ocrParametersGroupBox = dialog.findChild<QGroupBox *>(QStringLiteral("ocrParametersGroupBox"));
+        QVERIFY(ocrParametersGroupBox != nullptr);
+        auto *screenCaptureGroupBox = dialog.findChild<QGroupBox *>(QStringLiteral("screenCaptureGroupBox"));
+        QVERIFY(screenCaptureGroupBox != nullptr);
+
+        const int tesseractIndex = engineCombo->findData(static_cast<int>(AppSettings::OcrEngine::Tesseract));
+        const int llmIndex = engineCombo->findData(static_cast<int>(AppSettings::OcrEngine::Llm));
+        QVERIFY(tesseractIndex >= 0);
+        QVERIFY(llmIndex >= 0);
+
+        engineCombo->setCurrentIndex(tesseractIndex);
+        QVERIFY(languagesGroupBox->isVisible());
+        QVERIFY(ocrParametersGroupBox->isVisible());
+        QVERIFY(screenCaptureGroupBox->isVisible());
+
+        engineCombo->setCurrentIndex(llmIndex);
+        QVERIFY(!languagesGroupBox->isVisible());
+        QVERIFY(!ocrParametersGroupBox->isVisible());
+        QVERIFY(screenCaptureGroupBox->isVisible());
+    }
 };
 
 int main(int argc, char *argv[])
diff --git a/tests/test_snippingarea_terminal.cpp b/tests/test_snippingarea_terminal.cpp
new file mode 100644
index 00000000..b9dd7a4b
--- /dev/null
+++ b/tests/test_snippingarea_terminal.cpp
@@ -0,0 +1,104 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// Regression tests for SnippingArea's terminal-signal contract: every exit
+// from a snip must emit either snipped or cancelled. Before the fix,
+// acceptSelection() with an empty selection (Enter without selecting, or a
+// drag released without moving) hid the widget while emitting *nothing*,
+// leaving a pending translate-screen-area armed for the next recognition and
+// the status strip stuck on "Select a region".
+//
+// Driven entirely through QApplication::sendEvent() on a never-shown widget,
+// so no display interaction is needed beyond QApplication itself.
+
+#include "ocr/snippingarea.h"
+
+#include <QApplication>
+#include <QKeyEvent>
+#include <QMouseEvent>
+#include <QSignalSpy>
+#include <QTest>
+
+class SnippingAreaTerminalTest : public QObject
+{
+    Q_OBJECT
+
+private slots:
+    void testEnterWithEmptySelectionEmitsCancelled();
+    void testEscapeEmitsCancelled();
+    void testDragReleasedWithoutMoveEmitsCancelled();
+
+private:
+    static void sendKey(QWidget *receiver, int key);
+    static void sendMouse(QWidget *receiver, QEvent::Type type, const QPoint &pos);
+};
+
+// Enter with no selection made: acceptSelection() takes the empty branch.
+void SnippingAreaTerminalTest::testEnterWithEmptySelectionEmitsCancelled()
+{
+    SnippingArea area;
+    area.setRegionRememberType(AppSettings::NeverRemember);
+
+    QSignalSpy cancelledSpy(&area, &SnippingArea::cancelled);
+    sendKey(&area, Qt::Key_Return);
+
+    QCOMPARE(cancelledSpy.count(), 1);
+}
+
+// The long-standing Escape path, pinned so the "always a terminal" contract
+// has both halves locked down.
+void SnippingAreaTerminalTest::testEscapeEmitsCancelled()
+{
+    SnippingArea area;
+
+    QSignalSpy cancelledSpy(&area, &SnippingArea::cancelled);
+    sendKey(&area, Qt::Key_Escape);
+
+    QCOMPARE(cancelledSpy.count(), 1);
+}
+
+// Start a region drag and release without moving: mouseReleaseEvent takes the
+// Outside + confirmOnRelease path into acceptSelection() with an empty
+// selection - the manual "click without drag" scenario, driven headlessly.
+void SnippingAreaTerminalTest::testDragReleasedWithoutMoveEmitsCancelled()
+{
+    SnippingArea area;
+    area.setRegionRememberType(AppSettings::NeverRemember);
+    area.setCaptureOnRelese(true);
+
+    QSignalSpy cancelledSpy(&area, &SnippingArea::cancelled);
+    sendMouse(&area, QEvent::MouseButtonPress, QPoint(100, 100));
+    sendMouse(&area, QEvent::MouseButtonRelease, QPoint(100, 100));
+
+    QCOMPARE(cancelledSpy.count(), 1);
+}
+
+void SnippingAreaTerminalTest::sendKey(QWidget *receiver, int key)
+{
+    QKeyEvent press(QEvent::KeyPress, key, Qt::NoModifier);
+    QKeyEvent release(QEvent::KeyRelease, key, Qt::NoModifier);
+    QApplication::sendEvent(receiver, &press);
+    QApplication::sendEvent(receiver, &release);
+}
+
+void SnippingAreaTerminalTest::sendMouse(QWidget *receiver, QEvent::Type type, const QPoint &pos)
+{
+    const QPointF local(pos);
+    const QPointF global(receiver->mapToGlobal(pos));
+    QMouseEvent event(type, local, global, Qt::LeftButton,
+                      type == QEvent::MouseButtonPress ? Qt::LeftButton : Qt::NoButton,
+                      Qt::NoModifier);
+    QApplication::sendEvent(receiver, &event);
+}
+
+int main(int argc, char *argv[])
+{
+    QApplication app(argc, argv);
+    SnippingAreaTerminalTest tc;
+    QTEST_SET_MAIN_SOURCE_PATH
+    return QTest::qExec(&tc, argc, argv);
+}
+
+#include "test_snippingarea_terminal.moc"
diff --git a/tests/test_statusstrip.cpp b/tests/test_statusstrip.cpp
new file mode 100644
index 00000000..6f393030
--- /dev/null
+++ b/tests/test_statusstrip.cpp
@@ -0,0 +1,278 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// View-contract tests for StatusStrip. The strip is never shown; all
+// visibility assertions use isHidden()/!isHidden(), which reflect the
+// explicit show/hide calls synchronously without depending on window
+// exposure. Busy/error states are driven through ModuleStatus with providers
+// that hold those states persistently (screen capture via beginScreenCapture,
+// a synchronous Copy translation error), so no timing races are involved -
+// except the ellipsis animation, which is the one thing that genuinely needs
+// the timer and gets a qWaitFor().
+
+#include "language.h"
+#include "modulestatus.h"
+#include "statusstrip.h"
+#include "ocr/screengrabbers/genericscreengrabber.h"
+#include "ocr/snippingarea.h"
+#include "translator/copytranslationprovider.h"
+#include "tts/noopttsprovider.h"
+
+#include <QApplication>
+#include <QLabel>
+#include <QLocale>
+#include <QTest>
+
+class StatusStripTest : public QObject
+{
+    Q_OBJECT
+
+private slots:
+    void testReadyAtRest();
+    void testBusySegment();
+    void testErrorSegmentShowsDetailInTooltip();
+    void testSimultaneousBusyAndErrorSegments();
+    void testHideWhenIdle();
+    void testSetShownOverridesModel();
+    void testTtsSegmentOmittedForNoopBackend();
+    void testAnimatedEllipsisWhileBusy();
+    void testDotsStopWhenIdle();
+    void testHiddenStripHidesItsLabels();
+
+private:
+    static constexpr auto s_snipping = ModuleStatus::Module::Snipping;
+    static constexpr auto s_translating = ModuleStatus::Module::Translation;
+
+    static QLabel *segmentLabel(const StatusStrip *strip, ModuleStatus::Module module);
+    static void driveTranslationError(ModuleStatus &model);
+};
+
+void StatusStripTest::testReadyAtRest()
+{
+    ModuleStatus model;
+    StatusStrip strip;
+    strip.setModel(&model);
+
+    // Not hide-when-idle (the main window configuration): the strip is up
+    // with its "Ready" label and every module segment hidden.
+    QVERIFY(!strip.isHidden());
+    QVERIFY(!strip.findChild<QLabel *>(QStringLiteral("readyLabel"))->isHidden());
+    for (int i = 0; i < ModuleStatus::moduleCount(); ++i)
+        QVERIFY(segmentLabel(&strip, static_cast<ModuleStatus::Module>(i))->isHidden());
+}
+
+void StatusStripTest::testBusySegment()
+{
+    ModuleStatus model;
+    StatusStrip strip;
+    strip.setModel(&model);
+
+    model.beginScreenCapture();
+
+    QLabel *ready = strip.findChild<QLabel *>(QStringLiteral("readyLabel"));
+    QLabel *segment = segmentLabel(&strip, s_snipping);
+    QVERIFY(ready->isHidden());
+    QVERIFY(!segment->isHidden());
+    QCOMPARE(segment->text(), QStringLiteral("Waiting for capture"));
+}
+
+void StatusStripTest::testErrorSegmentShowsDetailInTooltip()
+{
+    ModuleStatus model;
+    StatusStrip strip;
+    strip.setModel(&model);
+
+    driveTranslationError(model);
+
+    QLabel *segment = segmentLabel(&strip, s_translating);
+    QVERIFY(!segment->isHidden());
+    QCOMPARE(segment->text(), QStringLiteral("Translation failed"));
+    QVERIFY(!segment->toolTip().isEmpty());
+}
+
+void StatusStripTest::testSimultaneousBusyAndErrorSegments()
+{
+    ModuleStatus model;
+    StatusStrip strip;
+    strip.setModel(&model);
+
+    model.beginScreenCapture();
+    driveTranslationError(model);
+
+    QVERIFY(!segmentLabel(&strip, s_snipping)->isHidden());
+    QVERIFY(!segmentLabel(&strip, s_translating)->isHidden());
+    QCOMPARE(segmentLabel(&strip, s_snipping)->text(), QStringLiteral("Waiting for capture"));
+    QCOMPARE(segmentLabel(&strip, s_translating)->text(), QStringLiteral("Translation failed"));
+}
+
+// The pop-up window configuration: strip hidden at rest, up while anything is
+// active (busy or error - an error must be visible too, not just work).
+void StatusStripTest::testHideWhenIdle()
+{
+    ModuleStatus model;
+    GenericScreenGrabber grabber;
+    model.bindCapture(&grabber, nullptr);
+
+    StatusStrip strip;
+    strip.setModel(&model);
+    strip.setHideWhenIdle(true);
+
+    QVERIFY(strip.isHidden());
+
+    model.beginScreenCapture();
+    QVERIFY(!strip.isHidden());
+
+    emit grabber.grabbingFailed();
+    QVERIFY(!strip.isHidden());
+}
+
+// Interface/ShowStatusBar pick-up: a strip hidden by the setting stays hidden
+// whatever the model does, and comes back when re-enabled.
+void StatusStripTest::testSetShownOverridesModel()
+{
+    ModuleStatus model;
+    StatusStrip strip;
+    strip.setModel(&model);
+
+    strip.setShown(false);
+    QVERIFY(strip.isHidden());
+
+    model.beginScreenCapture();
+    QVERIFY(strip.isHidden());
+
+    strip.setShown(true);
+    QVERIFY(!strip.isHidden());
+    QVERIFY(!segmentLabel(&strip, s_snipping)->isHidden());
+}
+
+void StatusStripTest::testTtsSegmentOmittedForNoopBackend()
+{
+    ModuleStatus model;
+    NoopTTSProvider noop;
+    model.bindTtsProvider(&noop);
+
+    StatusStrip strip;
+    strip.setModel(&model);
+
+    // Something else busy, TTS idle: only that segment shows. The TTS label
+    // must never appear for the no-op backend (isAvailable == false).
+    model.beginScreenCapture();
+
+    QVERIFY(!segmentLabel(&strip, s_snipping)->isHidden());
+    QVERIFY(segmentLabel(&strip, ModuleStatus::Module::Tts)->isHidden());
+}
+
+void StatusStripTest::testAnimatedEllipsisWhileBusy()
+{
+    ModuleStatus model;
+    StatusStrip strip;
+    strip.setModel(&model);
+
+    model.beginScreenCapture();
+    const QLabel *segment = segmentLabel(&strip, s_snipping);
+    QCOMPARE(segment->text(), QStringLiteral("Waiting for capture"));
+
+    // ~400 ms per dot; give it ample room rather than an exact count, since
+    // the assertion is "dots appear and grow", not "exactly N fired".
+    QVERIFY(QTest::qWaitFor([&segment]() {
+        const QString text = segment->text();
+        return text.startsWith(QStringLiteral("Waiting for capture")) && text.endsWith(QLatin1Char('.'));
+    },
+                            3000));
+    QVERIFY(QTest::qWaitFor([&segment]() {
+        return segment->text().endsWith(QStringLiteral(".."));
+    },
+                            3000));
+
+    // The animated ellipsis must not rename the widget for exact-match
+    // lookups (AT-SPI Name is what read_widget_text matches on): the
+    // accessible name stays the dot-free message while the dots animate.
+    QCOMPARE(segment->accessibleName(), QStringLiteral("Waiting for capture"));
+}
+
+void StatusStripTest::testDotsStopWhenIdle()
+{
+    ModuleStatus model;
+    GenericScreenGrabber grabber;
+    SnippingArea snippingArea;
+    model.bindCapture(&grabber, &snippingArea);
+
+    StatusStrip strip;
+    strip.setModel(&model);
+
+    model.beginScreenCapture();
+    QTest::qWait(900); // let some dots animate
+
+    emit snippingArea.cancelled();
+    const QLabel *segment = segmentLabel(&strip, s_snipping);
+    QVERIFY(segment->isHidden());
+    QVERIFY(segment->text().isEmpty());
+    QVERIFY(segment->accessibleName().isEmpty());
+
+    // No further ticks: the state stays clean after the timer's last slot.
+    QTest::qWait(900);
+    QVERIFY(segment->text().isEmpty());
+}
+
+QLabel *StatusStripTest::segmentLabel(const StatusStrip *strip, ModuleStatus::Module module)
+{
+    static const char *const segmentNames[] = {"snippingSegmentLabel", "ocrSegmentLabel", "translationSegmentLabel", "ttsSegmentLabel"};
+    return strip->findChild<QLabel *>(QString::fromLatin1(segmentNames[static_cast<int>(module)]));
+}
+
+void StatusStripTest::driveTranslationError(ModuleStatus &model)
+{
+    CopyTranslationProvider translator;
+    model.bindTranslator(&translator);
+    translator.translate(QStringLiteral("hello"), Language(QLocale::English), Language(QLocale::French));
+}
+
+// A strip that goes away (hide-when-idle at rest, or the ShowStatusBar
+// setting) must hide and DE-IDENTIFY its labels, not just hide the
+// container: the AT-SPI bridge never prunes interfaces, so a label keeps
+// its name-matchable identity (and keeps announcing to screen readers)
+// unless it is cleared and hidden itself.
+void StatusStripTest::testHiddenStripHidesItsLabels()
+{
+    ModuleStatus model;
+    StatusStrip strip;
+    strip.setModel(&model);
+
+    QLabel *ready = strip.findChild<QLabel *>(QStringLiteral("readyLabel"));
+
+    // Hide-when-idle at rest: strip hidden, "Ready" label hidden and
+    // de-identified.
+    strip.setHideWhenIdle(true);
+    QVERIFY(strip.isHidden());
+    QVERIFY(ready->isHidden());
+    QVERIFY(ready->text().isEmpty());
+    QVERIFY(ready->accessibleName().isEmpty());
+
+    // The setting pick-up path: strip suppressed by setShown(false) even
+    // while the model is busy.
+    strip.setHideWhenIdle(false);
+    strip.setShown(false);
+    model.beginScreenCapture();
+    QVERIFY(strip.isHidden());
+    for (int i = 0; i < ModuleStatus::moduleCount(); ++i)
+        QVERIFY(segmentLabel(&strip, static_cast<ModuleStatus::Module>(i))->isHidden());
+
+    // Coming back re-shows what the state warrants.
+    strip.setShown(true);
+    QVERIFY(!strip.isHidden());
+    QVERIFY(!segmentLabel(&strip, ModuleStatus::Module::Snipping)->isHidden());
+    QVERIFY(ready->isHidden());
+    QVERIFY(ready->text().isEmpty());
+}
+
+int main(int argc, char *argv[])
+{
+    QApplication app(argc, argv);
+    StatusStripTest tc;
+    QTEST_SET_MAIN_SOURCE_PATH
+    return QTest::qExec(&tc, argc, argv);
+}
+
+#include "test_statusstrip.moc"
diff --git a/tests/test_translation.cpp b/tests/test_translation.cpp
index a495b10f..5a4f0e43 100644
--- a/tests/test_translation.cpp
+++ b/tests/test_translation.cpp
@@ -12,7 +12,7 @@
 #include "singleapplication.h"
 #include "sourcetextedit.h"
 #include "testisolation.h"
-#include "ocr/ocr.h"
+#include "ocr/tesseractocr.h"
 #include "settings/appsettings.h"
 #include "translator/atranslationprovider.h"
 #include "tts/attsprovider.h"
diff --git a/tests/test_translationlogic.cpp b/tests/test_translationlogic.cpp
new file mode 100644
index 00000000..2330f471
--- /dev/null
+++ b/tests/test_translationlogic.cpp
@@ -0,0 +1,74 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// Pins the canonical auto-destination rule (TranslationLogic) so it can never
+// regress in one backend while another is correct. This behaviour has broken
+// repeatedly; keep it in one place and covered here.
+
+#include "translator/translationlogic.h"
+
+#include <QtTest>
+
+class TranslationLogicTest : public QObject
+{
+    Q_OBJECT
+
+private slots:
+    void sameLanguageTreatsBaseLanguageAsEqual();
+    void preferredDestinationPicksPrimaryWhenSourceDiffers();
+    void preferredDestinationPicksSecondaryWhenSourceIsPrimary();
+    void preferredDestinationFallsBackToSystem();
+};
+
+void TranslationLogicTest::sameLanguageTreatsBaseLanguageAsEqual()
+{
+    using TranslationLogic::sameLanguage;
+    QVERIFY(sameLanguage(Language(QStringLiteral("en")), Language(QLocale(QStringLiteral("en_US")))));
+    QVERIFY(sameLanguage(Language(QStringLiteral("en")), Language(QLocale(QStringLiteral("en_GB")))));
+    QVERIFY(sameLanguage(Language(QStringLiteral("pl")), Language(QStringLiteral("pl"))));
+    QVERIFY(!sameLanguage(Language(QStringLiteral("en")), Language(QStringLiteral("pl"))));
+}
+
+void TranslationLogicTest::preferredDestinationPicksPrimaryWhenSourceDiffers()
+{
+    using TranslationLogic::preferredDestination;
+    const Language primary(QStringLiteral("en"));
+    const Language secondary(QStringLiteral("ru"));
+    const Language fallback(QLocale(QStringLiteral("fr_FR")));
+
+    QCOMPARE(preferredDestination(Language(QStringLiteral("pl")), primary, secondary, fallback).toCode(), primary.toCode());
+    QCOMPARE(preferredDestination(Language(QStringLiteral("de")), primary, secondary, fallback).toCode(), primary.toCode());
+}
+
+void TranslationLogicTest::preferredDestinationPicksSecondaryWhenSourceIsPrimary()
+{
+    using TranslationLogic::preferredDestination;
+    const Language primary(QStringLiteral("en"));
+    const Language secondary(QStringLiteral("ru"));
+    const Language fallback(QLocale(QStringLiteral("fr_FR")));
+
+    QCOMPARE(preferredDestination(primary, primary, secondary, fallback).toCode(), secondary.toCode());
+    // a territory variant of the primary is still the primary -> secondary
+    QCOMPARE(preferredDestination(Language(QLocale(QStringLiteral("en_US"))), primary, secondary, fallback).toCode(),
+             secondary.toCode());
+    // source equal to the secondary -> primary
+    QCOMPARE(preferredDestination(secondary, primary, secondary, fallback).toCode(), primary.toCode());
+}
+
+void TranslationLogicTest::preferredDestinationFallsBackToSystem()
+{
+    using TranslationLogic::preferredDestination;
+    const Language primary(QStringLiteral("en"));
+    const Language fallback(QLocale(QStringLiteral("fr_FR")));
+
+    // unset primary and secondary -> fallback
+    QCOMPARE(preferredDestination(primary, Language::autoLanguage(), Language::autoLanguage(), fallback).toCode(),
+             fallback.toCode());
+    // source equals both primary and secondary -> fallback
+    QCOMPARE(preferredDestination(primary, primary, primary, fallback).toCode(), fallback.toCode());
+}
+
+QTEST_GUILESS_MAIN(TranslationLogicTest)
+#include "test_translationlogic.moc"
\ No newline at end of file
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.