From 583b5de14cfb1e2ea56cf6200e914e8044236f93 Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:27:33 +0200 Subject: [PATCH] feat: add support code completion via acp --- CMakeLists.txt | 15 +- docs/completion-modes.md | 37 ++ sources/completion/AcpCompletionEngine.cpp | 427 ++++++++++++++++ sources/completion/AcpCompletionEngine.hpp | 86 ++++ .../completion/AgenticCompletionEngine.cpp | 249 ++++++++++ .../completion/AgenticCompletionEngine.hpp | 61 +++ ...lient.cpp => CodeCompletionController.cpp} | 373 ++++++++------ ...lient.hpp => CodeCompletionController.hpp} | 63 ++- sources/completion/CompletionEngine.hpp | 34 ++ sources/completion/FimCompletionEngine.cpp | 292 +++++++++++ sources/completion/FimCompletionEngine.hpp | 69 +++ sources/completion/LLMClientInterface.cpp | 455 ------------------ sources/completion/LLMClientInterface.hpp | 93 ---- sources/completion/LSPCompletion.hpp | 147 ------ sources/context/CMakeLists.txt | 4 + sources/context/CompletionContextEnricher.cpp | 282 +++++++++++ sources/context/CompletionContextEnricher.hpp | 42 ++ sources/context/DocumentContextReader.cpp | 5 +- sources/mcp/CompletionMcpServer.cpp | 109 +++++ sources/mcp/CompletionMcpServer.hpp | 43 ++ sources/plugin/qodeassist.cpp | 127 ++++- sources/settings/CodeCompletionSettings.cpp | 97 ++-- sources/settings/CodeCompletionSettings.hpp | 5 +- sources/settings/SettingsConstants.hpp | 11 +- sources/tools/ProposeCompletionTool.cpp | 104 ++++ sources/tools/ProposeCompletionTool.hpp | 32 ++ sources/widgets/CompletionHintHandler.cpp | 57 --- sources/widgets/CompletionHintHandler.hpp | 34 -- sources/widgets/CompletionHintWidget.cpp | 68 --- sources/widgets/CompletionHintWidget.hpp | 29 -- sources/widgets/EditorChatButton.cpp | 125 ----- sources/widgets/EditorChatButton.hpp | 38 -- sources/widgets/EditorChatButtonHandler.cpp | 74 --- sources/widgets/EditorChatButtonHandler.hpp | 37 -- 34 files changed, 2310 insertions(+), 1414 deletions(-) create mode 100644 docs/completion-modes.md create mode 100644 sources/completion/AcpCompletionEngine.cpp create mode 100644 sources/completion/AcpCompletionEngine.hpp create mode 100644 sources/completion/AgenticCompletionEngine.cpp create mode 100644 sources/completion/AgenticCompletionEngine.hpp rename sources/completion/{QodeAssistClient.cpp => CodeCompletionController.cpp} (68%) rename sources/completion/{QodeAssistClient.hpp => CodeCompletionController.hpp} (50%) create mode 100644 sources/completion/CompletionEngine.hpp create mode 100644 sources/completion/FimCompletionEngine.cpp create mode 100644 sources/completion/FimCompletionEngine.hpp delete mode 100644 sources/completion/LLMClientInterface.cpp delete mode 100644 sources/completion/LLMClientInterface.hpp delete mode 100644 sources/completion/LSPCompletion.hpp create mode 100644 sources/context/CompletionContextEnricher.cpp create mode 100644 sources/context/CompletionContextEnricher.hpp create mode 100644 sources/mcp/CompletionMcpServer.cpp create mode 100644 sources/mcp/CompletionMcpServer.hpp create mode 100644 sources/tools/ProposeCompletionTool.cpp create mode 100644 sources/tools/ProposeCompletionTool.hpp delete mode 100644 sources/widgets/CompletionHintHandler.cpp delete mode 100644 sources/widgets/CompletionHintHandler.hpp delete mode 100644 sources/widgets/CompletionHintWidget.cpp delete mode 100644 sources/widgets/CompletionHintWidget.hpp delete mode 100644 sources/widgets/EditorChatButton.cpp delete mode 100644 sources/widgets/EditorChatButton.hpp delete mode 100644 sources/widgets/EditorChatButtonHandler.cpp delete mode 100644 sources/widgets/EditorChatButtonHandler.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1daeece..f7847e2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,7 +47,6 @@ add_subdirectory(sources) add_qtc_plugin(QodeAssist PLUGIN_DEPENDS QtCreator::Core - QtCreator::LanguageClient QtCreator::TextEditor QtCreator::ProjectExplorer QtCreator::CppEditor @@ -78,10 +77,12 @@ add_qtc_plugin(QodeAssist sources/plugin/UpdateStatusWidget.hpp sources/plugin/UpdateStatusWidget.cpp sources/llmcore/ContextData.hpp sources/llmcore/RequestType.hpp - sources/completion/LLMClientInterface.hpp sources/completion/LLMClientInterface.cpp - sources/completion/LSPCompletion.hpp + sources/completion/CompletionEngine.hpp + sources/completion/FimCompletionEngine.hpp sources/completion/FimCompletionEngine.cpp + sources/completion/AgenticCompletionEngine.hpp sources/completion/AgenticCompletionEngine.cpp + sources/completion/AcpCompletionEngine.hpp sources/completion/AcpCompletionEngine.cpp + sources/completion/CodeCompletionController.hpp sources/completion/CodeCompletionController.cpp sources/completion/LLMSuggestion.hpp sources/completion/LLMSuggestion.cpp - sources/completion/QodeAssistClient.hpp sources/completion/QodeAssistClient.cpp sources/completion/CodeHandler.hpp sources/completion/CodeHandler.cpp sources/refactor/QuickRefactorHandler.hpp sources/refactor/QuickRefactorHandler.cpp sources/refactor/RefactorContextHelper.hpp @@ -141,12 +142,8 @@ add_qtc_plugin(QodeAssist sources/chat/ChatEditorFactory.hpp sources/chat/ChatEditorFactory.cpp sources/widgets/CompletionProgressHandler.hpp sources/widgets/CompletionProgressHandler.cpp sources/widgets/CompletionErrorHandler.hpp sources/widgets/CompletionErrorHandler.cpp - sources/widgets/CompletionHintWidget.hpp sources/widgets/CompletionHintWidget.cpp - sources/widgets/CompletionHintHandler.hpp sources/widgets/CompletionHintHandler.cpp sources/widgets/ProgressWidget.hpp sources/widgets/ProgressWidget.cpp sources/widgets/ErrorWidget.hpp sources/widgets/ErrorWidget.cpp - sources/widgets/EditorChatButton.hpp sources/widgets/EditorChatButton.cpp - sources/widgets/EditorChatButtonHandler.hpp sources/widgets/EditorChatButtonHandler.cpp sources/widgets/QuickRefactorDialog.hpp sources/widgets/QuickRefactorDialog.cpp sources/widgets/CustomInstructionsManager.hpp sources/widgets/CustomInstructionsManager.cpp sources/widgets/AddCustomInstructionDialog.hpp sources/widgets/AddCustomInstructionDialog.cpp @@ -166,10 +163,12 @@ add_qtc_plugin(QodeAssist sources/tools/ReadFileTool.hpp sources/tools/ReadFileTool.cpp sources/tools/FileSearchUtils.hpp sources/tools/FileSearchUtils.cpp sources/tools/TodoTool.hpp sources/tools/TodoTool.cpp + sources/tools/ProposeCompletionTool.hpp sources/tools/ProposeCompletionTool.cpp sources/tools/ReadOriginalHistoryTool.hpp sources/tools/ReadOriginalHistoryTool.cpp sources/tools/SkillTool.hpp sources/tools/SkillTool.cpp sources/tools/EditorStateTools.hpp sources/tools/EditorStateTools.cpp sources/mcp/AgentKnowledgeServer.hpp sources/mcp/AgentKnowledgeServer.cpp + sources/mcp/CompletionMcpServer.hpp sources/mcp/CompletionMcpServer.cpp sources/mcp/McpServerManager.hpp sources/mcp/McpServerManager.cpp sources/mcp/McpServerConnection.hpp sources/mcp/McpServerConnection.cpp sources/mcp/McpClientsManager.hpp sources/mcp/McpClientsManager.cpp diff --git a/docs/completion-modes.md b/docs/completion-modes.md new file mode 100644 index 0000000..b69eb65 --- /dev/null +++ b/docs/completion-modes.md @@ -0,0 +1,37 @@ +# Code completion modes + +QodeAssist produces an inline code-completion suggestion (ghost text you accept with Tab) +in one of four ways, chosen per setup in **Preferences → QodeAssist → Code Completion → +Completion mode**. All four share the same trigger heuristics, ghost-text rendering, and +Tab / word / line accept — they differ only in *how* the suggestion is produced. Pick the +one that matches your model's capability. + +| Mode | What it does | Best for | +|---|---|---| +| **Classic FIM** | One fill-in-the-middle request to the configured provider, using the code around the cursor. | Weak or older local models; the lowest-latency option; the default. | +| **FIM + context** | The same FIM request, enriched with semantic context read from Qt Creator's built-in code model (C++ class outline and referenced declarations; QML sibling components). Chat-type templates only. | Capable models where extra context improves the completion and the added prompt size is affordable. | +| **Agentic (local tools)** | A tool-capable provider model runs the in-process tool loop, gathers its own context, and returns the completion by calling the `propose_completion` tool. | Strong tool-calling models, on demand. Multi-round and slow — **manual trigger only**. | +| **Agentic (ACP agent)** | An external ACP agent (chosen below the mode selector) does the same over the QodeAssist MCP server. | Using an external coding agent as the completion engine. Manual trigger only; needs an agent selected. | + +## Notes and requirements + +- **Default and migration.** Existing setups open as **Classic FIM** with unchanged + behavior — the mode is a new setting with a behavior-preserving default, and your + connection, model, template, and API-key settings are untouched. +- **FIM + context** only enriches C++ and QML files, and only when the active template is a + chat-type template. The built-in code model can lag unsaved edits, so the added context + is a hint, not a guarantee; the request still runs for unsupported languages, just without + the extra context. (Qt Creator's built-in `CPlusPlus` model is used, not clangd's AST, + which is not reachable by plugins.) +- **Agentic modes are manual-trigger only.** Because a tool loop takes seconds and many + tokens, the agentic modes are *not* fired automatically while you type — invoke them with + the **Request QodeAssist Suggestion** shortcut (default `Ctrl+Alt+Q`, reconfigurable in + Preferences → Keyboard). They need a tool-capable provider (local) and a chat-type + template. +- **Agentic (ACP agent)** additionally needs an agent id from the Agents catalogue entered + in **ACP completion agent id**, and the QodeAssist MCP server running. With no agent + selected, the mode is disabled — it never falls back to a FIM request; a manual trigger + reports the missing configuration instead. + +See also: [file-context.md](file-context.md) for how the plain-text context window is +assembled, and [acp-agents.md](acp-agents.md) for configuring ACP agents. diff --git a/sources/completion/AcpCompletionEngine.cpp b/sources/completion/AcpCompletionEngine.cpp new file mode 100644 index 0000000..82a93b0 --- /dev/null +++ b/sources/completion/AcpCompletionEngine.cpp @@ -0,0 +1,427 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "completion/AcpCompletionEngine.hpp" + +#include + +#include +#include + +#include "acp/AgentSpawn.hpp" +#include "context/DocumentContextReader.hpp" +#include "logger/Logger.hpp" +#include "settings/SettingsConstants.hpp" + +namespace QodeAssist { + +namespace { + +void upsertEnv(QList &env, const QString &name, const QString &value) +{ + for (auto &variable : env) { + if (variable.name == name) { + variable.value = value; + return; + } + } + env.append({name, value}); +} + +LLMQore::Acp::RequestPermissionResult autoAllow( + const QString &, + const LLMQore::Acp::ToolCall &toolCall, + const QList &options) +{ + QStringList optionDump; + for (const auto &option : options) + optionDump.append(QString("%1(%2)").arg(option.optionId, option.kind)); + + const LLMQore::Acp::PermissionOption *chosen = nullptr; + for (const auto &option : options) { + if (option.kind == QLatin1String(LLMQore::Acp::PermissionOptionKind::AllowOnce)) { + chosen = &option; + break; + } + if (!chosen && option.kind == QLatin1String(LLMQore::Acp::PermissionOptionKind::AllowAlways)) + chosen = &option; + } + if (!chosen && !options.isEmpty()) + chosen = &options.first(); + + LOG_MESSAGE( + QString("ACP completion: permission requested for tool '%1' (kind=%2); options=[%3]; %4") + .arg( + toolCall.title, + toolCall.kind, + optionDump.join(QStringLiteral(", ")), + chosen ? QString("auto-allowing '%1'").arg(chosen->optionId) + : QStringLiteral("cancelling (no option offered)"))); + + return chosen ? LLMQore::Acp::RequestPermissionResult::selected(chosen->optionId) + : LLMQore::Acp::RequestPermissionResult::cancelled(); +} + +} // namespace + +AcpCompletionEngine::AcpCompletionEngine( + const Settings::CodeCompletionSettings &completeSettings, + const Settings::GeneralSettings &generalSettings, + AgentResolver agentResolver, + Context::IDocumentReader &documentReader, + Tools::ProposeCompletionTool *proposeTool, + QObject *parent) + : CompletionEngine(parent) + , m_completeSettings(completeSettings) + , m_generalSettings(generalSettings) + , m_agentResolver(std::move(agentResolver)) + , m_documentReader(documentReader) + , m_proposeTool(proposeTool) + , m_clientFactory(&Acp::spawnAgent) + , m_completionServer(new Mcp::CompletionMcpServer(this)) + , m_permissions(new LLMQore::Acp::CallbackPermissionProvider(&autoAllow, this)) +{ + if (m_proposeTool) { + connect( + m_proposeTool, + &Tools::ProposeCompletionTool::completionProposed, + this, + &AcpCompletionEngine::handleCompletionProposed); + } +} + +void AcpCompletionEngine::setClientFactory(ClientFactory factory) +{ + if (factory) + m_clientFactory = std::move(factory); +} + +bool AcpCompletionEngine::hasConfiguredAgent() const +{ + const QString agentId = m_completeSettings.completionAgentId(); + if (agentId.isEmpty() || !m_agentResolver) + return false; + return m_agentResolver(agentId).has_value(); +} + +void AcpCompletionEngine::request(quint64 requestId, const CompletionContext &context) +{ + if (m_active) { + const auto stale = *m_active; + m_active.reset(); + if (m_client && !m_sessionId.isEmpty()) + m_client->cancel(m_sessionId); + emit completionFailed(stale.requestId, QStringLiteral("Superseded by a newer request")); + } + + const QString agentId = m_completeSettings.completionAgentId(); + if (agentId.isEmpty()) { + emit completionFailed( + requestId, QStringLiteral("Agentic (ACP agent) completion has no agent selected")); + return; + } + + if (!m_agentResolver || !m_agentResolver(agentId).has_value()) { + emit completionFailed( + requestId, QString("ACP completion agent '%1' is not in the catalogue").arg(agentId)); + return; + } + + auto documentInfo = m_documentReader.readDocument(context.filePath); + if (!documentInfo.document) { + emit completionFailed( + requestId, QString("Document is not available: %1").arg(context.filePath)); + return; + } + + Context::DocumentContextReader reader( + documentInfo.document, documentInfo.mimeType, documentInfo.filePath); + const auto ctx = reader.prepareContext(context.line, context.column, m_completeSettings); + const QString codeContext = ctx.prefix.value_or("") + "" + ctx.suffix.value_or(""); + + m_active + = ActiveRequest{requestId, documentInfo.filePath, context.line, context.column, codeContext}; + + LOG_MESSAGE( + QString("ACP completion: request %1 for %2 (line %3, col %4), agent '%5'") + .arg(QString::number(requestId), documentInfo.filePath) + .arg(context.line) + .arg(context.column) + .arg(agentId)); + + if (m_boundAgentId != agentId) { + if (!m_boundAgentId.isEmpty()) + LOG_MESSAGE( + QString("ACP completion: selected agent changed %1 -> %2, releasing client") + .arg(m_boundAgentId, agentId)); + releaseClient(); + } + m_boundAgentId = agentId; + + ensureClientAndPrompt(); +} + +void AcpCompletionEngine::ensureClientAndPrompt() +{ + if (m_client && m_sessionReady) { + LOG_MESSAGE( + QString("ACP completion: reusing live session %1, sending a new prompt") + .arg(m_sessionId)); + sendPrompt(); + return; + } + + if (m_client) { + LOG_MESSAGE("ACP completion: client is still establishing its session, will prompt once ready"); + return; + } + + const auto agent = m_agentResolver ? m_agentResolver(m_boundAgentId) : std::nullopt; + if (!agent) { + failActive(QString("ACP completion agent '%1' vanished from the catalogue").arg(m_boundAgentId)); + return; + } + + Acp::AgentDefinition launchAgent = *agent; + const QString model = m_generalSettings.ccModel(); + if (!model.isEmpty()) { + upsertEnv(launchAgent.distribution.env, QStringLiteral("ANTHROPIC_MODEL"), model); + LOG_MESSAGE( + QString("ACP completion: injecting ANTHROPIC_MODEL=%1 into agent env").arg(model)); + } + + const QString cwd = Acp::agentWorkingDirectory(); + LOG_MESSAGE( + QString("ACP completion: spawning agent '%1' (%2) in %3") + .arg(launchAgent.id, launchAgent.name, cwd)); + const Acp::AgentProcess process = m_clientFactory(launchAgent, cwd, this); + m_client = process.client; + if (!m_client) { + failActive(QStringLiteral("This agent has no launchable distribution")); + return; + } + + m_client->setPermissionProvider(m_permissions); + + const int generation = ++m_clientGeneration; + connect(m_client, &LLMQore::Acp::AcpClient::errorOccurred, this, [this, generation](const QString &error) { + if (generation != m_clientGeneration) + return; + LOG_MESSAGE(QString("ACP completion: agent error: %1").arg(error)); + failActive(error); + releaseClient(); + }); + connect(m_client, &LLMQore::Acp::AcpClient::disconnected, this, [this, generation]() { + if (generation != m_clientGeneration) + return; + LOG_MESSAGE("ACP completion: agent disconnected"); + m_sessionReady = false; + m_sessionId.clear(); + }); + connect(m_client, &LLMQore::Acp::AcpClient::agentStderr, this, [this, generation](const QString &line) { + if (generation != m_clientGeneration) + return; + LOG_MESSAGE(QString("ACP completion [agent stderr]: %1").arg(line)); + }); + connect( + m_client, + &LLMQore::Acp::AcpClient::toolCallStarted, + this, + [this, generation](const QString &, const LLMQore::Acp::ToolCall &call) { + if (generation != m_clientGeneration) + return; + LOG_MESSAGE( + QString("ACP completion: agent tool call started: title='%1' kind=%2 status=%3") + .arg(call.title, call.kind, call.status)); + }); + connect( + m_client, + &LLMQore::Acp::AcpClient::toolCallUpdated, + this, + [this, generation](const QString &, const LLMQore::Acp::ToolCall &call) { + if (generation != m_clientGeneration) + return; + LOG_MESSAGE( + QString("ACP completion: agent tool call updated: title='%1' kind=%2 status=%3") + .arg(call.title, call.kind, call.status)); + }); + + LOG_MESSAGE("ACP completion: connecting and initializing the agent"); + m_client->connectAndInitialize(std::chrono::seconds(30)) + .then(this, [this, generation, cwd](const LLMQore::Acp::InitializeResult &info) { + if (generation != m_clientGeneration || !m_client) + return; + const bool http = info.agentCapabilities.mcpCapabilities.http; + LOG_MESSAGE( + QString("ACP completion: agent initialized (protocol %1, MCP http=%2 sse=%3)") + .arg(QString::number(info.protocolVersion), + http ? QStringLiteral("true") : QStringLiteral("false"), + info.agentCapabilities.mcpCapabilities.sse ? QStringLiteral("true") + : QStringLiteral("false"))); + if (!http) { + LOG_MESSAGE( + "ACP completion: WARNING — this agent does not advertise HTTP MCP support, so " + "it will likely ignore the QodeAssist MCP server and never call " + "propose_completion. Use an agent that accepts HTTP MCP servers."); + } + + const QString mcpUrl = m_completionServer->start(m_proposeTool); + if (mcpUrl.isEmpty()) { + failActive(QStringLiteral( + "The QodeAssist completion MCP server could not start; ACP completion needs it")); + releaseClient(); + return; + } + LLMQore::Acp::NewSessionParams params; + params.cwd = cwd; + params.mcpServers + = {LLMQore::Acp::McpServer::http(Mcp::CompletionMcpServer::serverName(), mcpUrl)}; + LOG_MESSAGE( + QString("ACP completion: creating session, offering MCP server '%1' at %2") + .arg(Mcp::CompletionMcpServer::serverName(), mcpUrl)); + m_client->newSession(params, std::chrono::seconds(30)) + .then(this, [this, generation](const LLMQore::Acp::NewSessionResult &result) { + if (generation != m_clientGeneration || !m_client) + return; + m_sessionId = result.sessionId; + m_sessionReady = true; + LOG_MESSAGE(QString("ACP completion: session %1 established").arg(m_sessionId)); + sendPrompt(); + }) + .onFailed(this, [this, generation](const std::exception &e) { + if (generation != m_clientGeneration) + return; + LOG_MESSAGE(QString("ACP completion: session/new failed: %1") + .arg(QString::fromUtf8(e.what()))); + failActive(QString::fromUtf8(e.what())); + releaseClient(); + }); + }) + .onFailed(this, [this, generation](const std::exception &e) { + if (generation != m_clientGeneration) + return; + LOG_MESSAGE(QString("ACP completion: initialize failed: %1") + .arg(QString::fromUtf8(e.what()))); + failActive(QString::fromUtf8(e.what())); + releaseClient(); + }); +} + +void AcpCompletionEngine::sendPrompt() +{ + if (!m_active || !m_client || m_sessionId.isEmpty()) + return; + + const int generation = m_clientGeneration; + const quint64 activeId = m_active->requestId; + const QString text = promptText(); + LOG_MESSAGE( + QString("ACP completion: prompting session %1 for request %2") + .arg(m_sessionId, QString::number(activeId))); + LOG_MESSAGE(QString("ACP completion: prompt text:\n%1").arg(text)); + m_client->prompt(m_sessionId, {LLMQore::Acp::ContentBlock::makeText(text)}) + .then(this, [this, generation, activeId](const LLMQore::Acp::PromptResult &result) { + if (generation != m_clientGeneration || !m_active || m_active->requestId != activeId) + return; + LOG_MESSAGE( + QString("ACP completion: prompt for request %1 finished with stopReason '%2' but " + "no proposal arrived") + .arg(QString::number(activeId), result.stopReason)); + m_active.reset(); + emit completionFailed( + activeId, + QStringLiteral("The agent finished without proposing a completion")); + }) + .onFailed(this, [this, generation, activeId](const std::exception &e) { + if (generation != m_clientGeneration || !m_active || m_active->requestId != activeId) + return; + m_active.reset(); + LOG_MESSAGE(QString("ACP completion failed: %1").arg(QString::fromUtf8(e.what()))); + emit completionFailed(activeId, QString::fromUtf8(e.what())); + }); +} + +QString AcpCompletionEngine::promptText() const +{ + if (!m_active) + return {}; + return QString( + "Complete the code at the marker in the snippet below. Be fast: do NOT " + "use any other tools, do NOT read files, run commands, or search — work only " + "from this snippet.\n\n" + "\n%1\n\n\n" + "First work out the exact code that should replace (just the " + "continuation — no explanations, no code fences, and do not repeat the code " + "already before or after the cursor). Then make ONE call to propose_completion " + "with that code as the `text` argument. The `text` must be the real completion " + "and must never be empty. file=%2, line=%3, column=%4.") + .arg(m_active->codeContext) + .arg(m_active->filePath) + .arg(m_active->line) + .arg(m_active->column); +} + +void AcpCompletionEngine::cancel(quint64 requestId) +{ + if (!m_active || m_active->requestId != requestId) + return; + m_active.reset(); + if (m_client && !m_sessionId.isEmpty()) + m_client->cancel(m_sessionId); +} + +void AcpCompletionEngine::handleCompletionProposed( + const QString &filePath, int line, int column, const QString &text) +{ + Q_UNUSED(line) + Q_UNUSED(column) + + LOG_MESSAGE( + QString("ACP completion: propose_completion called for '%1' (%2 chars)") + .arg(filePath, QString::number(text.size()))); + + if (!m_active) { + LOG_MESSAGE("ACP completion: ignoring proposal — no completion request is active"); + return; + } + + if (m_active->filePath != filePath) { + LOG_MESSAGE( + QString("ACP completion: proposal path '%1' differs from the requested '%2'; " + "accepting anyway (single active request)") + .arg(filePath, m_active->filePath)); + } + + const quint64 externalId = m_active->requestId; + m_active.reset(); + LOG_MESSAGE( + QString("ACP completion: rendering proposal for request %1").arg(QString::number(externalId))); + emit completionReady(externalId, text); +} + +void AcpCompletionEngine::failActive(const QString &error) +{ + if (!m_active) + return; + const auto stale = *m_active; + m_active.reset(); + LOG_MESSAGE(QString("ACP completion failed: %1").arg(error)); + emit completionFailed(stale.requestId, error); +} + +void AcpCompletionEngine::releaseClient() +{ + ++m_clientGeneration; + m_sessionReady = false; + m_sessionId.clear(); + if (m_client) { + m_client->shutdown(); + m_client->deleteLater(); + m_client = nullptr; + } + if (m_completionServer) + m_completionServer->stop(); +} + +} // namespace QodeAssist diff --git a/sources/completion/AcpCompletionEngine.hpp b/sources/completion/AcpCompletionEngine.hpp new file mode 100644 index 0000000..3202444 --- /dev/null +++ b/sources/completion/AcpCompletionEngine.hpp @@ -0,0 +1,86 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include + +#include + +#include + +#include "acp/AgentDefinition.hpp" +#include "acp/AgentSpawn.hpp" +#include "completion/CompletionEngine.hpp" +#include "context/IDocumentReader.hpp" +#include "mcp/CompletionMcpServer.hpp" +#include "settings/CodeCompletionSettings.hpp" +#include "settings/GeneralSettings.hpp" +#include "tools/ProposeCompletionTool.hpp" + +namespace QodeAssist { + +class AcpCompletionEngine : public CompletionEngine +{ + Q_OBJECT + +public: + using ClientFactory = std::function< + Acp::AgentProcess(const Acp::AgentDefinition &agent, const QString &cwd, QObject *parent)>; + using AgentResolver = std::function(const QString &id)>; + + AcpCompletionEngine( + const Settings::CodeCompletionSettings &completeSettings, + const Settings::GeneralSettings &generalSettings, + AgentResolver agentResolver, + Context::IDocumentReader &documentReader, + Tools::ProposeCompletionTool *proposeTool, + QObject *parent = nullptr); + + void setClientFactory(ClientFactory factory); + + void request(quint64 requestId, const CompletionContext &context) override; + void cancel(quint64 requestId) override; + + bool hasConfiguredAgent() const; + +private slots: + void handleCompletionProposed( + const QString &filePath, int line, int column, const QString &text); + +private: + struct ActiveRequest + { + quint64 requestId = 0; + QString filePath; + int line = 0; + int column = 0; + QString codeContext; + }; + + void ensureClientAndPrompt(); + void sendPrompt(); + void failActive(const QString &error); + void releaseClient(); + QString promptText() const; + + const Settings::CodeCompletionSettings &m_completeSettings; + const Settings::GeneralSettings &m_generalSettings; + AgentResolver m_agentResolver; + Context::IDocumentReader &m_documentReader; + Tools::ProposeCompletionTool *m_proposeTool = nullptr; + ClientFactory m_clientFactory; + Mcp::CompletionMcpServer *m_completionServer = nullptr; + LLMQore::Acp::AcpPermissionProvider *m_permissions = nullptr; + + LLMQore::Acp::AcpClient *m_client = nullptr; + QString m_boundAgentId; + QString m_sessionId; + int m_clientGeneration = 0; + bool m_sessionReady = false; + std::optional m_active; +}; + +} // namespace QodeAssist diff --git a/sources/completion/AgenticCompletionEngine.cpp b/sources/completion/AgenticCompletionEngine.cpp new file mode 100644 index 0000000..e95fbec --- /dev/null +++ b/sources/completion/AgenticCompletionEngine.cpp @@ -0,0 +1,249 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "completion/AgenticCompletionEngine.hpp" + +#include +#include +#include + +#include "context/DocumentContextReader.hpp" +#include "llmcore/ContextData.hpp" +#include "logger/Logger.hpp" +#include "settings/ToolsSettings.hpp" + +namespace QodeAssist { + +AgenticCompletionEngine::AgenticCompletionEngine( + const Settings::GeneralSettings &generalSettings, + const Settings::CodeCompletionSettings &completeSettings, + Providers::IProviderRegistry &providerRegistry, + Templates::IPromptProvider *promptProvider, + Context::IDocumentReader &documentReader, + IRequestPerformanceLogger &performanceLogger, + QObject *parent) + : CompletionEngine(parent) + , m_generalSettings(generalSettings) + , m_completeSettings(completeSettings) + , m_providerRegistry(providerRegistry) + , m_promptProvider(promptProvider) + , m_documentReader(documentReader) + , m_performanceLogger(performanceLogger) +{ +} + +AgenticCompletionEngine::~AgenticCompletionEngine() +{ + const auto requests = m_activeRequests; + m_activeRequests.clear(); + for (auto it = requests.constBegin(); it != requests.constEnd(); ++it) { + if (it.value().provider) + it.value().provider->cancelRequest(it.key()); + } +} + +void AgenticCompletionEngine::request(quint64 requestId, const CompletionContext &context) +{ + auto documentInfo = m_documentReader.readDocument(context.filePath); + if (!documentInfo.document) { + const QString error = QString("Document is not available: %1").arg(context.filePath); + LOG_MESSAGE("Error: " + error); + emit completionFailed(requestId, error); + return; + } + + const auto providerName = m_generalSettings.ccProvider(); + const auto provider = m_providerRegistry.getProviderByName(providerName); + + if (!provider) { + const QString error = QString("No provider found with name: %1").arg(providerName); + LOG_MESSAGE(error); + emit completionFailed(requestId, error); + return; + } + + if (!provider->capabilities().testFlag(Providers::ProviderCapability::Tools)) { + const QString error + = QString("Agentic completion needs tool calling, which provider %1 does not support") + .arg(providerName); + LOG_MESSAGE(error); + emit completionFailed(requestId, error); + return; + } + + auto promptTemplate = m_promptProvider->getTemplateByName(m_generalSettings.ccTemplate()); + + if (!promptTemplate) { + const QString error + = QString("No template found with name: %1").arg(m_generalSettings.ccTemplate()); + LOG_MESSAGE(error); + emit completionFailed(requestId, error); + return; + } + + if (promptTemplate->type() != Templates::TemplateType::Chat) { + const QString error + = QString("Agentic completion needs a chat-type template; %1 is FIM-only") + .arg(promptTemplate->name()); + LOG_MESSAGE(error); + emit completionFailed(requestId, error); + return; + } + + if (auto *toolsManager = provider->toolsManager()) { + auto *proposeTool = qobject_cast( + toolsManager->tool(Tools::ProposeCompletionTool::toolId())); + if (!proposeTool) { + proposeTool = new Tools::ProposeCompletionTool(); + toolsManager->addTool(proposeTool); + } + connect( + proposeTool, + &Tools::ProposeCompletionTool::completionProposed, + this, + &AgenticCompletionEngine::handleCompletionProposed, + Qt::UniqueConnection); + } + + Context::DocumentContextReader + reader(documentInfo.document, documentInfo.mimeType, documentInfo.filePath); + auto updatedContext = reader.prepareContext(context.line, context.column, m_completeSettings); + + QString systemPrompt = m_completeSettings.systemPromptForNonFimModels(); + systemPrompt.append( + QString("\n\nYou are completing code at %1, zero-based line %2, column %3. " + "You may call the read-only project tools to gather context. When you know the " + "completion, call the propose_completion tool exactly once with the completion " + "text for that position; the text is shown to the user as an inline suggestion. " + "Do not answer in prose and do not edit any files.") + .arg(documentInfo.filePath) + .arg(context.line) + .arg(context.column)); + + if (updatedContext.fileContext.has_value()) + systemPrompt.append(updatedContext.fileContext.value()); + + updatedContext.systemPrompt = systemPrompt; + + QString userMessage = m_completeSettings.processMessageToFIM( + updatedContext.prefix.value_or(""), updatedContext.suffix.value_or("")); + QVector messages; + messages.append({"user", userMessage}); + updatedContext.history = messages; + + QJsonObject payload{{"model", m_generalSettings.ccModel()}, {"stream", true}}; + + provider->prepareRequest( + payload, + promptTemplate, + updatedContext, + LLMCore::RequestType::CodeCompletion, + true, + false); + + connect( + provider->client(), + &::LLMQore::BaseClient::requestCompleted, + this, + &AgenticCompletionEngine::handleFullResponse, + Qt::UniqueConnection); + connect( + provider->client(), + &::LLMQore::BaseClient::requestFailed, + this, + &AgenticCompletionEngine::handleRequestFailed, + Qt::UniqueConnection); + + provider->client()->setMaxToolContinuations(Settings::toolsSettings().maxToolContinuations()); + provider->client()->setTransferTimeout( + static_cast(m_generalSettings.requestTimeout() * 1000)); + + auto llmRequestId = provider->sendRequest( + QUrl(m_generalSettings.ccUrl()), payload, promptTemplate->endpoint()); + m_activeRequests.insert(llmRequestId, {requestId, documentInfo.filePath, provider}); + m_performanceLogger.startTimeMeasurement(llmRequestId); +} + +void AgenticCompletionEngine::cancel(quint64 requestId) +{ + for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) { + if (it.value().requestId != requestId) + continue; + const auto llmRequestId = it.key(); + auto *provider = it.value().provider; + m_activeRequests.erase(it); + m_performanceLogger.endTimeMeasurement(llmRequestId); + if (provider) + provider->cancelRequest(llmRequestId); + return; + } +} + +void AgenticCompletionEngine::handleCompletionProposed( + const QString &filePath, int line, int column, const QString &text) +{ + Q_UNUSED(line) + Q_UNUSED(column) + + auto match = m_activeRequests.end(); + for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) { + if (it.value().filePath == filePath) { + match = it; + break; + } + } + + if (match == m_activeRequests.end() && m_activeRequests.size() == 1) + match = m_activeRequests.begin(); + + if (match == m_activeRequests.end()) { + LOG_MESSAGE( + QString("Ignoring completion proposal for %1: no completion request in flight") + .arg(filePath)); + return; + } + + const quint64 externalId = match.value().requestId; + const auto llmRequestId = match.key(); + m_activeRequests.erase(match); + m_performanceLogger.endTimeMeasurement(llmRequestId); + emit completionReady(externalId, text); +} + +void AgenticCompletionEngine::handleFullResponse( + const ::LLMQore::RequestID &requestId, const QString &fullText) +{ + Q_UNUSED(fullText) + + auto it = m_activeRequests.find(requestId); + if (it == m_activeRequests.end()) + return; + + const ActiveRequest active = it.value(); + m_activeRequests.erase(it); + m_performanceLogger.endTimeMeasurement(requestId); + + const QString error + = QStringLiteral("The model finished without proposing a completion"); + LOG_MESSAGE(QString("Agentic completion request %1: %2").arg(requestId, error)); + emit completionFailed(active.requestId, error); +} + +void AgenticCompletionEngine::handleRequestFailed( + const ::LLMQore::RequestID &requestId, const QString &error) +{ + auto it = m_activeRequests.find(requestId); + if (it == m_activeRequests.end()) + return; + + const ActiveRequest active = it.value(); + m_activeRequests.erase(it); + m_performanceLogger.endTimeMeasurement(requestId); + + LOG_MESSAGE(QString("Agentic completion request %1 failed: %2").arg(requestId, error)); + + emit completionFailed(active.requestId, error); +} + +} // namespace QodeAssist diff --git a/sources/completion/AgenticCompletionEngine.hpp b/sources/completion/AgenticCompletionEngine.hpp new file mode 100644 index 0000000..ce975a1 --- /dev/null +++ b/sources/completion/AgenticCompletionEngine.hpp @@ -0,0 +1,61 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +#include "completion/CompletionEngine.hpp" +#include "context/IDocumentReader.hpp" +#include "logger/IRequestPerformanceLogger.hpp" +#include "providers/IProviderRegistry.hpp" +#include "settings/CodeCompletionSettings.hpp" +#include "settings/GeneralSettings.hpp" +#include "templates/IPromptProvider.hpp" +#include "tools/ProposeCompletionTool.hpp" + +namespace QodeAssist { + +class AgenticCompletionEngine : public CompletionEngine +{ + Q_OBJECT + +public: + AgenticCompletionEngine( + const Settings::GeneralSettings &generalSettings, + const Settings::CodeCompletionSettings &completeSettings, + Providers::IProviderRegistry &providerRegistry, + Templates::IPromptProvider *promptProvider, + Context::IDocumentReader &documentReader, + IRequestPerformanceLogger &performanceLogger, + QObject *parent = nullptr); + ~AgenticCompletionEngine() override; + + void request(quint64 requestId, const CompletionContext &context) override; + void cancel(quint64 requestId) override; + +private slots: + void handleCompletionProposed( + const QString &filePath, int line, int column, const QString &text); + void handleFullResponse(const ::LLMQore::RequestID &requestId, const QString &fullText); + void handleRequestFailed(const ::LLMQore::RequestID &requestId, const QString &error); + +private: + struct ActiveRequest + { + quint64 requestId = 0; + QString filePath; + Providers::Provider *provider = nullptr; + }; + + const Settings::GeneralSettings &m_generalSettings; + const Settings::CodeCompletionSettings &m_completeSettings; + Providers::IProviderRegistry &m_providerRegistry; + Templates::IPromptProvider *m_promptProvider = nullptr; + Context::IDocumentReader &m_documentReader; + IRequestPerformanceLogger &m_performanceLogger; + QHash<::LLMQore::RequestID, ActiveRequest> m_activeRequests; +}; + +} // namespace QodeAssist diff --git a/sources/completion/QodeAssistClient.cpp b/sources/completion/CodeCompletionController.cpp similarity index 68% rename from sources/completion/QodeAssistClient.cpp rename to sources/completion/CodeCompletionController.cpp index 651a680..51c41ae 100644 --- a/sources/completion/QodeAssistClient.cpp +++ b/sources/completion/CodeCompletionController.cpp @@ -24,30 +24,26 @@ * Additional attribution terms under GPLv3 §7(b) apply — see LICENSE */ -#include "completion/QodeAssistClient.hpp" +#include "completion/CodeCompletionController.hpp" #include -#include #include #include -#include -#include +#include +#include #include -#include "completion/LLMClientInterface.hpp" #include "completion/LLMSuggestion.hpp" +#include "refactor/RefactorContextHelper.hpp" #include "refactor/RefactorSuggestion.hpp" -#include "refactor/RefactorSuggestionHoverHandler.hpp" #include "settings/CodeCompletionSettings.hpp" #include "settings/GeneralSettings.hpp" #include "settings/ProjectSettings.hpp" #include "settings/QuickRefactorSettings.hpp" -#include "widgets/RefactorWidgetHandler.hpp" -#include "refactor/RefactorContextHelper.hpp" +#include "settings/SettingsConstants.hpp" #include -using namespace LanguageServerProtocol; using namespace TextEditor; using namespace Utils; using namespace ProjectExplorer; @@ -56,11 +52,6 @@ using namespace Core; namespace QodeAssist { namespace { -Utils::Text::Position toTextPos(const Utils::Text::Position &pos) -{ - return Utils::Text::Position{pos.line, pos.column}; -} - bool isIdentifierChar(QChar c) { return c.isLetterOrNumber() || c == QLatin1Char('_'); @@ -133,23 +124,32 @@ bool isAfterEagerTrigger(const QTextCursor &cursor) || c == QLatin1Char(':') || c == QLatin1Char('>'); } -bool isManualMode() +bool isManualTriggerMode() { - return Settings::codeCompletionSettings().completionMode.stringValue() == "Manual"; + return Settings::codeCompletionSettings().triggerMode.stringValue() == "Manual"; +} + +bool isAgenticMode() +{ + const QString mode = Settings::codeCompletionSettings().completionMode.stringValue(); + return mode == QLatin1StringView(Constants::CC_MODE_AGENTIC_LOCAL) + || mode == QLatin1StringView(Constants::CC_MODE_AGENTIC_ACP); } } // anonymous namespace -QodeAssistClient::QodeAssistClient(LLMClientInterface *clientInterface) - : LanguageClient::Client(clientInterface) - , m_llmClient(clientInterface) +CodeCompletionController::CodeCompletionController( + CompletionEngine *fimEngine, + CompletionEngine *agenticEngine, + CompletionEngine *acpEngine, + Context::ContextManager &contextManager, + QObject *parent) + : QObject(parent) + , m_fimEngine(fimEngine) + , m_agenticEngine(agenticEngine) + , m_acpEngine(acpEngine) + , m_contextManager(contextManager) , m_recentCharCount(0) { - setName("QodeAssist"); - LanguageClient::LanguageFilter filter; - filter.mimeTypes = QStringList() << "*"; - setSupportedLanguage(filter); - - start(); setupConnections(); m_typingTimer.start(); @@ -158,30 +158,33 @@ QodeAssistClient::QodeAssistClient(LLMClientInterface *clientInterface) m_refactorWidgetHandler = new RefactorWidgetHandler(this); } -QodeAssistClient::~QodeAssistClient() +CodeCompletionController::~CodeCompletionController() { cleanupConnections(); delete m_refactorHoverHandler; delete m_refactorWidgetHandler; } -void QodeAssistClient::openDocument(TextEditor::TextDocument *document) +void CodeCompletionController::openDocument(TextEditor::TextDocument *document) { + if (m_documentConnections.contains(document)) + return; + auto project = ProjectManager::projectForFile(document->filePath()); if (!isEnabled(project)) return; - Client::openDocument(document); - auto editors = TextEditor::BaseTextEditor::textEditorsForDocument(document); for (auto *editor : editors) { if (auto *widget = editor->editorWidget()) { widget->addHoverHandler(m_refactorHoverHandler); widget->installEventFilter(this); + if (!m_hoverHandlerWidgets.contains(widget)) + m_hoverHandlerWidgets.append(widget); } } - connect( + auto connection = connect( document, &TextDocument::contentsChangedWithPosition, this, @@ -189,7 +192,7 @@ void QodeAssistClient::openDocument(TextEditor::TextDocument *document) if (!Settings::codeCompletionSettings().autoCompletion()) return; - if (isManualMode()) + if (isManualTriggerMode() || isAgenticMode()) return; auto project = ProjectManager::projectForFile(document->filePath()); @@ -243,42 +246,47 @@ void QodeAssistClient::openDocument(TextEditor::TextDocument *document) handleAutoRequestTrigger(widget); }); + + m_documentConnections.insert(document, connection); } -bool QodeAssistClient::canOpenProject(ProjectExplorer::Project *project) +void CodeCompletionController::closeDocument(TextEditor::TextDocument *document) { - return isEnabled(project); + auto it = m_documentConnections.find(document); + if (it == m_documentConnections.end()) + return; + disconnect(it.value()); + m_documentConnections.erase(it); } -void QodeAssistClient::requestCompletions(TextEditor::TextEditorWidget *editor) +void CodeCompletionController::requestCompletions(TextEditor::TextEditorWidget *editor) { auto project = ProjectManager::projectForFile(editor->textDocument()->filePath()); if (!isEnabled(project)) return; - - if (m_llmClient->contextManager() - ->ignoreManager() - ->shouldIgnore(editor->textDocument()->filePath().toUrlishString(), project)) { + if (m_contextManager.ignoreManager()->shouldIgnore( + editor->textDocument()->filePath().toUrlishString(), project)) { LOG_MESSAGE(QString("Ignoring file due to .qodeassistignore: %1") .arg(editor->textDocument()->filePath().toUrlishString())); return; } + auto *engine = activeEngine(); + if (!engine) + return; + MultiTextCursor cursor = editor->multiTextCursor(); if (cursor.hasMultipleCursors() || cursor.hasSelection() || editor->suggestionVisible()) return; + cancelRunningRequest(editor); + const auto &settings = Settings::codeCompletionSettings(); if (settings.abortAssistOnRequest() && !settings.respectQtcPopup()) editor->abortAssist(); - const FilePath filePath = editor->textDocument()->filePath(); - GetCompletionRequest request{ - {TextDocumentIdentifier(hostPathToServerUri(filePath)), - documentVersion(filePath), - Position(cursor.mainCursor())}}; if (Settings::codeCompletionSettings().showProgressWidget()) { m_progressHandler.setCancelCallback([this, editor = QPointer(editor)]() { if (editor) { @@ -287,16 +295,22 @@ void QodeAssistClient::requestCompletions(TextEditor::TextEditorWidget *editor) }); m_progressHandler.showProgress(editor); } - request.setResponseCallback([this, editor = QPointer(editor)]( - const GetCompletionRequest::Response &response) { - QTC_ASSERT(editor, return); - handleCompletions(response, editor); - }); - m_runningRequests[editor] = request; - sendMessage(request); + + const int position = cursor.mainCursor().position(); + const quint64 requestId = ++m_lastRequestId; + m_runningRequests[editor] = {requestId, position, engine}; + m_requestEditors[requestId] = editor; + + const QTextBlock block = editor->document()->findBlock(position); + CompletionContext context; + context.filePath = editor->textDocument()->filePath().toUrlishString(); + context.line = block.blockNumber(); + context.column = position - block.position(); + + engine->request(requestId, context); } -void QodeAssistClient::requestQuickRefactor( +void CodeCompletionController::requestQuickRefactor( TextEditor::TextEditorWidget *editor, const QString &instructions) { auto project = ProjectManager::projectForFile(editor->textDocument()->filePath()); @@ -304,9 +318,8 @@ void QodeAssistClient::requestQuickRefactor( if (!isEnabled(project)) return; - if (m_llmClient->contextManager() - ->ignoreManager() - ->shouldIgnore(editor->textDocument()->filePath().toUrlishString(), project)) { + if (m_contextManager.ignoreManager()->shouldIgnore( + editor->textDocument()->filePath().toUrlishString(), project)) { LOG_MESSAGE(QString("Ignoring file due to .qodeassistignore: %1") .arg(editor->textDocument()->filePath().toUrlishString())); return; @@ -318,7 +331,7 @@ void QodeAssistClient::requestQuickRefactor( m_refactorHandler, &QuickRefactorHandler::refactoringCompleted, this, - &QodeAssistClient::handleRefactoringResult); + &CodeCompletionController::handleRefactoringResult); } m_progressHandler.setCancelCallback([this, editor = QPointer(editor)]() { @@ -331,7 +344,7 @@ void QodeAssistClient::requestQuickRefactor( m_refactorHandler->sendRefactorRequest(editor, instructions); } -void QodeAssistClient::scheduleRequest(TextEditor::TextEditorWidget *editor) +void CodeCompletionController::scheduleRequest(TextEditor::TextEditorWidget *editor) { if (m_runningRequests.contains(editor)) { if (Settings::codeCompletionSettings().cancelOnInput()) @@ -362,25 +375,74 @@ void QodeAssistClient::scheduleRequest(TextEditor::TextEditorWidget *editor) it.value()->setProperty("cursorPosition", editor->textCursor().position()); it.value()->start(Settings::codeCompletionSettings().startSuggestionTimer()); } -void QodeAssistClient::handleCompletions( - const GetCompletionRequest::Response &response, TextEditor::TextEditorWidget *editor) + +void CodeCompletionController::handleCompletionReady(quint64 requestId, const QString &text) { + auto editorIt = m_requestEditors.find(requestId); + if (editorIt == m_requestEditors.end()) + return; + + const QPointer editor = editorIt.value(); + m_requestEditors.erase(editorIt); + + if (!editor) { + dropRunningRequestById(requestId); + return; + } + + auto runningIt = m_runningRequests.find(editor); + if (runningIt == m_runningRequests.end() || runningIt.value().id != requestId) + return; + + const int requestPosition = runningIt.value().position; + m_runningRequests.erase(runningIt); + m_progressHandler.hideProgress(); const auto &settings = Settings::codeCompletionSettings(); if (settings.abortAssistOnRequest() && !settings.respectQtcPopup()) editor->abortAssist(); - if (response.error()) { - log(*response.error()); - m_errorHandler - .showError(editor, tr("Code completion failed: %1").arg(response.error()->message())); + applyCompletion(editor, text, requestPosition); +} + +void CodeCompletionController::handleCompletionFailed(quint64 requestId, const QString &error) +{ + auto editorIt = m_requestEditors.find(requestId); + if (editorIt == m_requestEditors.end()) + return; + + const QPointer editor = editorIt.value(); + m_requestEditors.erase(editorIt); + + LOG_MESSAGE(QString("Code completion failed: %1").arg(error)); + + if (!editor) { + dropRunningRequestById(requestId); return; } - int requestPosition = -1; - if (const auto requestParams = m_runningRequests.take(editor).params()) - requestPosition = requestParams->position().toPositionInDocument(editor->document()); + auto runningIt = m_runningRequests.find(editor); + if (runningIt == m_runningRequests.end() || runningIt.value().id != requestId) + return; + m_runningRequests.erase(runningIt); + m_progressHandler.hideProgress(); + m_errorHandler.showError(editor, tr("Code completion failed: %1").arg(error)); +} + +void CodeCompletionController::dropRunningRequestById(quint64 requestId) +{ + for (auto it = m_runningRequests.begin(); it != m_runningRequests.end(); ++it) { + if (it.value().id == requestId) { + m_runningRequests.erase(it); + return; + } + } +} + +void CodeCompletionController::applyCompletion( + TextEditor::TextEditorWidget *editor, const QString &text, int requestPosition) +{ const MultiTextCursor cursors = editor->multiTextCursor(); if (cursors.hasMultipleCursors() || cursors.hasSelection()) return; @@ -401,80 +463,51 @@ void QodeAssistClient::handleCompletions( } } - if (const std::optional result = response.result()) { - auto isValidCompletion = [](const Completion &completion) { - return completion.isValid() && !completion.text().trimmed().isEmpty(); - }; - QList completions - = Utils::filtered(result->completions().toListOrEmpty(), isValidCompletion); + QString completionText = text; + const int end = int(completionText.size()) - 1; + int delta = 0; + while (delta <= end && completionText[end - delta].isSpace()) + ++delta; + if (delta > 0) + completionText.chop(delta); - QList matchedCompletions; - matchedCompletions.reserve(completions.size()); - for (Completion &completion : completions) { - const LanguageServerProtocol::Range range = completion.range(); - if (range.start().line() != range.end().line()) - continue; - - QString completionText = completion.text(); - const int end = int(completionText.size()) - 1; - int delta = 0; - while (delta <= end && completionText[end - delta].isSpace()) - ++delta; - if (delta > 0) - completionText.chop(delta); - - if (!typedSinceRequest.isEmpty()) { - if (!completionText.startsWith(typedSinceRequest)) - continue; - completionText = completionText.mid(typedSinceRequest.size()); - if (completionText.isEmpty()) - continue; - } - - completion.setText(completionText); - matchedCompletions.append(completion); - } - - if (matchedCompletions.isEmpty()) { - LOG_MESSAGE("No valid completions received"); + if (!typedSinceRequest.isEmpty()) { + if (!completionText.startsWith(typedSinceRequest)) { + LOG_MESSAGE("Completion no longer matches the typed text"); return; } - - const Text::Position anchor = typedSinceRequest.isEmpty() - ? Text::Position{} - : Text::Position::fromPositionInDocument(editor->document(), currentPosition); - const bool useAnchor = !typedSinceRequest.isEmpty(); - - auto suggestions = Utils::transform(matchedCompletions, - [useAnchor, &anchor](const Completion &c) { - auto toTextPos = [](const LanguageServerProtocol::Position pos) { - return Text::Position{pos.line() + 1, pos.character()}; - }; - - if (useAnchor) { - return TextSuggestion::Data{Text::Range{anchor, anchor}, anchor, c.text()}; - } - - Text::Range range{toTextPos(c.range().start()), toTextPos(c.range().end())}; - Text::Position pos{toTextPos(c.position())}; - return TextSuggestion::Data{range, pos, c.text()}; - }); - - editor->insertSuggestion(std::make_unique(suggestions, editor->document())); + completionText = completionText.mid(typedSinceRequest.size()); } + + if (completionText.trimmed().isEmpty()) { + LOG_MESSAGE("No valid completions received"); + return; + } + + const int anchorPosition = typedSinceRequest.isEmpty() ? requestPosition : currentPosition; + const Text::Position anchor + = Text::Position::fromPositionInDocument(editor->document(), anchorPosition); + + const TextSuggestion::Data suggestion{Text::Range{anchor, anchor}, anchor, completionText}; + editor->insertSuggestion( + std::make_unique(QList{suggestion}, editor->document())); } -void QodeAssistClient::cancelRunningRequest(TextEditor::TextEditorWidget *editor) +void CodeCompletionController::cancelRunningRequest(TextEditor::TextEditorWidget *editor) { const auto it = m_runningRequests.constFind(editor); if (it == m_runningRequests.constEnd()) return; - m_progressHandler.hideProgress(); - cancelRequest(it->id()); + const quint64 requestId = it->id; + CompletionEngine *engine = it->engine; m_runningRequests.erase(it); + m_requestEditors.remove(requestId); + m_progressHandler.hideProgress(); + if (engine) + engine->cancel(requestId); } -bool QodeAssistClient::isEnabled(ProjectExplorer::Project *project) const +bool CodeCompletionController::isEnabled(ProjectExplorer::Project *project) const { if (!project) return Settings::generalSettings().enableQodeAssist(); @@ -483,8 +516,33 @@ bool QodeAssistClient::isEnabled(ProjectExplorer::Project *project) const return settings.isEnabled(); } -void QodeAssistClient::setupConnections() +CompletionEngine *CodeCompletionController::activeEngine() const { + const QString mode = Settings::codeCompletionSettings().completionMode.stringValue(); + if (mode == QLatin1StringView(Constants::CC_MODE_AGENTIC_LOCAL)) + return m_agenticEngine; + if (mode == QLatin1StringView(Constants::CC_MODE_AGENTIC_ACP)) + return m_acpEngine; + return m_fimEngine; +} + +void CodeCompletionController::setupConnections() +{ + for (CompletionEngine *engine : {m_fimEngine, m_agenticEngine, m_acpEngine}) { + if (!engine) + continue; + connect( + engine, + &CompletionEngine::completionReady, + this, + &CodeCompletionController::handleCompletionReady); + connect( + engine, + &CompletionEngine::completionFailed, + this, + &CodeCompletionController::handleCompletionFailed); + } + auto openDoc = [this](IDocument *document) { if (auto *textDocument = qobject_cast(document)) openDocument(textDocument); @@ -502,16 +560,26 @@ void QodeAssistClient::setupConnections() openDoc(doc); } -void QodeAssistClient::cleanupConnections() +void CodeCompletionController::cleanupConnections() { disconnect(m_documentOpenedConnection); disconnect(m_documentClosedConnection); + for (auto it = m_documentConnections.begin(); it != m_documentConnections.end(); ++it) + disconnect(it.value()); + m_documentConnections.clear(); + + for (const auto &widget : std::as_const(m_hoverHandlerWidgets)) { + if (widget) + widget->removeHoverHandler(m_refactorHoverHandler); + } + m_hoverHandlerWidgets.clear(); + qDeleteAll(m_scheduledRequests); m_scheduledRequests.clear(); } -void QodeAssistClient::handleRefactoringResult(const RefactorResult &result) +void CodeCompletionController::handleRefactoringResult(const RefactorResult &result) { m_progressHandler.hideProgress(); @@ -534,7 +602,7 @@ void QodeAssistClient::handleRefactoringResult(const RefactorResult &result) } int displayMode = Settings::quickRefactorSettings().displayMode(); - + if (displayMode == 0) { displayRefactoringWidget(result); } else { @@ -542,12 +610,12 @@ void QodeAssistClient::handleRefactoringResult(const RefactorResult &result) } } -void QodeAssistClient::displayRefactoringSuggestion(const RefactorResult &result) +void CodeCompletionController::displayRefactoringSuggestion(const RefactorResult &result) { TextEditorWidget *editorWidget = result.editor; - Utils::Text::Range range{toTextPos(result.insertRange.begin), toTextPos(result.insertRange.end)}; - Utils::Text::Position pos = toTextPos(result.insertRange.begin); + Utils::Text::Range range{result.insertRange.begin, result.insertRange.end}; + Utils::Text::Position pos = result.insertRange.begin; int startPos = range.begin.toPositionInDocument(editorWidget->document()); int endPos = range.end.toPositionInDocument(editorWidget->document()); @@ -577,9 +645,7 @@ void QodeAssistClient::displayRefactoringSuggestion(const RefactorResult &result } TextEditor::TextSuggestion::Data suggestionData{ - Utils::Text::Range{toTextPos(result.insertRange.begin), toTextPos(result.insertRange.end)}, - pos, - result.newText}; + Utils::Text::Range{result.insertRange.begin, result.insertRange.end}, pos, result.newText}; editorWidget->insertSuggestion( std::make_unique(suggestionData, editorWidget->document())); @@ -599,20 +665,20 @@ void QodeAssistClient::displayRefactoringSuggestion(const RefactorResult &result LOG_MESSAGE("Displaying refactoring suggestion with hover handler"); } -void QodeAssistClient::displayRefactoringWidget(const RefactorResult &result) +void CodeCompletionController::displayRefactoringWidget(const RefactorResult &result) { TextEditorWidget *editorWidget = result.editor; - Utils::Text::Range range{toTextPos(result.insertRange.begin), toTextPos(result.insertRange.end)}; - + Utils::Text::Range range{result.insertRange.begin, result.insertRange.end}; + RefactorContext ctx = RefactorContextHelper::extractContext(editorWidget, range); - + QString displayOriginal; QString displayRefactored; QString textToApply = result.newText; - + if (ctx.isInsertion) { bool isMultiline = result.newText.contains('\n'); - + if (isMultiline) { displayOriginal = ctx.textBeforeCursor; displayRefactored = ctx.textBeforeCursor + result.newText; @@ -620,7 +686,7 @@ void QodeAssistClient::displayRefactoringWidget(const RefactorResult &result) displayOriginal = ctx.textBeforeCursor + ctx.textAfterCursor; displayRefactored = ctx.textBeforeCursor + result.newText + ctx.textAfterCursor; } - + if (!ctx.textBeforeCursor.isEmpty() || !ctx.textAfterCursor.isEmpty()) { textToApply = result.newText; } @@ -638,31 +704,30 @@ void QodeAssistClient::displayRefactoringWidget(const RefactorResult &result) m_refactorWidgetHandler->showRefactorWidget( editorWidget, displayOriginal, displayRefactored, range, ctx.contextBefore, ctx.contextAfter); - + m_refactorWidgetHandler->setTextToApply(textToApply); } -void QodeAssistClient::applyRefactoringEdit(TextEditor::TextEditorWidget *editor, - const Utils::Text::Range &range, - const QString &text) +void CodeCompletionController::applyRefactoringEdit( + TextEditor::TextEditorWidget *editor, const Utils::Text::Range &range, const QString &text) { const QTextCursor startCursor = range.begin.toTextCursor(editor->document()); const QTextCursor endCursor = range.end.toTextCursor(editor->document()); const int startPos = startCursor.position(); const int endPos = endCursor.position(); - + QTextCursor editCursor(editor->document()); editCursor.beginEditBlock(); if (startPos == endPos) { bool isMultiline = text.contains('\n'); editCursor.setPosition(startPos); - + if (isMultiline) { editCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); editCursor.removeSelectedText(); } - + editCursor.insertText(text); } else { editCursor.setPosition(startPos); @@ -674,7 +739,7 @@ void QodeAssistClient::applyRefactoringEdit(TextEditor::TextEditorWidget *editor editCursor.endEditBlock(); } -void QodeAssistClient::handleAutoRequestTrigger(TextEditor::TextEditorWidget *widget) +void CodeCompletionController::handleAutoRequestTrigger(TextEditor::TextEditorWidget *widget) { const QTextCursor cursor = widget->textCursor(); const auto &settings = Settings::codeCompletionSettings(); @@ -690,11 +755,11 @@ void QodeAssistClient::handleAutoRequestTrigger(TextEditor::TextEditorWidget *wi scheduleRequest(widget); } -bool QodeAssistClient::eventFilter(QObject *watched, QEvent *event) +bool CodeCompletionController::eventFilter(QObject *watched, QEvent *event) { auto *editor = qobject_cast(watched); if (!editor) - return LanguageClient::Client::eventFilter(watched, event); + return QObject::eventFilter(watched, event); if (event->type() == QEvent::KeyPress) { auto *keyEvent = static_cast(event); @@ -719,7 +784,7 @@ bool QodeAssistClient::eventFilter(QObject *watched, QEvent *event) } } - return LanguageClient::Client::eventFilter(watched, event); + return QObject::eventFilter(watched, event); } } // namespace QodeAssist diff --git a/sources/completion/QodeAssistClient.hpp b/sources/completion/CodeCompletionController.hpp similarity index 50% rename from sources/completion/QodeAssistClient.hpp rename to sources/completion/CodeCompletionController.hpp index a8f6e9d..e617676 100644 --- a/sources/completion/QodeAssistClient.hpp +++ b/sources/completion/CodeCompletionController.hpp @@ -5,31 +5,36 @@ #pragma once +#include +#include #include +#include -#include "completion/LLMClientInterface.hpp" -#include "completion/LSPCompletion.hpp" +#include +#include + +#include "completion/CompletionEngine.hpp" +#include "context/ContextManager.hpp" #include "refactor/QuickRefactorHandler.hpp" #include "refactor/RefactorSuggestionHoverHandler.hpp" -#include "widgets/CompletionProgressHandler.hpp" #include "widgets/CompletionErrorHandler.hpp" -#include "widgets/EditorChatButtonHandler.hpp" +#include "widgets/CompletionProgressHandler.hpp" #include "widgets/RefactorWidgetHandler.hpp" -#include -#include "templates/IPromptProvider.hpp" -#include "providers/IProviderRegistry.hpp" namespace QodeAssist { -class QodeAssistClient : public LanguageClient::Client +class CodeCompletionController : public QObject { Q_OBJECT -public: - explicit QodeAssistClient(LLMClientInterface *clientInterface); - ~QodeAssistClient() override; - void openDocument(TextEditor::TextDocument *document) override; - bool canOpenProject(ProjectExplorer::Project *project) override; +public: + CodeCompletionController( + CompletionEngine *fimEngine, + CompletionEngine *agenticEngine, + CompletionEngine *acpEngine, + Context::ContextManager &contextManager, + QObject *parent = nullptr); + ~CodeCompletionController() override; void requestCompletions(TextEditor::TextEditorWidget *editor); void requestQuickRefactor( @@ -39,23 +44,45 @@ protected: bool eventFilter(QObject *watched, QEvent *event) override; private: + struct RunningRequest + { + quint64 id = 0; + int position = -1; + CompletionEngine *engine = nullptr; + }; + + void openDocument(TextEditor::TextDocument *document); + void closeDocument(TextEditor::TextDocument *document); void scheduleRequest(TextEditor::TextEditorWidget *editor); - void handleCompletions( - const GetCompletionRequest::Response &response, TextEditor::TextEditorWidget *editor); + void handleCompletionReady(quint64 requestId, const QString &text); + void handleCompletionFailed(quint64 requestId, const QString &error); + void applyCompletion( + TextEditor::TextEditorWidget *editor, const QString &text, int requestPosition); void cancelRunningRequest(TextEditor::TextEditorWidget *editor); + void dropRunningRequestById(quint64 requestId); bool isEnabled(ProjectExplorer::Project *project) const; + CompletionEngine *activeEngine() const; void setupConnections(); void cleanupConnections(); void handleRefactoringResult(const RefactorResult &result); void displayRefactoringSuggestion(const RefactorResult &result); void displayRefactoringWidget(const RefactorResult &result); - void applyRefactoringEdit(TextEditor::TextEditorWidget *editor, const Utils::Text::Range &range, const QString &text); + void applyRefactoringEdit( + TextEditor::TextEditorWidget *editor, const Utils::Text::Range &range, const QString &text); void handleAutoRequestTrigger(TextEditor::TextEditorWidget *widget); - QHash m_runningRequests; + CompletionEngine *m_fimEngine = nullptr; + CompletionEngine *m_agenticEngine = nullptr; + CompletionEngine *m_acpEngine = nullptr; + Context::ContextManager &m_contextManager; + quint64 m_lastRequestId = 0; + QHash m_runningRequests; + QHash> m_requestEditors; QHash m_scheduledRequests; + QHash m_documentConnections; + QList> m_hoverHandlerWidgets; QMetaObject::Connection m_documentOpenedConnection; QMetaObject::Connection m_documentClosedConnection; @@ -63,11 +90,9 @@ private: int m_recentCharCount; CompletionProgressHandler m_progressHandler; CompletionErrorHandler m_errorHandler; - EditorChatButtonHandler m_chatButtonHandler; QuickRefactorHandler *m_refactorHandler{nullptr}; RefactorSuggestionHoverHandler *m_refactorHoverHandler{nullptr}; RefactorWidgetHandler *m_refactorWidgetHandler{nullptr}; - LLMClientInterface *m_llmClient; }; } // namespace QodeAssist diff --git a/sources/completion/CompletionEngine.hpp b/sources/completion/CompletionEngine.hpp new file mode 100644 index 0000000..f9dc9d1 --- /dev/null +++ b/sources/completion/CompletionEngine.hpp @@ -0,0 +1,34 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include + +namespace QodeAssist { + +struct CompletionContext +{ + QString filePath; + int line = 0; + int column = 0; +}; + +class CompletionEngine : public QObject +{ + Q_OBJECT + +public: + using QObject::QObject; + + virtual void request(quint64 requestId, const CompletionContext &context) = 0; + virtual void cancel(quint64 requestId) = 0; + +signals: + void completionReady(quint64 requestId, const QString &text); + void completionFailed(quint64 requestId, const QString &error); +}; + +} // namespace QodeAssist diff --git a/sources/completion/FimCompletionEngine.cpp b/sources/completion/FimCompletionEngine.cpp new file mode 100644 index 0000000..1c762c8 --- /dev/null +++ b/sources/completion/FimCompletionEngine.cpp @@ -0,0 +1,292 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "completion/FimCompletionEngine.hpp" + +#include +#include +#include + +#include "completion/CodeHandler.hpp" +#include "context/DocumentContextReader.hpp" +#include "llmcore/ContextData.hpp" +#include "logger/Logger.hpp" +#include "settings/SettingsConstants.hpp" + +namespace QodeAssist { + +namespace { +constexpr int kSemanticContextTokenBudget = 1000; +} + +FimCompletionEngine::FimCompletionEngine( + const Settings::GeneralSettings &generalSettings, + const Settings::CodeCompletionSettings &completeSettings, + Providers::IProviderRegistry &providerRegistry, + Templates::IPromptProvider *promptProvider, + Context::IDocumentReader &documentReader, + Context::ContextManager &contextManager, + IRequestPerformanceLogger &performanceLogger, + Context::ICompletionEnricher *enricher, + QObject *parent) + : CompletionEngine(parent) + , m_generalSettings(generalSettings) + , m_completeSettings(completeSettings) + , m_providerRegistry(providerRegistry) + , m_promptProvider(promptProvider) + , m_documentReader(documentReader) + , m_contextManager(contextManager) + , m_performanceLogger(performanceLogger) + , m_enricher(enricher) +{ +} + +FimCompletionEngine::~FimCompletionEngine() +{ + const auto requests = m_activeRequests; + m_activeRequests.clear(); + for (auto it = requests.constBegin(); it != requests.constEnd(); ++it) { + if (it.value().provider) + it.value().provider->cancelRequest(it.key()); + } +} + +void FimCompletionEngine::request(quint64 requestId, const CompletionContext &context) +{ + auto documentInfo = m_documentReader.readDocument(context.filePath); + if (!documentInfo.document) { + const QString error = QString("Document is not available: %1").arg(context.filePath); + LOG_MESSAGE("Error: " + error); + emit completionFailed(requestId, error); + return; + } + + Context::DocumentContextReader + reader(documentInfo.document, documentInfo.mimeType, documentInfo.filePath); + auto updatedContext = reader.prepareContext(context.line, context.column, m_completeSettings); + + bool isPreset1Active = m_contextManager.isSpecifyCompletion(documentInfo); + + const auto providerName = !isPreset1Active ? m_generalSettings.ccProvider() + : m_generalSettings.ccPreset1Provider(); + const auto modelName = !isPreset1Active ? m_generalSettings.ccModel() + : m_generalSettings.ccPreset1Model(); + const auto url = !isPreset1Active ? m_generalSettings.ccUrl() + : m_generalSettings.ccPreset1Url(); + + const auto provider = m_providerRegistry.getProviderByName(providerName); + + if (!provider) { + const QString error = QString("No provider found with name: %1").arg(providerName); + LOG_MESSAGE(error); + emit completionFailed(requestId, error); + return; + } + + auto templateName = !isPreset1Active ? m_generalSettings.ccTemplate() + : m_generalSettings.ccPreset1Template(); + + auto promptTemplate = m_promptProvider->getTemplateByName(templateName); + + if (!promptTemplate) { + const QString error = QString("No template found with name: %1").arg(templateName); + LOG_MESSAGE(error); + emit completionFailed(requestId, error); + return; + } + + QJsonObject payload{{"model", modelName}, {"stream", true}}; + + const auto stopWords = QJsonArray::fromStringList(promptTemplate->stopWords()); + if (!stopWords.isEmpty()) + payload["stop"] = stopWords; + + QString systemPrompt; + if (m_completeSettings.useSystemPrompt()) + systemPrompt.append( + m_completeSettings.useUserMessageTemplateForCC() + && promptTemplate->type() == Templates::TemplateType::Chat + ? m_completeSettings.systemPromptForNonFimModels() + : m_completeSettings.systemPrompt()); + + if (updatedContext.fileContext.has_value()) + systemPrompt.append(updatedContext.fileContext.value()); + + if (m_completeSettings.useOpenFilesContext()) { + if (provider->providerID() == Providers::ProviderID::LlamaCpp) { + for (const auto openedFilePath : m_contextManager.openedFiles({context.filePath})) { + if (!updatedContext.filesMetadata) { + updatedContext.filesMetadata = QList(); + } + updatedContext.filesMetadata->append({openedFilePath.first, openedFilePath.second}); + } + } else { + systemPrompt.append(m_contextManager.openedFilesContext({context.filePath})); + } + } + + if (m_enricher && promptTemplate->type() == Templates::TemplateType::Chat + && m_completeSettings.completionMode.stringValue() + == QLatin1StringView(Constants::CC_MODE_FIM_WITH_CONTEXT)) { + const QString enrichment = m_enricher->enrichmentFor( + documentInfo, + updatedContext.prefix.value_or(""), + context.line, + context.column, + kSemanticContextTokenBudget); + if (!enrichment.isEmpty()) + systemPrompt.append(enrichment); + } + + updatedContext.systemPrompt = systemPrompt; + + if (promptTemplate->type() == Templates::TemplateType::Chat) { + QString userMessage; + if (m_completeSettings.useUserMessageTemplateForCC()) { + userMessage = m_completeSettings.processMessageToFIM( + updatedContext.prefix.value_or(""), updatedContext.suffix.value_or("")); + } else { + userMessage = updatedContext.prefix.value_or("") + updatedContext.suffix.value_or(""); + } + + QVector messages; + messages.append({"user", userMessage}); + updatedContext.history = messages; + } + + provider->prepareRequest( + payload, + promptTemplate, + updatedContext, + LLMCore::RequestType::CodeCompletion, + false, + false); + + connect( + provider->client(), + &::LLMQore::BaseClient::requestCompleted, + this, + &FimCompletionEngine::handleFullResponse, + Qt::UniqueConnection); + connect( + provider->client(), + &::LLMQore::BaseClient::requestFinalized, + this, + &FimCompletionEngine::handleRequestFinalized, + Qt::UniqueConnection); + connect( + provider->client(), + &::LLMQore::BaseClient::requestFailed, + this, + &FimCompletionEngine::handleRequestFailed, + Qt::UniqueConnection); + + provider->client()->setTransferTimeout( + static_cast(m_generalSettings.requestTimeout() * 1000)); + + auto llmRequestId + = provider->sendRequest(QUrl(url), payload, resolveEndpoint(promptTemplate, isPreset1Active)); + m_activeRequests.insert(llmRequestId, {requestId, context.filePath, provider}); + m_performanceLogger.startTimeMeasurement(llmRequestId); +} + +void FimCompletionEngine::cancel(quint64 requestId) +{ + for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) { + if (it.value().requestId != requestId) + continue; + const auto llmRequestId = it.key(); + auto *provider = it.value().provider; + m_activeRequests.erase(it); + m_performanceLogger.endTimeMeasurement(llmRequestId); + if (provider) + provider->cancelRequest(llmRequestId); + return; + } +} + +void FimCompletionEngine::handleFullResponse( + const ::LLMQore::RequestID &requestId, const QString &fullText) +{ + auto it = m_activeRequests.find(requestId); + if (it == m_activeRequests.end()) + return; + + const ActiveRequest active = it.value(); + m_activeRequests.erase(it); + m_performanceLogger.endTimeMeasurement(requestId); + + emit completionReady(active.requestId, postProcess(fullText, active.filePath)); +} + +void FimCompletionEngine::handleRequestFinalized( + const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info) +{ + if (!m_activeRequests.contains(requestId) || !info.usage) + return; + + const auto &u = *info.usage; + LOG_MESSAGE(QString("Completion usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5") + .arg(requestId) + .arg(u.promptTokens) + .arg(u.completionTokens) + .arg(u.cachedPromptTokens) + .arg(u.reasoningTokens)); +} + +void FimCompletionEngine::handleRequestFailed( + const ::LLMQore::RequestID &requestId, const QString &error) +{ + auto it = m_activeRequests.find(requestId); + if (it == m_activeRequests.end()) + return; + + const ActiveRequest active = it.value(); + m_activeRequests.erase(it); + m_performanceLogger.endTimeMeasurement(requestId); + + LOG_MESSAGE(QString("Request %1 failed: %2").arg(requestId, error)); + + emit completionFailed(active.requestId, error); +} + +QString FimCompletionEngine::resolveEndpoint( + Templates::PromptTemplate *promptTemplate, bool isPreset1Active) const +{ + const QString custom = isPreset1Active ? m_generalSettings.ccPreset1CustomEndpoint() + : m_generalSettings.ccCustomEndpoint(); + return !custom.isEmpty() ? custom : promptTemplate->endpoint(); +} + +QString FimCompletionEngine::postProcess(const QString &completion, const QString &filePath) const +{ + LOG_MESSAGE(QString("Completions before filter: \n%1").arg(completion)); + + const QString outputHandler = m_completeSettings.modelOutputHandler.stringValue(); + QString processedCompletion; + + if (outputHandler == "Raw text") { + processedCompletion = completion; + } else if (outputHandler == "Force processing") { + processedCompletion = CodeHandler::processText(completion, filePath); + } else { + processedCompletion = CodeHandler::hasCodeBlocks(completion) + ? CodeHandler::processText(completion, filePath) + : completion; + } + + if (processedCompletion.endsWith('\n')) { + QString withoutTrailing = processedCompletion.chopped(1); + if (!withoutTrailing.contains('\n')) { + LOG_MESSAGE(QString("Removed trailing newline from single-line completion")); + processedCompletion = withoutTrailing; + } + } + + LOG_MESSAGE(QString("Completion after filter: \n%1").arg(processedCompletion)); + + return processedCompletion; +} + +} // namespace QodeAssist diff --git a/sources/completion/FimCompletionEngine.hpp b/sources/completion/FimCompletionEngine.hpp new file mode 100644 index 0000000..7904601 --- /dev/null +++ b/sources/completion/FimCompletionEngine.hpp @@ -0,0 +1,69 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +#include "completion/CompletionEngine.hpp" +#include "context/CompletionContextEnricher.hpp" +#include "context/ContextManager.hpp" +#include "context/IDocumentReader.hpp" +#include "logger/IRequestPerformanceLogger.hpp" +#include "providers/IProviderRegistry.hpp" +#include "settings/CodeCompletionSettings.hpp" +#include "settings/GeneralSettings.hpp" +#include "templates/IPromptProvider.hpp" + +namespace QodeAssist { + +class FimCompletionEngine : public CompletionEngine +{ + Q_OBJECT + +public: + FimCompletionEngine( + const Settings::GeneralSettings &generalSettings, + const Settings::CodeCompletionSettings &completeSettings, + Providers::IProviderRegistry &providerRegistry, + Templates::IPromptProvider *promptProvider, + Context::IDocumentReader &documentReader, + Context::ContextManager &contextManager, + IRequestPerformanceLogger &performanceLogger, + Context::ICompletionEnricher *enricher = nullptr, + QObject *parent = nullptr); + ~FimCompletionEngine() override; + + void request(quint64 requestId, const CompletionContext &context) override; + void cancel(quint64 requestId) override; + +private slots: + void handleFullResponse(const ::LLMQore::RequestID &requestId, const QString &fullText); + void handleRequestFinalized( + const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info); + void handleRequestFailed(const ::LLMQore::RequestID &requestId, const QString &error); + +private: + struct ActiveRequest + { + quint64 requestId = 0; + QString filePath; + Providers::Provider *provider = nullptr; + }; + + QString resolveEndpoint(Templates::PromptTemplate *promptTemplate, bool isPreset1Active) const; + QString postProcess(const QString &completion, const QString &filePath) const; + + const Settings::GeneralSettings &m_generalSettings; + const Settings::CodeCompletionSettings &m_completeSettings; + Providers::IProviderRegistry &m_providerRegistry; + Templates::IPromptProvider *m_promptProvider = nullptr; + Context::IDocumentReader &m_documentReader; + Context::ContextManager &m_contextManager; + IRequestPerformanceLogger &m_performanceLogger; + Context::ICompletionEnricher *m_enricher = nullptr; + QHash<::LLMQore::RequestID, ActiveRequest> m_activeRequests; +}; + +} // namespace QodeAssist diff --git a/sources/completion/LLMClientInterface.cpp b/sources/completion/LLMClientInterface.cpp deleted file mode 100644 index fde5ca7..0000000 --- a/sources/completion/LLMClientInterface.cpp +++ /dev/null @@ -1,455 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "completion/LLMClientInterface.hpp" - -#include -#include -#include -#include - -#include "completion/CodeHandler.hpp" -#include "context/DocumentContextReader.hpp" -#include "context/Utils.hpp" -#include "logger/Logger.hpp" -#include "settings/CodeCompletionSettings.hpp" -#include "settings/GeneralSettings.hpp" - -namespace QodeAssist { - -LLMClientInterface::LLMClientInterface( - const Settings::GeneralSettings &generalSettings, - const Settings::CodeCompletionSettings &completeSettings, - Providers::IProviderRegistry &providerRegistry, - Templates::IPromptProvider *promptProvider, - Context::IDocumentReader &documentReader, - IRequestPerformanceLogger &performanceLogger) - : m_generalSettings(generalSettings) - , m_completeSettings(completeSettings) - , m_providerRegistry(providerRegistry) - , m_promptProvider(promptProvider) - , m_documentReader(documentReader) - , m_performanceLogger(performanceLogger) - , m_contextManager(new Context::ContextManager(this)) -{ -} - -LLMClientInterface::~LLMClientInterface() -{ - handleCancelRequest(); -} - -Utils::FilePath LLMClientInterface::serverDeviceTemplate() const -{ - return "QodeAssist"; -} - -void LLMClientInterface::startImpl() -{ - emit started(); -} - -void LLMClientInterface::handleFullResponse(const QString &requestId, const QString &fullText) -{ - auto it = m_activeRequests.find(requestId); - if (it == m_activeRequests.end()) - return; - - const RequestContext &ctx = it.value(); - sendCompletionToClient(fullText, ctx.originalRequest, true); - - m_activeRequests.erase(it); - m_performanceLogger.endTimeMeasurement(requestId); -} - -void LLMClientInterface::handleRequestFinalized( - const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info) -{ - if (!m_activeRequests.contains(requestId) || !info.usage) - return; - - const auto &u = *info.usage; - LOG_MESSAGE(QString("Completion usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5") - .arg(requestId) - .arg(u.promptTokens) - .arg(u.completionTokens) - .arg(u.cachedPromptTokens) - .arg(u.reasoningTokens)); -} - -void LLMClientInterface::handleRequestFailed(const QString &requestId, const QString &error) -{ - auto it = m_activeRequests.find(requestId); - if (it == m_activeRequests.end()) - return; - - const RequestContext &ctx = it.value(); - - LOG_MESSAGE(QString("Request %1 failed: %2").arg(requestId, error)); - - // Send LSP error response to client - QJsonObject response; - response["jsonrpc"] = "2.0"; - response[LanguageServerProtocol::idKey] = ctx.originalRequest["id"]; - - QJsonObject errorObject; - errorObject["code"] = -32603; // Internal error code - errorObject["message"] = error; - response["error"] = errorObject; - - emit messageReceived(LanguageServerProtocol::JsonRpcMessage(response)); - - m_activeRequests.erase(it); - m_performanceLogger.endTimeMeasurement(requestId); -} - -void LLMClientInterface::sendData(const QByteArray &data) -{ - QJsonDocument doc = QJsonDocument::fromJson(data); - if (!doc.isObject()) - return; - - QJsonObject request = doc.object(); - QString method = request["method"].toString(); - - if (method == "initialize") { - handleInitialize(request); - } else if (method == "initialized") { - // TODO make initilizied handler - } else if (method == "shutdown") { - handleShutdown(request); - } else if (method == "textDocument/didOpen") { - handleTextDocumentDidOpen(request); - } else if (method == "getCompletionsCycling") { - handleCompletion(request); - } else if (method == "$/cancelRequest") { - handleCancelRequest(); - } else if (method == "exit") { - // TODO make exit handler - } else { - LOG_MESSAGE(QString("Unknown method: %1").arg(method)); - } -} - -void LLMClientInterface::handleCancelRequest() -{ - QSet providers; - for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) { - if (it.value().provider) { - providers.insert(it.value().provider); - } - } - - for (auto *provider : providers) { - disconnect(provider->client(), nullptr, this, nullptr); - } - - for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) { - const RequestContext &ctx = it.value(); - if (ctx.provider) { - ctx.provider->cancelRequest(it.key()); - } - } - - m_activeRequests.clear(); - - LOG_MESSAGE("All requests cancelled and state cleared"); -} - -void LLMClientInterface::handleInitialize(const QJsonObject &request) -{ - QJsonObject response; - response["jsonrpc"] = "2.0"; - response["id"] = request["id"]; - - QJsonObject result; - QJsonObject capabilities; - capabilities["textDocumentSync"] = 1; - capabilities["completionProvider"] = QJsonObject{{"resolveProvider", false}}; - capabilities["hoverProvider"] = true; - result["capabilities"] = capabilities; - - QJsonObject serverInfo; - serverInfo["name"] = "QodeAssist LSP Server"; - serverInfo["version"] = "0.1"; - result["serverInfo"] = serverInfo; - - response["result"] = result; - - emit messageReceived(LanguageServerProtocol::JsonRpcMessage(response)); -} - -void LLMClientInterface::handleShutdown(const QJsonObject &request) -{ - QJsonObject response; - response["jsonrpc"] = "2.0"; - response["id"] = request["id"]; - response["result"] = QJsonValue(); - - emit messageReceived(LanguageServerProtocol::JsonRpcMessage(response)); -} - -void LLMClientInterface::handleTextDocumentDidOpen(const QJsonObject &request) {} - -void LLMClientInterface::handleInitialized(const QJsonObject &request) -{ - QJsonObject response; - response["jsonrpc"] = "2.0"; - response["method"] = "initialized"; - response["params"] = QJsonObject(); - - emit messageReceived(LanguageServerProtocol::JsonRpcMessage(response)); -} - -void LLMClientInterface::handleExit(const QJsonObject &request) -{ - emit finished(); -} - -void LLMClientInterface::sendErrorResponse(const QJsonObject &request, const QString &errorMessage) -{ - QJsonObject response; - response["jsonrpc"] = "2.0"; - response[LanguageServerProtocol::idKey] = request["id"]; - - QJsonObject errorObject; - errorObject["code"] = -32603; // Internal error code - errorObject["message"] = errorMessage; - response["error"] = errorObject; - - emit messageReceived(LanguageServerProtocol::JsonRpcMessage(response)); - - // End performance measurement if it was started - QString requestId = request["id"].toString(); - m_performanceLogger.endTimeMeasurement(requestId); -} - -void LLMClientInterface::handleCompletion(const QJsonObject &request) -{ - auto filePath = Context::extractFilePathFromRequest(request); - auto documentInfo = m_documentReader.readDocument(filePath); - if (!documentInfo.document) { - QString error = QString("Document is not available: %1").arg(filePath); - LOG_MESSAGE("Error: " + error); - sendErrorResponse(request, error); - return; - } - - auto updatedContext = prepareContext(request, documentInfo); - - bool isPreset1Active = m_contextManager->isSpecifyCompletion(documentInfo); - - const auto providerName = !isPreset1Active ? m_generalSettings.ccProvider() - : m_generalSettings.ccPreset1Provider(); - const auto modelName = !isPreset1Active ? m_generalSettings.ccModel() - : m_generalSettings.ccPreset1Model(); - const auto url = !isPreset1Active ? m_generalSettings.ccUrl() - : m_generalSettings.ccPreset1Url(); - - const auto provider = m_providerRegistry.getProviderByName(providerName); - - if (!provider) { - QString error = QString("No provider found with name: %1").arg(providerName); - LOG_MESSAGE(error); - sendErrorResponse(request, error); - return; - } - - auto templateName = !isPreset1Active ? m_generalSettings.ccTemplate() - : m_generalSettings.ccPreset1Template(); - - auto promptTemplate = m_promptProvider->getTemplateByName(templateName); - - if (!promptTemplate) { - QString error = QString("No template found with name: %1").arg(templateName); - LOG_MESSAGE(error); - sendErrorResponse(request, error); - return; - } - - QJsonObject payload{{"model", modelName}, {"stream", true}}; - - const auto stopWords = QJsonArray::fromStringList(promptTemplate->stopWords()); - if (!stopWords.isEmpty()) - payload["stop"] = stopWords; - - QString systemPrompt; - if (m_completeSettings.useSystemPrompt()) - systemPrompt.append( - m_completeSettings.useUserMessageTemplateForCC() - && promptTemplate->type() == Templates::TemplateType::Chat - ? m_completeSettings.systemPromptForNonFimModels() - : m_completeSettings.systemPrompt()); - - if (updatedContext.fileContext.has_value()) - systemPrompt.append(updatedContext.fileContext.value()); - - if (m_completeSettings.useOpenFilesContext()) { - if (provider->providerID() == Providers::ProviderID::LlamaCpp) { - for (const auto openedFilePath : m_contextManager->openedFiles({filePath})) { - if (!updatedContext.filesMetadata) { - updatedContext.filesMetadata = QList(); - } - updatedContext.filesMetadata->append({openedFilePath.first, openedFilePath.second}); - } - } else { - systemPrompt.append(m_contextManager->openedFilesContext({filePath})); - } - } - - updatedContext.systemPrompt = systemPrompt; - - if (promptTemplate->type() == Templates::TemplateType::Chat) { - QString userMessage; - if (m_completeSettings.useUserMessageTemplateForCC()) { - userMessage = m_completeSettings.processMessageToFIM( - updatedContext.prefix.value_or(""), updatedContext.suffix.value_or("")); - } else { - userMessage = updatedContext.prefix.value_or("") + updatedContext.suffix.value_or(""); - } - - // TODO refactor add message - QVector messages; - messages.append({"user", userMessage}); - updatedContext.history = messages; - } - - provider->prepareRequest( - payload, - promptTemplate, - updatedContext, - LLMCore::RequestType::CodeCompletion, - false, - false); - - connect( - provider->client(), - &::LLMQore::BaseClient::requestCompleted, - this, - &LLMClientInterface::handleFullResponse, - Qt::UniqueConnection); - connect( - provider->client(), - &::LLMQore::BaseClient::requestFinalized, - this, - &LLMClientInterface::handleRequestFinalized, - Qt::UniqueConnection); - connect( - provider->client(), - &::LLMQore::BaseClient::requestFailed, - this, - &LLMClientInterface::handleRequestFailed, - Qt::UniqueConnection); - - provider->client()->setTransferTimeout( - static_cast(m_generalSettings.requestTimeout() * 1000)); - - auto requestId - = provider->sendRequest(QUrl(url), payload, resolveEndpoint(promptTemplate, isPreset1Active)); - m_activeRequests[requestId] = {request, provider}; - m_performanceLogger.startTimeMeasurement(requestId); -} - -LLMCore::ContextData LLMClientInterface::prepareContext( - const QJsonObject &request, const Context::DocumentInfo &documentInfo) -{ - QJsonObject params = request["params"].toObject(); - QJsonObject doc = params["doc"].toObject(); - QJsonObject position = doc["position"].toObject(); - int cursorPosition = position["character"].toInt(); - int lineNumber = position["line"].toInt(); - - Context::DocumentContextReader - reader(documentInfo.document, documentInfo.mimeType, documentInfo.filePath); - return reader.prepareContext(lineNumber, cursorPosition, m_completeSettings); -} - -QString LLMClientInterface::resolveEndpoint( - Templates::PromptTemplate *promptTemplate, bool isLanguageSpecify) const -{ - const QString custom = isLanguageSpecify ? m_generalSettings.ccPreset1CustomEndpoint() - : m_generalSettings.ccCustomEndpoint(); - return !custom.isEmpty() ? custom : promptTemplate->endpoint(); -} - -Context::ContextManager *LLMClientInterface::contextManager() const -{ - return m_contextManager; -} - -void LLMClientInterface::sendCompletionToClient( - const QString &completion, const QJsonObject &request, bool isComplete) -{ - auto filePath = Context::extractFilePathFromRequest(request); - auto documentInfo = m_documentReader.readDocument(filePath); - bool isPreset1Active = m_contextManager->isSpecifyCompletion(documentInfo); - - auto templateName = !isPreset1Active ? m_generalSettings.ccTemplate() - : m_generalSettings.ccPreset1Template(); - - auto promptTemplate = m_promptProvider->getTemplateByName(templateName); - - QJsonObject position = request["params"].toObject()["doc"].toObject()["position"].toObject(); - - QJsonObject response; - response["jsonrpc"] = "2.0"; - response[LanguageServerProtocol::idKey] = request["id"]; - - QJsonObject result; - QJsonArray completions; - QJsonObject completionItem; - - LOG_MESSAGE(QString("Completions before filter: \n%1").arg(completion)); - - QString outputHandler = m_completeSettings.modelOutputHandler.stringValue(); - QString processedCompletion; - - if (outputHandler == "Raw text") { - processedCompletion = completion; - } else if (outputHandler == "Force processing") { - processedCompletion = CodeHandler::processText(completion, - Context::extractFilePathFromRequest(request)); - } else { // "Auto" - processedCompletion = CodeHandler::hasCodeBlocks(completion) - ? CodeHandler::processText(completion, - Context::extractFilePathFromRequest( - request)) - : completion; - } - - if (processedCompletion.endsWith('\n')) { - QString withoutTrailing = processedCompletion.chopped(1); - if (!withoutTrailing.contains('\n')) { - LOG_MESSAGE(QString("Removed trailing newline from single-line completion")); - processedCompletion = withoutTrailing; - } - } - - completionItem[LanguageServerProtocol::textKey] = processedCompletion; - - QJsonObject range; - range["start"] = position; - range["end"] = position; - - completionItem[LanguageServerProtocol::rangeKey] = range; - completionItem[LanguageServerProtocol::positionKey] = position; - completions.append(completionItem); - result["completions"] = completions; - result[LanguageServerProtocol::isIncompleteKey] = !isComplete; - response[LanguageServerProtocol::resultKey] = result; - - LOG_MESSAGE( - QString("Completions: \n%1") - .arg(QString::fromUtf8(QJsonDocument(completions).toJson(QJsonDocument::Indented)))); - - LOG_MESSAGE( - QString("Full response: \n%1") - .arg(QString::fromUtf8(QJsonDocument(response).toJson(QJsonDocument::Indented)))); - - QString requestId = request["id"].toString(); - m_performanceLogger.endTimeMeasurement(requestId); - emit messageReceived(LanguageServerProtocol::JsonRpcMessage(response)); -} - -} // namespace QodeAssist diff --git a/sources/completion/LLMClientInterface.hpp b/sources/completion/LLMClientInterface.hpp deleted file mode 100644 index 53f046f..0000000 --- a/sources/completion/LLMClientInterface.hpp +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include -#include - -#include -#include -#include -#include "llmcore/ContextData.hpp" -#include "templates/IPromptProvider.hpp" -#include "providers/IProviderRegistry.hpp" -#include -#include -#include - -class QNetworkReply; -class QNetworkAccessManager; - -namespace QodeAssist { - -class LLMClientInterface : public LanguageClient::BaseClientInterface -{ - Q_OBJECT - -public: - LLMClientInterface( - const Settings::GeneralSettings &generalSettings, - const Settings::CodeCompletionSettings &completeSettings, - Providers::IProviderRegistry &providerRegistry, - Templates::IPromptProvider *promptProvider, - Context::IDocumentReader &documentReader, - IRequestPerformanceLogger &performanceLogger); - ~LLMClientInterface() override; - - Utils::FilePath serverDeviceTemplate() const override; - - void sendCompletionToClient( - const QString &completion, const QJsonObject &request, bool isComplete); - - void handleCompletion(const QJsonObject &request); - - // exposed for tests - void sendData(const QByteArray &data) override; - - Context::ContextManager *contextManager() const; - -protected: - void startImpl() override; - -private slots: - void handleFullResponse(const QString &requestId, const QString &fullText); - void handleRequestFinalized( - const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info); - void handleRequestFailed(const QString &requestId, const QString &error); - -private: - void handleInitialize(const QJsonObject &request); - void handleShutdown(const QJsonObject &request); - void handleTextDocumentDidOpen(const QJsonObject &request); - void handleInitialized(const QJsonObject &request); - void handleExit(const QJsonObject &request); - void handleCancelRequest(); - void sendErrorResponse(const QJsonObject &request, const QString &errorMessage); - - struct RequestContext - { - QJsonObject originalRequest; - Providers::Provider *provider; - }; - - LLMCore::ContextData prepareContext( - const QJsonObject &request, const Context::DocumentInfo &documentInfo); - - QString resolveEndpoint( - Templates::PromptTemplate *promptTemplate, bool isLanguageSpecify) const; - - const Settings::CodeCompletionSettings &m_completeSettings; - const Settings::GeneralSettings &m_generalSettings; - Templates::IPromptProvider *m_promptProvider = nullptr; - Providers::IProviderRegistry &m_providerRegistry; - Context::IDocumentReader &m_documentReader; - IRequestPerformanceLogger &m_performanceLogger; - QElapsedTimer m_completionTimer; - Context::ContextManager *m_contextManager; - QHash m_activeRequests; -}; - -} // namespace QodeAssist diff --git a/sources/completion/LSPCompletion.hpp b/sources/completion/LSPCompletion.hpp deleted file mode 100644 index b8d2698..0000000 --- a/sources/completion/LSPCompletion.hpp +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright (C) 2023 The Qt Company Ltd. - * Copyright (C) 2024-2026 Petr Mironychev - * - * This file is part of QodeAssist. - * - * The Qt Company portions: - * SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0+ OR GPL-3.0 WITH Qt-GPL-exception-1.0 - * - * Petr Mironychev portions: - * QodeAssist is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * QodeAssist is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with QodeAssist. If not, see . - * - * Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - */ - -#pragma once - -#include -#include -#include - -namespace QodeAssist { - -class Completion : public LanguageServerProtocol::JsonObject -{ - static constexpr LanguageServerProtocol::Key displayTextKey{"displayText"}; - static constexpr LanguageServerProtocol::Key uuidKey{"uuid"}; - -public: - using JsonObject::JsonObject; - - QString displayText() const { return typedValue(displayTextKey); } - LanguageServerProtocol::Position position() const - { - return typedValue(LanguageServerProtocol::positionKey); - } - void setRange(const LanguageServerProtocol::Range &range) - { - insert(LanguageServerProtocol::rangeKey, range); - } - LanguageServerProtocol::Range range() const - { - return typedValue(LanguageServerProtocol::rangeKey); - } - QString text() const { return typedValue(LanguageServerProtocol::textKey); } - void setText(const QString &text) { insert(LanguageServerProtocol::textKey, text); } - QString uuid() const { return typedValue(uuidKey); } - - bool isValid() const override - { - return contains(LanguageServerProtocol::textKey) - && contains(LanguageServerProtocol::rangeKey) - && contains(LanguageServerProtocol::positionKey); - } -}; - -class GetCompletionParams : public LanguageServerProtocol::JsonObject -{ -public: - static constexpr LanguageServerProtocol::Key docKey{"doc"}; - - GetCompletionParams( - const LanguageServerProtocol::TextDocumentIdentifier &document, - int version, - const LanguageServerProtocol::Position &position) - { - setTextDocument(document); - setVersion(version); - setPosition(position); - } - using JsonObject::JsonObject; - - // The text document. - LanguageServerProtocol::TextDocumentIdentifier textDocument() const - { - return typedValue(docKey); - } - void setTextDocument(const LanguageServerProtocol::TextDocumentIdentifier &id) - { - insert(docKey, id); - } - - // The position inside the text document. - LanguageServerProtocol::Position position() const - { - return LanguageServerProtocol::fromJsonValue( - value(docKey).toObject().value(LanguageServerProtocol::positionKey)); - } - void setPosition(const LanguageServerProtocol::Position &position) - { - QJsonObject result = value(docKey).toObject(); - result[LanguageServerProtocol::positionKey] = (QJsonObject) position; - insert(docKey, result); - } - - // The version - int version() const { return typedValue(LanguageServerProtocol::versionKey); } - void setVersion(int version) - { - QJsonObject result = value(docKey).toObject(); - result[LanguageServerProtocol::versionKey] = version; - insert(docKey, result); - } - - bool isValid() const override - { - return contains(docKey) - && value(docKey).toObject().contains(LanguageServerProtocol::positionKey) - && value(docKey).toObject().contains(LanguageServerProtocol::versionKey); - } -}; - -class GetCompletionResponse : public LanguageServerProtocol::JsonObject -{ - static constexpr LanguageServerProtocol::Key completionKey{"completions"}; - -public: - using JsonObject::JsonObject; - - LanguageServerProtocol::LanguageClientArray completions() const - { - return clientArray(completionKey); - } -}; - -class GetCompletionRequest - : public LanguageServerProtocol::Request -{ -public: - explicit GetCompletionRequest(const GetCompletionParams ¶ms = {}) - : Request(methodName, params) - {} - using Request::Request; - constexpr static const LanguageServerProtocol::Key methodName{"getCompletionsCycling"}; -}; -} // namespace QodeAssist diff --git a/sources/context/CMakeLists.txt b/sources/context/CMakeLists.txt index 769bd57..ffa4079 100644 --- a/sources/context/CMakeLists.txt +++ b/sources/context/CMakeLists.txt @@ -2,6 +2,7 @@ add_library(Context STATIC DocumentContextReader.hpp DocumentContextReader.cpp FileEditManager.hpp FileEditManager.cpp ContextManager.hpp ContextManager.cpp + CompletionContextEnricher.hpp CompletionContextEnricher.cpp ContentFile.hpp DocumentReaderQtCreator.hpp IDocumentReader.hpp @@ -21,6 +22,9 @@ target_link_libraries(Context QtCreator::Utils QtCreator::ProjectExplorer PRIVATE + QtCreator::CPlusPlus + QtCreator::CppEditor + QtCreator::QmlJS QodeAssistLogger QodeAssistSettings ) diff --git a/sources/context/CompletionContextEnricher.cpp b/sources/context/CompletionContextEnricher.cpp new file mode 100644 index 0000000..0d7ce90 --- /dev/null +++ b/sources/context/CompletionContextEnricher.cpp @@ -0,0 +1,282 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "CompletionContextEnricher.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ProgrammingLanguage.hpp" +#include "TokenUtils.hpp" + +namespace QodeAssist::Context { + +namespace { + +constexpr int kMaxPrefixIdentifiers = 40; +constexpr int kMaxEnclosingMembers = 40; +constexpr int kMaxReferencedDeclarations = 60; +constexpr int kMaxIncludedDocuments = 30; +constexpr int kMaxSiblingComponents = 60; +constexpr int kMaxVisitedSymbols = 5000; +constexpr int kPrefixScanWindow = 4096; + +const QSet &cppKeywords() +{ + static const QSet keywords = { + "alignas", "alignof", "asm", "auto", "bool", "break", + "case", "catch", "char", "class", "const", "constexpr", + "consteval", "constinit","continue", "decltype", "default", "delete", + "do", "double", "else", "enum", "explicit", "export", + "extern", "false", "float", "for", "friend", "goto", + "if", "inline", "int", "long", "mutable", "namespace", + "new", "noexcept", "nullptr", "operator", "private", "protected", + "public", "register", "requires", "return", "short", "signed", + "sizeof", "static", "struct", "switch", "template", "this", + "throw", "true", "try", "typedef", "typeid", "typename", + "union", "unsigned", "using", "virtual", "void", "volatile", + "while", "override", "final", "co_await", "co_return", "co_yield"}; + return keywords; +} + +QString prettySymbol(const CPlusPlus::Overview &overview, CPlusPlus::Symbol *symbol) +{ + return overview.prettyType(symbol->type(), overview.prettyName(symbol->name())); +} + +QString cppEnclosingScopeSection( + const CPlusPlus::Document::Ptr &document, + const CPlusPlus::Overview &overview, + int line, + int column) +{ + CPlusPlus::Scope *scope = document->scopeAt(line + 1, column + 1); + for (CPlusPlus::Scope *current = scope; current; current = current->enclosingScope()) { + CPlusPlus::Class *klass = current->asClass(); + if (!klass || !klass->name()) + continue; + + QStringList members; + const int memberCount = qMin(klass->memberCount(), kMaxEnclosingMembers); + for (int i = 0; i < memberCount; ++i) { + CPlusPlus::Symbol *member = klass->memberAt(i); + if (!member || !member->name()) + continue; + members.append(" " + prettySymbol(overview, member)); + } + + if (members.isEmpty()) + continue; + + return QString("Enclosing class %1:\n%2") + .arg(overview.prettyName(klass->name()), members.join('\n')); + } + return {}; +} + +QString cppReferencedDeclarationsSection( + const CPlusPlus::Snapshot &snapshot, + const CPlusPlus::Document::Ptr &document, + const CPlusPlus::Overview &overview, + const QString &prefix) +{ + const QStringList identifiers + = identifiersNearCursor(prefix.right(kPrefixScanWindow), kMaxPrefixIdentifiers); + if (identifiers.isEmpty()) + return {}; + + QSet wanted; + for (const QString &identifier : identifiers) + wanted.insert(identifier.toUtf8()); + + QList documents{document}; + const Utils::FilePaths includes = document->includedFiles(); + for (const Utils::FilePath &include : includes) { + if (documents.size() > kMaxIncludedDocuments) + break; + if (auto included = snapshot.document(include)) + documents.append(included); + } + + QStringList declarations; + QSet seen; + int visited = 0; + + auto collect = [&](auto self, CPlusPlus::Scope *scope) -> void { + if (!scope || declarations.size() >= kMaxReferencedDeclarations + || visited >= kMaxVisitedSymbols) + return; + for (int i = 0; i < scope->memberCount(); ++i) { + if (declarations.size() >= kMaxReferencedDeclarations + || ++visited >= kMaxVisitedSymbols) + return; + CPlusPlus::Symbol *symbol = scope->memberAt(i); + if (!symbol || !symbol->name()) + continue; + if (const CPlusPlus::Identifier *nameId = symbol->name()->asNameId()) { + const QByteArray key + = QByteArray::fromRawData(nameId->chars(), int(nameId->size())); + if (wanted.contains(key)) { + const QString pretty = " " + prettySymbol(overview, symbol); + if (!seen.contains(pretty)) { + seen.insert(pretty); + declarations.append(pretty); + } + } + } + if (symbol->asFunction() || symbol->asBlock()) + continue; + if (auto nested = symbol->asScope()) + self(self, nested); + } + }; + + for (const auto &doc : std::as_const(documents)) { + if (doc->globalNamespace()) + collect(collect, doc->globalNamespace()); + } + + if (declarations.isEmpty()) + return {}; + + return QString("Declarations referenced near the cursor:\n%1").arg(declarations.join('\n')); +} + +QStringList cppEnrichment(const QString &filePath, const QString &prefix, int line, int column) +{ + auto *modelManager = CppEditor::CppModelManager::instance(); + if (!modelManager) + return {}; + + const CPlusPlus::Snapshot snapshot = modelManager->snapshot(); + const CPlusPlus::Document::Ptr document + = snapshot.document(Utils::FilePath::fromString(filePath)); + if (!document || !document->globalNamespace()) + return {}; + + CPlusPlus::Overview overview; + overview.showReturnTypes = true; + overview.showArgumentNames = true; + + QStringList sections; + const QString enclosing = cppEnclosingScopeSection(document, overview, line, column); + if (!enclosing.isEmpty()) + sections.append(enclosing); + const QString referenced + = cppReferencedDeclarationsSection(snapshot, document, overview, prefix); + if (!referenced.isEmpty()) + sections.append(referenced); + return sections; +} + +QStringList qmlEnrichment(const QString &filePath) +{ + auto *modelManager = QmlJS::ModelManagerInterface::instance(); + if (!modelManager) + return {}; + + const QmlJS::Snapshot snapshot = modelManager->snapshot(); + const Utils::FilePath path = Utils::FilePath::fromString(filePath); + const QmlJS::Document::Ptr document = snapshot.document(path); + const Utils::FilePath directory = document ? document->path() : path.parentDir(); + + QStringList components; + const QList siblings = snapshot.documentsInDirectory(directory); + for (const auto &sibling : siblings) { + if (components.size() >= kMaxSiblingComponents) + break; + if (!sibling || sibling->fileName() == path) + continue; + const QString component = sibling->componentName(); + if (!component.isEmpty() && !components.contains(component)) + components.append(component); + } + + if (components.isEmpty()) + return {}; + + components.sort(); + return QStringList{ + QString("QML components available in this directory: %1").arg(components.join(", "))}; +} + +} // anonymous namespace + +QString SemanticContextEnricher::enrichmentFor( + const DocumentInfo &documentInfo, const QString &prefix, int line, int column, int maxTokens) +{ + const ProgrammingLanguage language + = ProgrammingLanguageUtils::fromMimeType(documentInfo.mimeType); + + QStringList sections; + switch (language) { + case ProgrammingLanguage::Cpp: + sections = cppEnrichment(documentInfo.filePath, prefix, line, column); + break; + case ProgrammingLanguage::QML: + sections = qmlEnrichment(documentInfo.filePath); + break; + default: + return {}; + } + + const QString clamped = clampSectionsToTokenBudget(sections, maxTokens); + if (clamped.isEmpty()) + return {}; + + return QString("\n\n# Project code model context (may lag unsaved edits)\n%1\n").arg(clamped); +} + +QString clampSectionsToTokenBudget(const QStringList §ions, int maxTokens) +{ + QStringList kept; + int remaining = maxTokens; + for (const QString §ion : sections) { + if (section.isEmpty()) + continue; + const int cost = TokenUtils::estimateTokens(section); + if (cost > remaining) + continue; + kept.append(section); + remaining -= cost; + } + return kept.join("\n\n"); +} + +QStringList identifiersNearCursor(const QString &prefix, int maxIdentifiers) +{ + static const QRegularExpression identifierRegex( + QStringLiteral("[A-Za-z_][A-Za-z0-9_]{2,}")); + + QStringList result; + QSet seen; + + QRegularExpressionMatchIterator it = identifierRegex.globalMatch(prefix); + QStringList all; + while (it.hasNext()) + all.append(it.next().captured()); + + for (auto identifier = all.crbegin(); identifier != all.crend(); ++identifier) { + if (result.size() >= maxIdentifiers) + break; + if (seen.contains(*identifier) || cppKeywords().contains(*identifier)) + continue; + seen.insert(*identifier); + result.append(*identifier); + } + + return result; +} + +} // namespace QodeAssist::Context diff --git a/sources/context/CompletionContextEnricher.hpp b/sources/context/CompletionContextEnricher.hpp new file mode 100644 index 0000000..84e6009 --- /dev/null +++ b/sources/context/CompletionContextEnricher.hpp @@ -0,0 +1,42 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include + +#include "IDocumentReader.hpp" + +namespace QodeAssist::Context { + +class ICompletionEnricher +{ +public: + virtual ~ICompletionEnricher() = default; + + virtual QString enrichmentFor( + const DocumentInfo &documentInfo, + const QString &prefix, + int line, + int column, + int maxTokens) + = 0; +}; + +class SemanticContextEnricher : public ICompletionEnricher +{ +public: + QString enrichmentFor( + const DocumentInfo &documentInfo, + const QString &prefix, + int line, + int column, + int maxTokens) override; +}; + +QString clampSectionsToTokenBudget(const QStringList §ions, int maxTokens); +QStringList identifiersNearCursor(const QString &prefix, int maxIdentifiers); + +} // namespace QodeAssist::Context diff --git a/sources/context/DocumentContextReader.cpp b/sources/context/DocumentContextReader.cpp index ebd12d7..92f8a87 100644 --- a/sources/context/DocumentContextReader.cpp +++ b/sources/context/DocumentContextReader.cpp @@ -4,11 +4,11 @@ #include "DocumentContextReader.hpp" -#include #include #include #include "CodeCompletionSettings.hpp" +#include "ProgrammingLanguage.hpp" const QRegularExpression &getYearRegex() { @@ -108,7 +108,8 @@ QString DocumentContextReader::readWholeFileAfter(int lineNumber, int cursorPosi QString DocumentContextReader::getLanguageAndFileInfo() const { - QString language = LanguageServerProtocol::TextDocumentItem::mimeTypeToLanguageId(m_mimeType); + QString language + = ProgrammingLanguageUtils::toString(ProgrammingLanguageUtils::fromMimeType(m_mimeType)); QString fileExtension = QFileInfo(m_filePath).suffix(); return QString("Language: %1 (MIME: %2) filepath: %3(%4)\n\n") diff --git a/sources/mcp/CompletionMcpServer.cpp b/sources/mcp/CompletionMcpServer.cpp new file mode 100644 index 0000000..21f95c4 --- /dev/null +++ b/sources/mcp/CompletionMcpServer.cpp @@ -0,0 +1,109 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "CompletionMcpServer.hpp" + +#include +#include + +#include +#include +#include +#include +#include + +#include + +namespace QodeAssist::Mcp { + +CompletionMcpServer::CompletionMcpServer(QObject *parent) + : QObject(parent) +{} + +CompletionMcpServer::~CompletionMcpServer() +{ + stop(); +} + +QString CompletionMcpServer::serverName() +{ + return QStringLiteral("qodeassist-completion"); +} + +QString CompletionMcpServer::endpointUrl() const +{ + return m_runningPort == 0 + ? QString() + : QStringLiteral("http://127.0.0.1:%1/mcp/%2").arg(m_runningPort).arg(m_pathToken); +} + +QString CompletionMcpServer::start(::LLMQore::BaseTool *proposeTool) +{ + if (!proposeTool) + return {}; + + if (m_runningPort != 0) + return endpointUrl(); + + m_pathToken = QUuid::createUuid().toString(QUuid::WithoutBraces); + + ::LLMQore::Mcp::HttpServerConfig transportConfig; + transportConfig.address = QHostAddress::LocalHost; + transportConfig.port = 0; + transportConfig.path = QStringLiteral("/mcp/%1").arg(m_pathToken); + + m_transport = new ::LLMQore::Mcp::McpHttpServerTransport(transportConfig, this); + + ::LLMQore::Mcp::McpServerConfig serverConfig; + serverConfig.serverInfo = {"QodeAssist Completion", QStringLiteral(LLMQORE_VERSION_STRING)}; + serverConfig.instructions = tr( + "Inline code-completion channel. Call propose_completion exactly once with the completion " + "text for the requested cursor position; it is shown to the user as a ghost-text " + "suggestion. Do not edit files."); + + m_server = new ::LLMQore::Mcp::McpServer(m_transport.data(), serverConfig, this); + m_server->addTool(proposeTool); + + connect( + m_server, + &::LLMQore::Mcp::McpServer::toolCallReceived, + this, + [](const QString &toolName) { + LOG_MESSAGE( + QString("Completion MCP server: incoming tools/call for '%1'").arg(toolName)); + }); + + m_server->start(); + + if (!m_transport->isOpen()) { + LOG_MESSAGE("The QodeAssist completion MCP server could not bind a loopback port"); + stop(); + return {}; + } + + m_runningPort = m_transport->serverPort(); + LOG_MESSAGE( + QString("QodeAssist completion MCP server listening on %1").arg(endpointUrl())); + + return endpointUrl(); +} + +void CompletionMcpServer::stop() +{ + if (m_server) { + m_server->stop(); + m_server->deleteLater(); + m_server.clear(); + } + + if (m_transport) { + m_transport->deleteLater(); + m_transport.clear(); + } + + m_runningPort = 0; + m_pathToken.clear(); +} + +} // namespace QodeAssist::Mcp diff --git a/sources/mcp/CompletionMcpServer.hpp b/sources/mcp/CompletionMcpServer.hpp new file mode 100644 index 0000000..9383f5a --- /dev/null +++ b/sources/mcp/CompletionMcpServer.hpp @@ -0,0 +1,43 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include +#include + +namespace LLMQore { +class BaseTool; +namespace Mcp { +class McpServer; +class McpHttpServerTransport; +} // namespace Mcp +} // namespace LLMQore + +namespace QodeAssist::Mcp { + +class CompletionMcpServer : public QObject +{ + Q_OBJECT + +public: + explicit CompletionMcpServer(QObject *parent = nullptr); + ~CompletionMcpServer() override; + + QString start(::LLMQore::BaseTool *proposeTool); + void stop(); + + static QString serverName(); + QString endpointUrl() const; + quint16 runningPort() const { return m_runningPort; } + +private: + QPointer<::LLMQore::Mcp::McpServer> m_server; + QPointer<::LLMQore::Mcp::McpHttpServerTransport> m_transport; + QString m_pathToken; + quint16 m_runningPort = 0; +}; + +} // namespace QodeAssist::Mcp diff --git a/sources/plugin/qodeassist.cpp b/sources/plugin/qodeassist.cpp index f085b6d..615f49d 100644 --- a/sources/plugin/qodeassist.cpp +++ b/sources/plugin/qodeassist.cpp @@ -23,7 +23,6 @@ #include #include #include -#include #include #include @@ -35,7 +34,13 @@ #include #include "ConfigurationManager.hpp" -#include "completion/QodeAssistClient.hpp" +#include "completion/AcpCompletionEngine.hpp" +#include "completion/AgenticCompletionEngine.hpp" +#include "completion/CodeCompletionController.hpp" +#include "completion/FimCompletionEngine.hpp" +#include "context/CompletionContextEnricher.hpp" +#include "context/ContextManager.hpp" +#include "tools/ProposeCompletionTool.hpp" #include "UpdateStatusWidget.hpp" #include "plugin/Version.hpp" #include "chat/ChatEditor.hpp" @@ -63,6 +68,7 @@ #include #include +#include #include #include #include @@ -78,7 +84,28 @@ #include #ifdef WITH_TESTS -#include "QodeAssistTest.hpp" +#include "AcpChatBackendTest.hpp" +#include "AcpCompletionEngineTest.hpp" +#include "AgentBindingTest.hpp" +#include "AgentCatalogTest.hpp" +#include "AgentKnowledgeServerTest.hpp" +#include "AgenticCompletionEngineTest.hpp" +#include "BlockCodecTest.hpp" +#include "ChatFileStoreTest.hpp" +#include "ChatHistorySerializerTest.hpp" +#include "ChatViewTest.hpp" +#include "ClaudeCacheControlTest.hpp" +#include "CodeHandlerTest.hpp" +#include "ConversationCoordinatorTest.hpp" +#include "DocumentContextReaderTest.hpp" +#include "FimCompletionEngineTest.hpp" +#include "LlmChatBackendTest.hpp" +#include "LlmSuggestionTest.hpp" +#include "RowAudienceTest.hpp" +#include "SessionPermissionsTest.hpp" +#include "SessionTest.hpp" +#include "ToolsManagerGateTest.hpp" +#include "TurnContextTest.hpp" #endif using namespace Utils; @@ -101,8 +128,12 @@ public: ~QodeAssistPlugin() final { Chat::AttachmentStaging::cleanupGlobalIntermediateStorage(); - - delete m_qodeAssistClient; + + delete m_completionController; + delete m_fimEngine; + delete m_agenticEngine; + delete m_acpEngine; + delete m_completionContextManager; if (m_chatOutputPane) { delete m_chatOutputPane; } @@ -161,8 +192,8 @@ public: requestAction.setDefaultKeySequence(defaultShortcut); requestAction.addOnTriggered(this, [this] { if (auto editor = TextEditor::TextEditorWidget::currentTextEditorWidget()) { - if (m_qodeAssistClient && m_qodeAssistClient->reachable()) { - m_qodeAssistClient->requestCompletions(editor); + if (m_completionController) { + m_completionController->requestCompletions(editor); } else qWarning() << "The QodeAssist is not ready. Please check your connection and " "settings."; @@ -209,6 +240,8 @@ public: Settings::setupProjectPanel(); ConfigurationManager::instance().init(); + m_proposeCompletionTool = new Tools::ProposeCompletionTool(this); + m_mcpServerManager = new Mcp::McpServerManager(this); m_mcpServerManager->init(); @@ -226,7 +259,7 @@ public: quickRefactorAction.setIcon(QCODEASSIST_ICON.icon()); quickRefactorAction.addOnTriggered(this, [this] { if (auto editor = TextEditor::TextEditorWidget::currentTextEditorWidget()) { - if (m_qodeAssistClient && m_qodeAssistClient->reachable()) { + if (m_completionController) { QuickRefactorDialog dialog(Core::ICore::dialogParent(), m_lastRefactorInstructions); @@ -234,7 +267,7 @@ public: QString instructions = dialog.instructions(); if (!instructions.isEmpty()) { m_lastRefactorInstructions = instructions; - m_qodeAssistClient->requestQuickRefactor(editor, instructions); + m_completionController->requestQuickRefactor(editor, instructions); } } } else { @@ -319,36 +352,84 @@ public: Chat::AttachmentStaging::cleanupGlobalIntermediateStorage(); #ifdef WITH_TESTS - addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); + addTest(); #endif } void extensionsInitialized() final {} - void restartClient() + void restartCompletion() { - LanguageClient::LanguageClientManager::shutdownClient(m_qodeAssistClient); - m_qodeAssistClient = new QodeAssistClient(new LLMClientInterface( + delete m_completionController; + delete m_fimEngine; + delete m_agenticEngine; + delete m_acpEngine; + delete m_completionContextManager; + + m_completionContextManager = new Context::ContextManager(this); + m_fimEngine = new FimCompletionEngine( Settings::generalSettings(), Settings::codeCompletionSettings(), Providers::ProvidersManager::instance(), &m_promptProvider, m_documentReader, - m_performanceLogger)); + *m_completionContextManager, + m_performanceLogger, + &m_completionEnricher, + this); + m_agenticEngine = new AgenticCompletionEngine( + Settings::generalSettings(), + Settings::codeCompletionSettings(), + Providers::ProvidersManager::instance(), + &m_promptProvider, + m_documentReader, + m_performanceLogger, + this); + m_acpEngine = new AcpCompletionEngine( + Settings::codeCompletionSettings(), + Settings::generalSettings(), + [this](const QString &id) -> std::optional { + if (!m_agentCatalog) + return std::nullopt; + return m_agentCatalog->catalog().agent(id); + }, + m_documentReader, + m_proposeCompletionTool, + this); + m_completionController = new CodeCompletionController( + m_fimEngine, m_agenticEngine, m_acpEngine, *m_completionContextManager, this); } bool delayedInitialize() final { - restartClient(); + restartCompletion(); return true; } ShutdownFlag aboutToShutdown() final { - if (!m_qodeAssistClient) - return SynchronousShutdown; - connect(m_qodeAssistClient, &QObject::destroyed, this, &IPlugin::asynchronousShutdownFinished); - return AsynchronousShutdown; + return SynchronousShutdown; } private: @@ -491,7 +572,13 @@ private: m_statusWidget->showUpdateAvailable(info.version); } - QPointer m_qodeAssistClient; + QPointer m_completionController; + QPointer m_fimEngine; + QPointer m_agenticEngine; + QPointer m_acpEngine; + QPointer m_proposeCompletionTool; + QPointer m_completionContextManager; + Context::SemanticContextEnricher m_completionEnricher; Templates::PromptProviderFim m_promptProvider; Context::DocumentReaderQtCreator m_documentReader; RequestPerformanceLogger m_performanceLogger; diff --git a/sources/settings/CodeCompletionSettings.cpp b/sources/settings/CodeCompletionSettings.cpp index 45025c5..fce2e0b 100644 --- a/sources/settings/CodeCompletionSettings.cpp +++ b/sources/settings/CodeCompletionSettings.cpp @@ -60,17 +60,47 @@ CodeCompletionSettings::CodeCompletionSettings() Tr::tr("Hint-based: Shows a hint when typing, press Tab to request completion\n" "Automatic: Automatically requests completion after typing threshold")); - completionMode.setLabelText(Tr::tr("Completion mode:")); - completionMode.setSettingsKey(Constants::CC_COMPLETION_MODE); - completionMode.setDisplayStyle(Utils::SelectionAspect::DisplayStyle::ComboBox); - completionMode.addOption("Automatic"); - completionMode.addOption("Manual"); - completionMode.setDefaultValue("Automatic"); - completionMode.setToolTip( + triggerMode.setLabelText(Tr::tr("Trigger mode:")); + triggerMode.setSettingsKey(Constants::CC_TRIGGER_MODE); + triggerMode.setDisplayStyle(Utils::SelectionAspect::DisplayStyle::ComboBox); + triggerMode.addOption("Automatic"); + triggerMode.addOption("Manual"); + triggerMode.setDefaultValue("Automatic"); + triggerMode.setToolTip( Tr::tr("Automatic: requests completion while typing (with smart context gates).\n" "Manual: no auto-triggering; invoke via the 'Request QodeAssist Suggestion' " "shortcut (default Ctrl+Alt+Q, reconfigurable in Preferences > Keyboard).")); + completionMode.setLabelText(Tr::tr("Completion mode:")); + completionMode.setSettingsKey(Constants::CC_COMPLETION_MODE); + completionMode.setDisplayStyle(Utils::SelectionAspect::DisplayStyle::ComboBox); + completionMode.addOption(Constants::CC_MODE_CLASSIC_FIM); + completionMode.addOption(Constants::CC_MODE_FIM_WITH_CONTEXT); + completionMode.addOption(Constants::CC_MODE_AGENTIC_LOCAL); + completionMode.addOption(Constants::CC_MODE_AGENTIC_ACP); + completionMode.setDefaultValue(Constants::CC_MODE_CLASSIC_FIM); + completionMode.setToolTip( + Tr::tr("How a completion is produced.\n" + "Classic FIM: one fill-in-the-middle request to the configured provider.\n" + "FIM + context: the same request enriched with semantic context from the IDE " + "code model (C++/QML, chat-type templates only; the built-in model may lag " + "unsaved edits).\n" + "Agentic (local tools): a tool-capable model runs the in-process tool loop, " + "gathers its own context and proposes the completion via the " + "propose_completion tool (needs a tool-capable provider and a chat-type " + "template; multi-round and slow, so it is invoked only by the manual " + "'Request QodeAssist Suggestion' shortcut, never auto-triggered while typing).\n" + "Agentic (ACP agent): an external ACP agent (chosen below) calls " + "propose_completion over the QodeAssist MCP server; manual trigger only. " + "With no agent chosen the feature is disabled.")); + + completionAgentId.setSettingsKey(Constants::CC_ACP_AGENT_ID); + completionAgentId.setLabelText(Tr::tr("ACP completion agent id:")); + completionAgentId.setDisplayStyle(Utils::StringAspect::LineEditDisplay); + completionAgentId.setToolTip( + Tr::tr("The id of the ACP agent (from the Agents catalogue) used for the " + "'Agentic (ACP agent)' completion mode. Leave empty to keep that mode disabled.")); + smartContextTrigger.setSettingsKey(Constants::CC_SMART_CONTEXT_TRIGGER); smartContextTrigger.setLabelText(Tr::tr("Smart context-aware triggering")); smartContextTrigger.setDefaultValue(true); @@ -125,37 +155,6 @@ CodeCompletionSettings::CodeCompletionSettings() autoCompletionTypingInterval.setRange(500, 5000); autoCompletionTypingInterval.setDefaultValue(1200); - hintCharThreshold.setSettingsKey(Constants::CC_HINT_CHAR_THRESHOLD); - hintCharThreshold.setLabelText(Tr::tr("Hint shows after typing")); - hintCharThreshold.setToolTip( - Tr::tr("The number of characters that need to be typed before the hint widget appears " - "(only for Hint-based trigger mode).")); - hintCharThreshold.setRange(1, 10); - hintCharThreshold.setDefaultValue(3); - - hintHideTimeout.setSettingsKey(Constants::CC_HINT_HIDE_TIMEOUT); - hintHideTimeout.setLabelText(Tr::tr("Hint auto-hide timeout (ms)")); - hintHideTimeout.setToolTip( - Tr::tr("Time in milliseconds after which the hint widget will automatically hide " - "(only for Hint-based trigger mode).")); - hintHideTimeout.setRange(500, 10000); - hintHideTimeout.setDefaultValue(4000); - - hintTriggerKey.setLabelText(Tr::tr("Trigger key:")); - hintTriggerKey.setSettingsKey(Constants::CC_HINT_TRIGGER_KEY); - hintTriggerKey.setDisplayStyle(Utils::SelectionAspect::DisplayStyle::ComboBox); - hintTriggerKey.addOption("Space"); - hintTriggerKey.addOption("Ctrl+Space"); - hintTriggerKey.addOption("Alt+Space"); - hintTriggerKey.addOption("Ctrl+Enter"); - hintTriggerKey.addOption("Tab"); - hintTriggerKey.addOption("Enter"); - hintTriggerKey.setDefaultValue("Tab"); - hintTriggerKey.setToolTip( - Tr::tr("Key to press for requesting completion when hint is visible.\n" - "Space is recommended as least conflicting with context menu.\n" - "(Only for Hint-based trigger mode)")); - ignoreWhitespaceInCharCount.setSettingsKey(Constants::CC_IGNORE_WHITESPACE_IN_CHAR_COUNT); ignoreWhitespaceInCharCount.setLabelText( Tr::tr("Ignore spaces and tabs in character count")); @@ -387,7 +386,9 @@ CodeCompletionSettings::CodeCompletionSettings() autoCompletion, multiLineCompletion, Row{modelOutputHandler, Stretch{1}}, + Row{triggerMode, Stretch{1}}, Row{completionMode, Stretch{1}}, + Row{completionAgentId, Stretch{1}}, showProgressWidget, useOpenFilesContext, respectQtcPopup, @@ -488,13 +489,12 @@ void CodeCompletionSettings::resetSettingsToDefaults() resetAspect(useOpenFilesContext); resetAspect(modelOutputHandler); resetAspect(completionTriggerMode); + resetAspect(triggerMode); resetAspect(completionMode); + resetAspect(completionAgentId); resetAspect(smartContextTrigger); resetAspect(respectQtcPopup); resetAspect(cancelOnInput); - resetAspect(hintCharThreshold); - resetAspect(hintHideTimeout); - resetAspect(hintTriggerKey); resetAspect(ignoreWhitespaceInCharCount); resetAspect(abortAssistOnRequest); writeSettings(); @@ -504,16 +504,19 @@ void CodeCompletionSettings::resetSettingsToDefaults() void CodeCompletionSettings::migrateCompletionMode() { auto *qtcSettings = Core::ICore::settings(); - if (!qtcSettings || qtcSettings->contains(Constants::CC_COMPLETION_MODE)) + if (!qtcSettings || !qtcSettings->contains(Constants::CC_COMPLETION_TRIGGER_MODE)) return; - const QString oldMode = completionTriggerMode.stringValue(); - if (oldMode.startsWith("Hint")) - completionMode.setStringValue("Manual"); - else - completionMode.setStringValue("Automatic"); + if (!qtcSettings->contains(Constants::CC_TRIGGER_MODE)) { + const QString oldMode = completionTriggerMode.stringValue(); + if (oldMode.startsWith("Hint")) + triggerMode.setStringValue("Manual"); + else + triggerMode.setStringValue("Automatic"); + } writeSettings(); + qtcSettings->remove(Constants::CC_COMPLETION_TRIGGER_MODE); } QString CodeCompletionSettings::processMessageToFIM(const QString &prefix, const QString &suffix) const diff --git a/sources/settings/CodeCompletionSettings.hpp b/sources/settings/CodeCompletionSettings.hpp index dfa1d2d..9cfcebe 100644 --- a/sources/settings/CodeCompletionSettings.hpp +++ b/sources/settings/CodeCompletionSettings.hpp @@ -22,7 +22,9 @@ public: Utils::BoolAspect multiLineCompletion{this}; Utils::SelectionAspect modelOutputHandler{this}; Utils::SelectionAspect completionTriggerMode{this}; + Utils::SelectionAspect triggerMode{this}; Utils::SelectionAspect completionMode{this}; + Utils::StringAspect completionAgentId{this}; Utils::BoolAspect smartContextTrigger{this}; Utils::BoolAspect respectQtcPopup{this}; Utils::BoolAspect cancelOnInput{this}; @@ -30,9 +32,6 @@ public: Utils::IntegerAspect startSuggestionTimer{this}; Utils::IntegerAspect autoCompletionCharThreshold{this}; Utils::IntegerAspect autoCompletionTypingInterval{this}; - Utils::IntegerAspect hintCharThreshold{this}; - Utils::IntegerAspect hintHideTimeout{this}; - Utils::SelectionAspect hintTriggerKey{this}; Utils::BoolAspect ignoreWhitespaceInCharCount{this}; Utils::StringListAspect customLanguages{this}; diff --git a/sources/settings/SettingsConstants.hpp b/sources/settings/SettingsConstants.hpp index 04a2f8c..664e737 100644 --- a/sources/settings/SettingsConstants.hpp +++ b/sources/settings/SettingsConstants.hpp @@ -67,13 +67,16 @@ const char СС_START_SUGGESTION_TIMER[] = "QodeAssist.startSuggestionTimer"; const char СС_AUTO_COMPLETION_CHAR_THRESHOLD[] = "QodeAssist.autoCompletionCharThreshold"; const char СС_AUTO_COMPLETION_TYPING_INTERVAL[] = "QodeAssist.autoCompletionTypingInterval"; const char CC_COMPLETION_TRIGGER_MODE[] = "QodeAssist.ccCompletionTriggerMode"; -const char CC_COMPLETION_MODE[] = "QodeAssist.ccCompletionMode"; +const char CC_TRIGGER_MODE[] = "QodeAssist.ccCompletionMode"; +const char CC_COMPLETION_MODE[] = "QodeAssist.ccCompletionProduceMode"; +const char CC_MODE_CLASSIC_FIM[] = "Classic FIM"; +const char CC_MODE_FIM_WITH_CONTEXT[] = "FIM + context"; +const char CC_MODE_AGENTIC_LOCAL[] = "Agentic (local tools)"; +const char CC_MODE_AGENTIC_ACP[] = "Agentic (ACP agent)"; +const char CC_ACP_AGENT_ID[] = "QodeAssist.ccAcpAgentId"; const char CC_SMART_CONTEXT_TRIGGER[] = "QodeAssist.ccSmartContextTrigger"; const char CC_RESPECT_QTC_POPUP[] = "QodeAssist.ccRespectQtcPopup"; const char CC_CANCEL_ON_INPUT[] = "QodeAssist.ccCancelOnInput"; -const char CC_HINT_CHAR_THRESHOLD[] = "QodeAssist.ccHintCharThreshold"; -const char CC_HINT_HIDE_TIMEOUT[] = "QodeAssist.ccHintHideTimeout"; -const char CC_HINT_TRIGGER_KEY[] = "QodeAssist.ccHintTriggerKey"; const char CC_ABORT_ASSIST_ON_REQUEST[] = "QodeAssist.ccAbortAssistOnRequest"; const char CC_IGNORE_WHITESPACE_IN_CHAR_COUNT[] = "QodeAssist.ccIgnoreWhitespaceInCharCount"; const char MAX_FILE_THRESHOLD[] = "QodeAssist.maxFileThreshold"; diff --git a/sources/tools/ProposeCompletionTool.cpp b/sources/tools/ProposeCompletionTool.cpp new file mode 100644 index 0000000..a896fab --- /dev/null +++ b/sources/tools/ProposeCompletionTool.cpp @@ -0,0 +1,104 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "tools/ProposeCompletionTool.hpp" + +#include +#include +#include + +#include + +namespace QodeAssist::Tools { + +ProposeCompletionTool::ProposeCompletionTool(QObject *parent) + : ::LLMQore::BaseTool(parent) +{ +} + +QString ProposeCompletionTool::toolId() +{ + return QStringLiteral("propose_completion"); +} + +QString ProposeCompletionTool::id() const +{ + return toolId(); +} + +QString ProposeCompletionTool::displayName() const +{ + return tr("Propose Completion"); +} + +QString ProposeCompletionTool::description() const +{ + return tr( + "Propose the code completion text for the current cursor position. Pass the actual " + "code to insert in the `text` argument — it must be the real, non-empty continuation, " + "never an empty string. The text is shown to the user as an inline ghost-text " + "suggestion they accept with Tab. Call this exactly once, after you have worked out " + "the completion; it inserts nothing by itself."); +} + +QJsonObject ProposeCompletionTool::parametersSchema() const +{ + return QJsonObject{ + {"type", "object"}, + {"properties", + QJsonObject{ + {"file", + QJsonObject{ + {"type", "string"}, + {"description", "Absolute path of the file the completion is for."}}}, + {"line", + QJsonObject{ + {"type", "integer"}, + {"description", "Zero-based line of the cursor position."}}}, + {"column", + QJsonObject{ + {"type", "integer"}, + {"description", "Zero-based column of the cursor position."}}}, + {"text", + QJsonObject{ + {"type", "string"}, + {"description", + "The actual code to insert at the cursor position, verbatim. Must be the " + "real, non-empty continuation — never an empty string."}}}}}, + {"required", QJsonArray{"text"}}}; +} + +::LLMQore::ToolSafety ProposeCompletionTool::safety() const +{ + return ::LLMQore::ToolSafety::ReadOnly; +} + +QFuture<::LLMQore::ToolResult> ProposeCompletionTool::executeAsync(const QJsonObject &input) +{ + LOG_MESSAGE( + QString("propose_completion invoked with input: %1") + .arg(QString::fromUtf8(QJsonDocument(input).toJson(QJsonDocument::Compact)))); + + const QString filePath = input.value("file").toString(); + const QString text = input.value("text").toString(); + + if (text.isEmpty()) { + LOG_MESSAGE(QStringLiteral("propose_completion rejected: text is empty")); + return QtFuture::makeReadyValueFuture( + ::LLMQore::ToolResult::error(QStringLiteral( + "The 'text' argument was empty. Work out the actual code to insert at the cursor " + "and call propose_completion again with that non-empty completion text."))); + } + + const int line = input.value("line").toInt(-1); + const int column = input.value("column").toInt(-1); + + emit completionProposed(filePath, line, column, text); + + return QtFuture::makeReadyValueFuture( + ::LLMQore::ToolResult::text( + QStringLiteral("Completion proposal shown to the user as a suggestion."))); +} + +} // namespace QodeAssist::Tools diff --git a/sources/tools/ProposeCompletionTool.hpp b/sources/tools/ProposeCompletionTool.hpp new file mode 100644 index 0000000..9edc180 --- /dev/null +++ b/sources/tools/ProposeCompletionTool.hpp @@ -0,0 +1,32 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +namespace QodeAssist::Tools { + +class ProposeCompletionTool : public ::LLMQore::BaseTool +{ + Q_OBJECT + +public: + explicit ProposeCompletionTool(QObject *parent = nullptr); + + static QString toolId(); + + QString id() const override; + QString displayName() const override; + QString description() const override; + QJsonObject parametersSchema() const override; + ::LLMQore::ToolSafety safety() const override; + + QFuture<::LLMQore::ToolResult> executeAsync(const QJsonObject &input = QJsonObject()) override; + +signals: + void completionProposed(const QString &filePath, int line, int column, const QString &text); +}; + +} // namespace QodeAssist::Tools diff --git a/sources/widgets/CompletionHintHandler.cpp b/sources/widgets/CompletionHintHandler.cpp deleted file mode 100644 index 0a3203c..0000000 --- a/sources/widgets/CompletionHintHandler.cpp +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "CompletionHintHandler.hpp" -#include "CompletionHintWidget.hpp" - -#include - -namespace QodeAssist { - -CompletionHintHandler::CompletionHintHandler() = default; - -CompletionHintHandler::~CompletionHintHandler() -{ - hideHint(); -} - -void CompletionHintHandler::showHint(TextEditor::TextEditorWidget *widget, QPoint position, int fontSize) -{ - if (!widget) { - return; - } - - if (!m_hintWidget) { - m_hintWidget = new CompletionHintWidget(widget, fontSize); - } - - m_hintWidget->move(position); - m_hintWidget->show(); - m_hintWidget->raise(); -} - -void CompletionHintHandler::hideHint() -{ - if (m_hintWidget) { - m_hintWidget->deleteLater(); - m_hintWidget = nullptr; - } -} - -bool CompletionHintHandler::isHintVisible() const -{ - return !m_hintWidget.isNull() && m_hintWidget->isVisible(); -} - -void CompletionHintHandler::updateHintPosition(TextEditor::TextEditorWidget *widget, QPoint position) -{ - if (!widget || !m_hintWidget) { - return; - } - - m_hintWidget->move(position); -} - -} // namespace QodeAssist - diff --git a/sources/widgets/CompletionHintHandler.hpp b/sources/widgets/CompletionHintHandler.hpp deleted file mode 100644 index eb72781..0000000 --- a/sources/widgets/CompletionHintHandler.hpp +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -namespace TextEditor { -class TextEditorWidget; -} - -namespace QodeAssist { - -class CompletionHintWidget; - -class CompletionHintHandler -{ -public: - CompletionHintHandler(); - ~CompletionHintHandler(); - - void showHint(TextEditor::TextEditorWidget *widget, QPoint position, int fontSize); - void hideHint(); - bool isHintVisible() const; - void updateHintPosition(TextEditor::TextEditorWidget *widget, QPoint position); - -private: - QPointer m_hintWidget; -}; - -} // namespace QodeAssist - diff --git a/sources/widgets/CompletionHintWidget.cpp b/sources/widgets/CompletionHintWidget.cpp deleted file mode 100644 index dbe97c0..0000000 --- a/sources/widgets/CompletionHintWidget.cpp +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "CompletionHintWidget.hpp" - -#include - -#include - -namespace QodeAssist { - -CompletionHintWidget::CompletionHintWidget(QWidget *parent, int fontSize) - : QWidget(parent) - , m_isHovered(false) -{ - m_accentColor = Utils::creatorTheme()->color(Utils::Theme::TextColorNormal); - - setMouseTracking(true); - setFocusPolicy(Qt::NoFocus); - setAttribute(Qt::WA_TranslucentBackground); - - int triangleSize = qMax(6, fontSize / 2); - setFixedSize(triangleSize, triangleSize); -} - -void CompletionHintWidget::paintEvent(QPaintEvent *event) -{ - Q_UNUSED(event); - - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing); - - QColor triangleColor = m_accentColor; - triangleColor.setAlpha(m_isHovered ? 255 : 200); - - painter.setPen(Qt::NoPen); - painter.setBrush(triangleColor); - - QPolygonF triangle; - int w = width(); - int h = height(); - - triangle << QPointF(0, 0) - << QPointF(0, h) - << QPointF(w, h / 2.0); - - painter.drawPolygon(triangle); -} - -void CompletionHintWidget::enterEvent(QEnterEvent *event) -{ - Q_UNUSED(event); - m_isHovered = true; - setCursor(Qt::PointingHandCursor); - update(); -} - -void CompletionHintWidget::leaveEvent(QEvent *event) -{ - Q_UNUSED(event); - m_isHovered = false; - setCursor(Qt::ArrowCursor); - update(); -} - -} // namespace QodeAssist - diff --git a/sources/widgets/CompletionHintWidget.hpp b/sources/widgets/CompletionHintWidget.hpp deleted file mode 100644 index 1df948c..0000000 --- a/sources/widgets/CompletionHintWidget.hpp +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -namespace QodeAssist { - -class CompletionHintWidget : public QWidget -{ - Q_OBJECT -public: - explicit CompletionHintWidget(QWidget *parent = nullptr, int fontSize = 12); - ~CompletionHintWidget() override = default; - -protected: - void paintEvent(QPaintEvent *event) override; - void enterEvent(QEnterEvent *event) override; - void leaveEvent(QEvent *event) override; - -private: - QColor m_accentColor; - bool m_isHovered; -}; - -} // namespace QodeAssist - diff --git a/sources/widgets/EditorChatButton.cpp b/sources/widgets/EditorChatButton.cpp deleted file mode 100644 index 2ebc788..0000000 --- a/sources/widgets/EditorChatButton.cpp +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "EditorChatButton.hpp" - -#include -#include -#include - -namespace QodeAssist { - -EditorChatButton::EditorChatButton(QWidget *parent) - : QWidget(parent) -{ - m_textColor = Utils::creatorTheme()->color(Utils::Theme::TextColorNormal); - m_backgroundColor = Utils::creatorTheme()->color(Utils::Theme::BackgroundColorNormal); - - m_logoPixmap = QPixmap(":/resources/images/qoderassist-icon.png"); - - if (!m_logoPixmap.isNull()) { - QImage image = m_logoPixmap.toImage(); - image = image.convertToFormat(QImage::Format_ARGB32); - - for (int y = 0; y < image.height(); ++y) { - for (int x = 0; x < image.width(); ++x) { - QColor pixelColor = QColor::fromRgba(image.pixel(x, y)); - - int brightness = (pixelColor.red() + pixelColor.green() + pixelColor.blue()) / 3; - - if (brightness > 200) { - pixelColor.setAlpha(0); - image.setPixelColor(x, y, pixelColor); - } else if (pixelColor.alpha() > 0) { - int alpha = pixelColor.alpha(); - pixelColor = m_textColor; - pixelColor.setAlpha(alpha); - image.setPixelColor(x, y, pixelColor); - } - } - } - - m_logoPixmap = QPixmap::fromImage(image); - m_logoPixmap = m_logoPixmap.scaled(24, 24, Qt::KeepAspectRatio, Qt::SmoothTransformation); - } - - setFixedSize(40, 40); - setCursor(Qt::PointingHandCursor); - setToolTip(tr("Open QodeAssist Chat")); -} - -EditorChatButton::~EditorChatButton() = default; - -void EditorChatButton::paintEvent(QPaintEvent *) -{ - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing); - - QColor bgColor = m_backgroundColor; - if (m_isPressed) { - bgColor = bgColor.darker(120); - } else if (m_isHovered) { - bgColor = bgColor.lighter(110); - } - painter.fillRect(rect(), bgColor); - - QRect buttonRect = rect().adjusted(4, 4, -4, -4); - painter.setPen(Qt::NoPen); - QColor buttonBgColor - = m_isPressed ? Utils::creatorTheme()->color(Utils::Theme::BackgroundColorHover).darker(110) - : Utils::creatorTheme()->color(Utils::Theme::BackgroundColorHover); - - if (m_isHovered) { - buttonBgColor = buttonBgColor.lighter(110); - } - painter.setBrush(buttonBgColor); - painter.drawEllipse(buttonRect); - - if (!m_logoPixmap.isNull()) { - QRect logoRect( - (width() - m_logoPixmap.width()) / 2, - (height() - m_logoPixmap.height()) / 2, - m_logoPixmap.width(), - m_logoPixmap.height()); - painter.drawPixmap(logoRect, m_logoPixmap); - } -} - -void EditorChatButton::mousePressEvent(QMouseEvent *event) -{ - if (event->button() == Qt::LeftButton) { - m_isPressed = true; - update(); - } - QWidget::mousePressEvent(event); -} - -void EditorChatButton::mouseReleaseEvent(QMouseEvent *event) -{ - if (event->button() == Qt::LeftButton && m_isPressed) { - m_isPressed = false; - update(); - if (rect().contains(event->pos())) { - emit clicked(); - } - } - QWidget::mouseReleaseEvent(event); -} - -void EditorChatButton::enterEvent(QEnterEvent *event) -{ - m_isHovered = true; - update(); - QWidget::enterEvent(event); -} - -void EditorChatButton::leaveEvent(QEvent *event) -{ - m_isHovered = false; - m_isPressed = false; - update(); - QWidget::leaveEvent(event); -} - -} // namespace QodeAssist diff --git a/sources/widgets/EditorChatButton.hpp b/sources/widgets/EditorChatButton.hpp deleted file mode 100644 index 6f7a671..0000000 --- a/sources/widgets/EditorChatButton.hpp +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include -#include - -namespace QodeAssist { - -class EditorChatButton : public QWidget -{ - Q_OBJECT -public: - explicit EditorChatButton(QWidget *parent = nullptr); - ~EditorChatButton() override; - -signals: - void clicked(); - -protected: - void paintEvent(QPaintEvent *event) override; - void mousePressEvent(QMouseEvent *event) override; - void mouseReleaseEvent(QMouseEvent *event) override; - void enterEvent(QEnterEvent *event) override; - void leaveEvent(QEvent *event) override; - -private: - QPixmap m_logoPixmap; - QColor m_textColor; - QColor m_backgroundColor; - bool m_isPressed = false; - bool m_isHovered = false; -}; - -} // namespace QodeAssist diff --git a/sources/widgets/EditorChatButtonHandler.cpp b/sources/widgets/EditorChatButtonHandler.cpp deleted file mode 100644 index cb54d76..0000000 --- a/sources/widgets/EditorChatButtonHandler.cpp +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "EditorChatButtonHandler.hpp" -#include "EditorChatButton.hpp" - -#include -#include -#include - -namespace QodeAssist { - -EditorChatButtonHandler::~EditorChatButtonHandler() -{ - delete m_chatButton; -} - -void EditorChatButtonHandler::showButton(TextEditor::TextEditorWidget *widget) -{ - if (!widget) - return; - - m_widget = widget; - - identifyMatch(widget, widget->textCursor().position(), [this](auto priority) { - if (priority != Priority_None && m_widget) { - const QTextCursor cursor = m_widget->textCursor(); - const QRect selectionRect = m_widget->cursorRect(cursor); - m_cursorPosition = m_widget->viewport()->mapToGlobal(selectionRect.topLeft()) - - Utils::ToolTip::offsetFromPosition(); - operateTooltip(m_widget, m_cursorPosition); - } - }); -} - -void EditorChatButtonHandler::hideButton() -{ - Utils::ToolTip::hide(); -} - -void EditorChatButtonHandler::identifyMatch( - TextEditor::TextEditorWidget *editorWidget, int pos, ReportPriority report) -{ - if (!editorWidget) { - report(Priority_None); - return; - } - - report(Priority_Tooltip); -} - -void EditorChatButtonHandler::operateTooltip( - TextEditor::TextEditorWidget *editorWidget, const QPoint &point) -{ - if (!editorWidget) - return; - - if (!Utils::ToolTip::isVisible()) { - m_chatButton = new EditorChatButton(editorWidget); - m_buttonHeight = m_chatButton->height(); - - QPoint showPoint = point; - showPoint.ry() -= m_buttonHeight; - - Utils::ToolTip::show(showPoint, m_chatButton, editorWidget); - } else { - QPoint showPoint = point; - showPoint.ry() -= m_buttonHeight; - Utils::ToolTip::move(showPoint); - } -} - -} // namespace QodeAssist diff --git a/sources/widgets/EditorChatButtonHandler.hpp b/sources/widgets/EditorChatButtonHandler.hpp deleted file mode 100644 index d8645d2..0000000 --- a/sources/widgets/EditorChatButtonHandler.hpp +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include "widgets/EditorChatButton.hpp" -#include -#include - -namespace QodeAssist { - -class EditorChatButtonHandler : public TextEditor::BaseHoverHandler -{ -public: - explicit EditorChatButtonHandler() = default; - ~EditorChatButtonHandler() override; - - void showButton(TextEditor::TextEditorWidget *widget); - void hideButton(); - -signals: - void chatButtonClicked(TextEditor::TextEditorWidget *widget); - -protected: - void identifyMatch( - TextEditor::TextEditorWidget *editorWidget, int pos, ReportPriority report) override; - void operateTooltip(TextEditor::TextEditorWidget *editorWidget, const QPoint &point) override; - -private: - QPointer m_widget; - QPoint m_cursorPosition; - EditorChatButton *m_chatButton = nullptr; - int m_buttonHeight = 0; -}; - -} // namespace QodeAssist