refactor: Restructure project into sources/tests layout and dissolve PluginLLMCore

This commit is contained in:
Petr Mironychev
2026-07-14 02:50:43 +02:00
parent adef7972ed
commit 34d4f2c517
348 changed files with 809 additions and 852 deletions

View File

@@ -0,0 +1,305 @@
// 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 "BuildProjectTool.hpp"
#include "GetIssuesListTool.hpp"
#include "plugin/Version.hpp"
#include <logger/Logger.hpp>
#include <projectexplorer/buildmanager.h>
#include <projectexplorer/project.h>
#include <projectexplorer/projectexplorer.h>
#include <projectexplorer/projectexplorerconstants.h>
#include <projectexplorer/projectmanager.h>
#include <projectexplorer/runconfiguration.h>
#include <projectexplorer/target.h>
#include <projectexplorer/task.h>
#include <utils/id.h>
#include <QApplication>
#include <QJsonArray>
#include <QJsonObject>
#include <QMetaObject>
#include <QTimer>
namespace QodeAssist::Tools {
BuildProjectTool::BuildProjectTool(QObject *parent)
: BaseTool(parent)
{
}
BuildProjectTool::~BuildProjectTool()
{
for (auto it = m_activeBuilds.begin(); it != m_activeBuilds.end(); ++it) {
BuildInfo &info = it.value();
if (info.buildFinishedConnection) {
disconnect(info.buildFinishedConnection);
}
if (info.promise) {
info.promise->finish();
}
}
m_activeBuilds.clear();
}
QString BuildProjectTool::id() const
{
return "build_project";
}
QString BuildProjectTool::displayName() const
{
return "Building and running project";
}
QString BuildProjectTool::description() const
{
return "Build the active Qt Creator project using its current build configuration and block "
"until the build finishes. Returns success/failure along with the full compiler output "
"(stdout + stderr). Use `get_issues_list` afterwards to get structured diagnostics. "
"This call is blocking and may take a long time for large projects.";
}
QJsonObject BuildProjectTool::parametersSchema() const
{
QJsonObject definition;
definition["type"] = "object";
QJsonObject properties;
properties["rebuild"] = QJsonObject{
{"type", "boolean"},
{"description", "Force a clean rebuild instead of incremental build (default: false)"}};
properties["run_after_build"] = QJsonObject{
{"type", "boolean"},
{"description", "Run the project after successful build (default: false)"}};
definition["properties"] = properties;
definition["required"] = QJsonArray();
return definition;
}
QFuture<LLMQore::ToolResult> BuildProjectTool::executeAsync(const QJsonObject &input)
{
auto *project = ProjectExplorer::ProjectManager::startupProject();
if (!project) {
return QtFuture::makeReadyFuture(
LLMQore::ToolResult::error("Error: No active project found. Please open a project in Qt Creator."));
}
if (ProjectExplorer::BuildManager::isBuilding(project)) {
return QtFuture::makeReadyFuture(
LLMQore::ToolResult::error("Error: Build is already in progress. Please wait for it to complete."));
}
if (m_activeBuilds.contains(project)) {
return QtFuture::makeReadyFuture(
LLMQore::ToolResult::error(QString("Error: Build is already being tracked for project '%1'.")
.arg(project->displayName())));
}
bool rebuild = input.value("rebuild").toBool(false);
bool runAfterBuild = input.value("run_after_build").toBool(false);
LOG_MESSAGE(QString("BuildProjectTool: %1 project '%2'%3")
.arg(rebuild ? QString("Rebuilding") : QString("Building"))
.arg(project->displayName())
.arg(runAfterBuild ? QString(" (run after build)") : QString()));
auto promise = QSharedPointer<QPromise<LLMQore::ToolResult>>::create();
promise->start();
BuildInfo buildInfo;
buildInfo.promise = promise;
buildInfo.project = project;
buildInfo.projectName = project->displayName();
buildInfo.isRebuild = rebuild;
buildInfo.runAfterBuild = runAfterBuild;
auto *buildManager = ProjectExplorer::BuildManager::instance();
buildInfo.buildFinishedConnection = QObject::connect(
buildManager,
&ProjectExplorer::BuildManager::buildQueueFinished,
this,
&BuildProjectTool::onBuildQueueFinished);
m_activeBuilds.insert(project, buildInfo);
QMetaObject::invokeMethod(
qApp,
[project, rebuild]() {
if (rebuild) {
ProjectExplorer::BuildManager::rebuildProjectWithDependencies(
project, ProjectExplorer::ConfigSelection::Active);
} else {
ProjectExplorer::BuildManager::buildProjectWithDependencies(
project, ProjectExplorer::ConfigSelection::Active);
}
},
Qt::QueuedConnection);
return promise->future();
}
void BuildProjectTool::onBuildQueueFinished(bool success)
{
QList<ProjectExplorer::Project *> projectsToCleanup;
for (auto it = m_activeBuilds.begin(); it != m_activeBuilds.end(); ++it) {
ProjectExplorer::Project *project = it.key();
if (!ProjectExplorer::BuildManager::isBuilding(project)) {
BuildInfo &info = it.value();
if (info.promise && info.promise->future().isCanceled()) {
LOG_MESSAGE(QString("BuildProjectTool: Build cancelled for project '%1'")
.arg(info.projectName));
projectsToCleanup.append(project);
continue;
}
QString result = collectBuildResults(success, info.projectName, info.isRebuild);
if (success && info.runAfterBuild) {
scheduleProjectRun(project, info.projectName, result);
} else if (!success && info.runAfterBuild) {
result += QString("\n\nProject was not started due to build failure.");
}
if (info.promise) {
info.promise->addResult(LLMQore::ToolResult::text(result));
info.promise->finish();
}
projectsToCleanup.append(project);
}
}
for (ProjectExplorer::Project *project : projectsToCleanup) {
cleanupBuildInfo(project);
}
}
void BuildProjectTool::scheduleProjectRun(ProjectExplorer::Project *project,
const QString &projectName,
QString &result)
{
auto *target = project->activeTarget();
if (!target) {
result += QString("\n\nError: No active target found for the project.");
return;
}
auto *runConfig = target->activeRunConfiguration();
if (!runConfig) {
result += QString("\n\nError: No active run configuration found for the project.");
return;
}
QString runConfigName = runConfig->displayName();
result += QString("\n\nProject '%1' will be started with run configuration '%2'.")
.arg(projectName, runConfigName);
ProjectExplorer::ProjectExplorerPlugin::runProject(project, Utils::Id(ProjectExplorer::Constants::NORMAL_RUN_MODE));
}
QString BuildProjectTool::collectBuildResults(
bool success, const QString &projectName, bool isRebuild)
{
QStringList results;
// Build header
QString buildType = isRebuild ? QString("Rebuild") : QString("Build");
QString statusText = success ? QString("✓ SUCCEEDED") : QString("✗ FAILED");
results.append(QString("%1 %2 for project '%3'\n")
.arg(buildType, statusText, projectName));
const auto tasks = IssuesTracker::instance().getTasks();
if (!tasks.isEmpty()) {
int errorCount = 0;
int warningCount = 0;
QStringList issuesList;
for (const ProjectExplorer::Task &task : tasks) {
#if QODEASSIST_QT_CREATOR_VERSION >= QT_VERSION_CHECK(18, 0, 0)
auto taskType = task.type();
auto taskFile = task.file();
auto taskLine = task.line();
auto taskColumn = task.column();
#else
auto taskType = task.type;
auto taskFile = task.file;
auto taskLine = task.line;
auto taskColumn = task.column;
#endif
QString typeStr;
switch (taskType) {
case ProjectExplorer::Task::Error:
typeStr = QString("ERROR");
errorCount++;
break;
case ProjectExplorer::Task::Warning:
typeStr = QString("WARNING");
warningCount++;
break;
default:
continue;
}
if (issuesList.size() < 50) {
QString issueText = QString("[%1] %2").arg(typeStr, task.description());
if (!taskFile.isEmpty()) {
issueText += QString("\n File: %1").arg(taskFile.toUrlishString());
if (taskLine > 0) {
issueText += QString(":%1").arg(taskLine);
if (taskColumn > 0) {
issueText += QString(":%1").arg(taskColumn);
}
}
}
issuesList.append(issueText);
}
}
results.append(QString("Issues found: %1 error(s), %2 warning(s)")
.arg(errorCount)
.arg(warningCount));
if (!issuesList.isEmpty()) {
results.append("\nDetails:");
results.append(issuesList.join("\n\n"));
if (errorCount + warningCount > 50) {
results.append(
QString("\n... and %1 more issue(s). Use get_issues_list tool for full list.")
.arg(errorCount + warningCount - 50));
}
}
} else {
results.append("No compilation errors or warnings.");
}
return results.join("\n");
}
void BuildProjectTool::cleanupBuildInfo(ProjectExplorer::Project *project)
{
if (!m_activeBuilds.contains(project)) {
return;
}
BuildInfo info = m_activeBuilds.take(project);
if (info.buildFinishedConnection) {
disconnect(info.buildFinishedConnection);
}
}
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,57 @@
// 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 <LLMQore/BaseTool.hpp>
#include <QHash>
#include <QObject>
#include <QPointer>
#include <QPromise>
#include <QSharedPointer>
namespace ProjectExplorer {
class Project;
}
namespace QodeAssist::Tools {
struct BuildInfo
{
QSharedPointer<QPromise<LLMQore::ToolResult>> promise;
QPointer<ProjectExplorer::Project> project;
QString projectName;
bool isRebuild = false;
bool runAfterBuild = false;
QMetaObject::Connection buildFinishedConnection;
};
class BuildProjectTool : public ::LLMQore::BaseTool
{
Q_OBJECT
public:
explicit BuildProjectTool(QObject *parent = nullptr);
~BuildProjectTool() override;
QString id() const override;
QString displayName() const override;
QString description() const override;
QJsonObject parametersSchema() const override;
QFuture<LLMQore::ToolResult> executeAsync(const QJsonObject &input = QJsonObject()) override;
private slots:
void onBuildQueueFinished(bool success);
private:
void scheduleProjectRun(ProjectExplorer::Project *project,
const QString &projectName,
QString &result);
QString collectBuildResults(bool success, const QString &projectName, bool isRebuild);
void cleanupBuildInfo(ProjectExplorer::Project *project);
QHash<ProjectExplorer::Project *, BuildInfo> m_activeBuilds;
};
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,128 @@
// 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 "CreateNewFileTool.hpp"
#include <LLMQore/ToolExceptions.hpp>
#include <context/ProjectUtils.hpp>
#include <logger/Logger.hpp>
#include <settings/GeneralSettings.hpp>
#include <settings/ToolsSettings.hpp>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonObject>
#include <QtConcurrent>
namespace QodeAssist::Tools {
CreateNewFileTool::CreateNewFileTool(QObject *parent)
: BaseTool(parent)
{}
QString CreateNewFileTool::id() const
{
return "create_new_file";
}
QString CreateNewFileTool::displayName() const
{
return {"Creating new file"};
}
QString CreateNewFileTool::description() const
{
return "Create a new empty file at the given absolute path. Any missing parent directories "
"are created automatically. The file is written to disk only — it is NOT added to the "
"project's build system automatically; the user must register it in CMakeLists.txt or "
"the equivalent project file. Use `edit_file` afterwards to populate its content.";
}
QJsonObject CreateNewFileTool::parametersSchema() const
{
QJsonObject properties;
QJsonObject filepathProperty;
filepathProperty["type"] = "string";
filepathProperty["description"]
= "Absolute path where the new file should be created. Parent directories are made if "
"missing. Relative paths are rejected.";
properties["filepath"] = filepathProperty;
QJsonObject definition;
definition["type"] = "object";
definition["properties"] = properties;
QJsonArray required;
required.append("filepath");
definition["required"] = required;
return definition;
}
QFuture<LLMQore::ToolResult> CreateNewFileTool::executeAsync(const QJsonObject &input)
{
return QtConcurrent::run([this, input]() -> LLMQore::ToolResult {
QString filePath = input["filepath"].toString();
if (filePath.isEmpty()) {
throw LLMQore::ToolInvalidArgument("Error: 'filepath' parameter is required");
}
QFileInfo fileInfo(filePath);
QString absolutePath = fileInfo.absoluteFilePath();
bool isInProject = Context::ProjectUtils::isFileInProject(absolutePath);
if (!isInProject) {
const auto &settings = Settings::toolsSettings();
if (!settings.allowAccessOutsideProject()) {
const QString projectRoot = Context::ProjectUtils::getProjectRoot();
const QString hint = projectRoot.isEmpty()
? QStringLiteral(
"No project is currently open. Open a project in Qt Creator or "
"enable 'Allow file access outside project' in QodeAssist settings.")
: QString(
"Retry with a path under the active project root: '%1'. The build "
"directory is for compiler output only and cannot accept new source "
"files. If you really need to write outside the project, enable "
"'Allow file access outside project' in QodeAssist settings.")
.arg(projectRoot);
throw LLMQore::ToolRuntimeError(
QString("Error: File path '%1' is not within the current project. %2")
.arg(absolutePath, hint));
}
LOG_MESSAGE(QString("Creating file outside project scope: %1").arg(absolutePath));
}
if (fileInfo.exists()) {
throw LLMQore::ToolRuntimeError(
QString("Error: File already exists at path '%1'").arg(filePath));
}
QDir dir = fileInfo.absoluteDir();
if (!dir.exists()) {
if (!dir.mkpath(".")) {
throw LLMQore::ToolRuntimeError(
QString("Error: Could not create directory: '%1'").arg(dir.absolutePath()));
}
LOG_MESSAGE(QString("Created directory path: %1").arg(dir.absolutePath()));
}
QFile file(absolutePath);
if (!file.open(QIODevice::WriteOnly)) {
throw LLMQore::ToolRuntimeError(
QString("Error: Could not create file '%1': %2").arg(absolutePath, file.errorString()));
}
file.close();
LOG_MESSAGE(QString("Successfully created new file: %1").arg(absolutePath));
return LLMQore::ToolResult::text(QString("Successfully created new file: %1").arg(absolutePath));
});
}
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,26 @@
// 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 <LLMQore/BaseTool.hpp>
namespace QodeAssist::Tools {
class CreateNewFileTool : public ::LLMQore::BaseTool
{
Q_OBJECT
public:
explicit CreateNewFileTool(QObject *parent = nullptr);
QString id() const override;
QString displayName() const override;
QString description() const override;
QJsonObject parametersSchema() const override;
QFuture<LLMQore::ToolResult> executeAsync(const QJsonObject &input = QJsonObject()) override;
};
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,224 @@
// 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 "EditFileTool.hpp"
#include <LLMQore/ToolExceptions.hpp>
#include <context/ChangesManager.h>
#include <context/ProjectUtils.hpp>
#include <logger/Logger.hpp>
#include <settings/GeneralSettings.hpp>
#include <settings/ToolsSettings.hpp>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QUuid>
#include <QtConcurrent>
namespace QodeAssist::Tools {
EditFileTool::EditFileTool(QObject *parent)
: BaseTool(parent)
{}
QString EditFileTool::id() const
{
return "edit_file";
}
QString EditFileTool::displayName() const
{
return {"Editing file"};
}
QString EditFileTool::description() const
{
return "Edit a file by replacing old content with new content. "
"Provide the file path (absolute or relative to project root), old_content to find and replace, "
"and new_content to replace it with. Changes are applied immediately if auto-apply "
"is enabled in settings. The user can undo or reapply changes at any time. "
"\n\nIMPORTANT:"
"\n- ALWAYS use read_file to get current file content before editing to ensure accuracy."
"\n- Path can be absolute (e.g., /path/to/file.cpp) or relative to project root (e.g., src/main.cpp)."
"\n- For EMPTY files: use empty old_content (empty string or omit parameter)."
"\n- To append at the END of file: use empty old_content."
"\n- To insert at the BEGINNING of a file (e.g., copyright header), you MUST provide "
"the EXACT first few lines of the file as old_content (at least 3-5 lines), "
"then put those lines + new header in new_content."
"\n- For replacements in the middle, provide EXACT matching text with sufficient "
"context (at least 5-10 lines) to ensure correct placement."
"\n- The system uses fuzzy matching with 85% similarity threshold for first-time edits. "
"Provide accurate old_content to avoid incorrect placement."
"\n- If changes remain 'pending' and file content hasn't changed, the user likely "
"disabled auto-apply. DO NOT retry the same edit - wait for user action.";
}
QJsonObject EditFileTool::parametersSchema() const
{
QJsonObject properties;
QJsonObject filenameProperty;
filenameProperty["type"] = "string";
filenameProperty["description"]
= "The path of the file to edit. Can be an absolute path (e.g., /path/to/file.cpp) "
"or a relative path from the project root (e.g., src/main.cpp)";
properties["filename"] = filenameProperty;
QJsonObject oldContentProperty;
oldContentProperty["type"] = "string";
oldContentProperty["description"]
= "The content to find and replace. For exact matches, provide precise text "
"(including whitespace). For changed files, the system uses fuzzy matching with "
"85% similarity threshold for first-time edits. If empty, new_content will be "
"appended to the end of the file";
properties["old_content"] = oldContentProperty;
QJsonObject newContentProperty;
newContentProperty["type"] = "string";
newContentProperty["description"] = "The new content to replace the old content with";
properties["new_content"] = newContentProperty;
QJsonObject definition;
definition["type"] = "object";
definition["properties"] = properties;
QJsonArray required;
required.append("filename");
required.append("new_content");
definition["required"] = required;
return definition;
}
QFuture<LLMQore::ToolResult> EditFileTool::executeAsync(const QJsonObject &input)
{
return QtConcurrent::run([this, input]() -> LLMQore::ToolResult {
QString filename = input["filename"].toString().trimmed();
QString oldContent = input["old_content"].toString();
QString newContent = input["new_content"].toString();
QString requestId = input["_request_id"].toString();
if (filename.isEmpty()) {
throw LLMQore::ToolInvalidArgument("'filename' parameter is required and cannot be empty");
}
if (newContent.isEmpty()) {
throw LLMQore::ToolInvalidArgument("'new_content' parameter is required and cannot be empty");
}
QFileInfo fileInfo(filename);
QString filePath;
if (fileInfo.isAbsolute()) {
filePath = filename;
} else {
QString projectRoot = Context::ProjectUtils::getProjectRoot();
if (projectRoot.isEmpty()) {
throw LLMQore::ToolRuntimeError(
QString("Cannot resolve relative path '%1': no project is open. "
"Please provide an absolute path or open a project.")
.arg(filename));
}
filePath = QDir(projectRoot).absoluteFilePath(filename);
LOG_MESSAGE(QString("EditFileTool: Resolved relative path '%1' to '%2'")
.arg(filename, filePath));
}
QFile file(filePath);
if (!file.exists()) {
throw LLMQore::ToolRuntimeError(QString("File does not exist: %1").arg(filePath));
}
QFileInfo finalFileInfo(filePath);
if (!finalFileInfo.isWritable()) {
throw LLMQore::ToolRuntimeError(
QString("File is not writable (read-only or permission denied): %1").arg(filePath));
}
bool isInProject = Context::ProjectUtils::isFileInProject(filePath);
if (!isInProject) {
const auto &settings = Settings::toolsSettings();
if (!settings.allowAccessOutsideProject()) {
const QString projectRoot = Context::ProjectUtils::getProjectRoot();
const QString hint = projectRoot.isEmpty()
? QStringLiteral(
"No project is currently open. Open a project in Qt Creator or "
"enable 'Allow file access outside project' in QodeAssist settings.")
: QString(
"Retry with a path under the active project root: '%1'. The build "
"directory is for compiler output only — source files must live under "
"the project root. If you really need to edit outside the project, "
"enable 'Allow file access outside project' in QodeAssist settings.")
.arg(projectRoot);
throw LLMQore::ToolRuntimeError(
QString("File path '%1' is not within the current project. %2")
.arg(filePath, hint));
}
LOG_MESSAGE(QString("Editing file outside project scope: %1").arg(filePath));
}
QString editId = QUuid::createUuid().toString(QUuid::WithoutBraces);
bool autoApply = Settings::toolsSettings().autoApplyFileEdits();
LOG_MESSAGE(QString("EditFileTool: Edit details for %1:").arg(filePath));
LOG_MESSAGE(QString(" oldContent length: %1 chars").arg(oldContent.length()));
LOG_MESSAGE(QString(" newContent length: %1 chars").arg(newContent.length()));
if (oldContent.length() <= 200) {
LOG_MESSAGE(QString(" oldContent: '%1'").arg(oldContent));
} else {
LOG_MESSAGE(QString(" oldContent (first 200 chars): '%1...'")
.arg(oldContent.left(200)));
}
if (newContent.length() <= 200) {
LOG_MESSAGE(QString(" newContent: '%1'").arg(newContent));
} else {
LOG_MESSAGE(QString(" newContent (first 200 chars): '%1...'")
.arg(newContent.left(200)));
}
Context::ChangesManager::instance().addFileEdit(
editId,
filePath,
oldContent,
newContent,
autoApply,
false,
requestId
);
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
QString status = "pending";
if (edit.status == Context::ChangesManager::Applied) {
status = "applied";
} else if (edit.status == Context::ChangesManager::Rejected) {
status = "rejected";
} else if (edit.status == Context::ChangesManager::Archived) {
status = "archived";
}
QString statusMessage = edit.statusMessage;
QJsonObject result;
result["edit_id"] = editId;
result["file"] = filePath;
result["old_content"] = oldContent;
result["new_content"] = newContent;
result["status"] = status;
result["status_message"] = statusMessage;
LOG_MESSAGE(QString("File edit created: %1 (ID: %2, Status: %3, Deferred: %4)")
.arg(filePath, editId, status, requestId.isEmpty() ? QString("no") : QString("yes")));
QString resultStr = "QODEASSIST_FILE_EDIT:"
+ QString::fromUtf8(QJsonDocument(result).toJson(QJsonDocument::Compact));
return LLMQore::ToolResult::text(resultStr);
});
}
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,26 @@
// 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 <LLMQore/BaseTool.hpp>
namespace QodeAssist::Tools {
class EditFileTool : public ::LLMQore::BaseTool
{
Q_OBJECT
public:
explicit EditFileTool(QObject *parent = nullptr);
QString id() const override;
QString displayName() const override;
QString description() const override;
QJsonObject parametersSchema() const override;
QFuture<LLMQore::ToolResult> executeAsync(const QJsonObject &input = QJsonObject()) override;
};
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,510 @@
// 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 "ExecuteTerminalCommandTool.hpp"
#include <logger/Logger.hpp>
#include <settings/ToolsSettings.hpp>
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
#include <QDir>
#include <QJsonArray>
#include <QJsonObject>
#include <QProcess>
#include <QPromise>
#include <QRegularExpression>
#include <QRegularExpression>
#include <QSharedPointer>
#include <QTimer>
#include <atomic>
namespace QodeAssist::Tools {
ExecuteTerminalCommandTool::ExecuteTerminalCommandTool(QObject *parent)
: BaseTool(parent)
{
}
QString ExecuteTerminalCommandTool::id() const
{
return "execute_terminal_command";
}
QString ExecuteTerminalCommandTool::displayName() const
{
return "Executing terminal command";
}
QString ExecuteTerminalCommandTool::description() const
{
return getCommandDescription();
}
QJsonObject ExecuteTerminalCommandTool::parametersSchema() const
{
QJsonObject definition;
definition["type"] = "object";
const QStringList allowed = getAllowedCommands();
const QString allowedList = allowed.isEmpty() ? "none" : allowed.join(", ");
QJsonObject properties;
properties["command"] = QJsonObject{
{"type", "string"},
{"description",
QString("Name of the executable to run, WITHOUT any arguments or flags. "
"Must be exactly one of the allowed commands: %1. "
"Put every flag and argument in the separate `args` field. "
"Correct: command=\"ls\", args=\"-R\". "
"Incorrect: command=\"ls -R\" (the whole line in one field will be rejected).")
.arg(allowedList)}};
properties["args"] = QJsonObject{
{"type", "string"},
{"description",
"Optional arguments and flags for the command, as a single string. Do NOT repeat the "
"command name here. Arguments with spaces should be quoted. "
"Example: args=\"--file \\\"path with spaces.txt\\\" --verbose\"."}};
definition["properties"] = properties;
definition["required"] = QJsonArray{"command"};
return definition;
}
QFuture<LLMQore::ToolResult> ExecuteTerminalCommandTool::executeAsync(const QJsonObject &input)
{
using LLMQore::ToolResult;
QString command = input.value("command").toString().trimmed();
QString args = input.value("args").toString().trimmed();
if (command.isEmpty()) {
LOG_MESSAGE("ExecuteTerminalCommandTool: Command is empty");
return QtFuture::makeReadyFuture(ToolResult::error("Error: Command parameter is required."));
}
// Tolerate models that pack the whole command line into `command`. As long as `args` is
// empty we can safely split on the first whitespace — the allowlist check still validates
// the actual executable name.
if (args.isEmpty()) {
const int firstSpace = command.indexOf(QRegularExpression("\\s"));
if (firstSpace > 0) {
args = command.mid(firstSpace + 1).trimmed();
command = command.left(firstSpace);
}
}
if (command.length() > MAX_COMMAND_LENGTH) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command too long (%1 chars)")
.arg(command.length()));
return QtFuture::makeReadyFuture(
ToolResult::error(QString("Error: Command exceeds maximum length of %1 characters.")
.arg(MAX_COMMAND_LENGTH)));
}
if (args.length() > MAX_ARGS_LENGTH) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Arguments too long (%1 chars)")
.arg(args.length()));
return QtFuture::makeReadyFuture(
ToolResult::error(QString("Error: Arguments exceed maximum length of %1 characters.")
.arg(MAX_ARGS_LENGTH)));
}
if (!isCommandAllowed(command)) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1' is not allowed")
.arg(command));
const QStringList allowed = getAllowedCommands();
const QString allowedList = allowed.isEmpty() ? "none" : allowed.join(", ");
return QtFuture::makeReadyFuture(
ToolResult::error(QString("Error: Command '%1' is not in the allowed list. Allowed commands: %2")
.arg(command)
.arg(allowedList)));
}
if (!isCommandSafe(command)) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1' contains unsafe characters")
.arg(command));
#ifdef Q_OS_WIN
const QString allowedChars = "alphanumeric characters, hyphens, underscores, dots, colons, "
"backslashes, and forward slashes";
#else
const QString allowedChars = "alphanumeric characters, hyphens, underscores, dots, and slashes";
#endif
return QtFuture::makeReadyFuture(
ToolResult::error(QString("Error: Command '%1' contains potentially dangerous characters. "
"Only %2 are allowed.")
.arg(command)
.arg(allowedChars)));
}
if (!args.isEmpty() && !areArgumentsSafe(args)) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Arguments contain unsafe patterns: '%1'")
.arg(args));
return QtFuture::makeReadyFuture(
ToolResult::error(QString("Error: Arguments contain potentially dangerous patterns (command chaining, "
"redirection, or pipe operators).")));
}
auto *project = ProjectExplorer::ProjectManager::startupProject();
QString workingDir;
if (project) {
workingDir = project->projectDirectory().toString();
LOG_MESSAGE(
QString("ExecuteTerminalCommandTool: Working directory is '%1'").arg(workingDir));
} else {
LOG_MESSAGE("ExecuteTerminalCommandTool: No active project, using current directory");
workingDir = QDir::currentPath();
}
QDir dir(workingDir);
if (!dir.exists() || !dir.isReadable()) {
LOG_MESSAGE(
QString("ExecuteTerminalCommandTool: Working directory '%1' is not accessible")
.arg(workingDir));
return QtFuture::makeReadyFuture(
ToolResult::error(QString("Error: Working directory '%1' does not exist or is not accessible.")
.arg(workingDir)));
}
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Executing command '%1' with args '%2' in '%3'")
.arg(command)
.arg(args.isEmpty() ? "(no args)" : args)
.arg(workingDir));
auto promise = QSharedPointer<QPromise<ToolResult>>::create();
QFuture<ToolResult> future = promise->future();
promise->start();
auto resolved = std::make_shared<std::atomic<bool>>(false);
QProcess *process = new QProcess();
process->setWorkingDirectory(workingDir);
process->setProcessChannelMode(QProcess::MergedChannels);
process->setReadChannel(QProcess::StandardOutput);
const int timeoutMs = commandTimeoutMs();
QTimer *timeoutTimer = new QTimer();
timeoutTimer->setSingleShot(true);
timeoutTimer->setInterval(timeoutMs);
QObject::connect(timeoutTimer, &QTimer::timeout, [process, promise, resolved, command, args, timeoutTimer, timeoutMs]() {
if (*resolved)
return;
*resolved = true;
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1 %2' timed out after %3ms")
.arg(command)
.arg(args)
.arg(timeoutMs));
process->terminate();
QTimer::singleShot(1000, process, [process]() {
if (process->state() != QProcess::NotRunning) {
LOG_MESSAGE("ExecuteTerminalCommandTool: Forcefully killing process after timeout");
process->kill();
}
process->deleteLater();
});
promise->addResult(ToolResult::error(QString("Error: Command '%1 %2' timed out after %3 seconds. "
"The process has been terminated.")
.arg(command)
.arg(args.isEmpty() ? "" : args)
.arg(timeoutMs / 1000)));
promise->finish();
timeoutTimer->deleteLater();
});
QObject::connect(
process,
QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
[this, process, promise, resolved, command, args, timeoutTimer](
int exitCode, QProcess::ExitStatus exitStatus) {
if (*resolved) {
process->deleteLater();
return;
}
*resolved = true;
timeoutTimer->stop();
timeoutTimer->deleteLater();
const QByteArray rawOutput = process->readAll();
const qint64 outputSize = rawOutput.size();
const QString output = sanitizeOutput(QString::fromUtf8(rawOutput), outputSize);
const QString fullCommand = args.isEmpty() ? command : QString("%1 %2").arg(command).arg(args);
if (exitStatus == QProcess::NormalExit) {
if (exitCode == 0) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1' completed "
"successfully (output size: %2 bytes)")
.arg(fullCommand)
.arg(outputSize));
promise->addResult(ToolResult::text(
QString("Command '%1' executed successfully.\n\nOutput:\n%2")
.arg(fullCommand)
.arg(output.isEmpty() ? "(no output)" : output)));
} else {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1' failed with "
"exit code %2 (output size: %3 bytes)")
.arg(fullCommand)
.arg(exitCode)
.arg(outputSize));
promise->addResult(ToolResult::error(
QString("Command '%1' failed with exit code %2.\n\nOutput:\n%3")
.arg(fullCommand)
.arg(exitCode)
.arg(output.isEmpty() ? "(no output)" : output)));
}
} else {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1' crashed or was "
"terminated (output size: %2 bytes)")
.arg(fullCommand)
.arg(outputSize));
const QString error = process->errorString();
promise->addResult(ToolResult::error(
QString("Command '%1' crashed or was terminated.\n\nError: %2\n\nOutput:\n%3")
.arg(fullCommand)
.arg(error)
.arg(output.isEmpty() ? "(no output)" : output)));
}
promise->finish();
process->deleteLater();
});
QObject::connect(process, &QProcess::errorOccurred, [process, promise, resolved, command, args, timeoutTimer](
QProcess::ProcessError error) {
if (*resolved) {
process->deleteLater();
return;
}
*resolved = true;
timeoutTimer->stop();
timeoutTimer->deleteLater();
const QString fullCommand = args.isEmpty() ? command : QString("%1 %2").arg(command).arg(args);
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Process error occurred for '%1': %2 (%3)")
.arg(fullCommand)
.arg(error)
.arg(process->errorString()));
QString errorMessage;
switch (error) {
case QProcess::FailedToStart:
errorMessage = QString("Failed to start command '%1'. The command may not exist or "
"you may not have permission to execute it.")
.arg(fullCommand);
break;
case QProcess::Crashed:
errorMessage = QString("Command '%1' crashed during execution.").arg(fullCommand);
break;
case QProcess::Timedout:
errorMessage = QString("Command '%1' timed out.").arg(fullCommand);
break;
case QProcess::WriteError:
errorMessage = QString("Write error occurred while executing '%1'.").arg(fullCommand);
break;
case QProcess::ReadError:
errorMessage = QString("Read error occurred while executing '%1'.").arg(fullCommand);
break;
default:
errorMessage = QString("Unknown error occurred while executing '%1': %2")
.arg(fullCommand)
.arg(process->errorString());
break;
}
promise->addResult(ToolResult::error(QString("Error: %1").arg(errorMessage)));
promise->finish();
process->deleteLater();
});
QStringList argsList;
if (!args.isEmpty()) {
argsList = QProcess::splitCommand(args);
}
#ifdef Q_OS_WIN
static const QStringList windowsBuiltinCommands = {
"dir", "type", "del", "copy", "move", "ren", "rename",
"md", "mkdir", "rd", "rmdir", "cd", "chdir", "cls", "echo",
"set", "path", "prompt", "ver", "vol", "date", "time"
};
const QString lowerCommand = command.toLower();
const bool isBuiltin = windowsBuiltinCommands.contains(lowerCommand);
if (isBuiltin) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Executing Windows builtin command '%1' via cmd.exe")
.arg(command));
QStringList cmdArgs;
cmdArgs << "/c" << command;
cmdArgs.append(argsList);
process->start("cmd.exe", cmdArgs);
} else {
process->start(command, argsList);
}
#else
process->start(command, argsList);
#endif
timeoutTimer->start();
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Process start requested for '%1'")
.arg(command));
return future;
}
bool ExecuteTerminalCommandTool::isCommandAllowed(const QString &command) const
{
const QStringList allowed = getAllowedCommands();
return allowed.contains(command, Qt::CaseInsensitive);
}
bool ExecuteTerminalCommandTool::isCommandSafe(const QString &command) const
{
#ifdef Q_OS_WIN
static const QRegularExpression safePattern("^[a-zA-Z0-9._/\\\\:-]+$");
#else
static const QRegularExpression safePattern("^[a-zA-Z0-9._/-]+$");
#endif
const bool isSafe = safePattern.match(command).hasMatch();
if (!isSafe) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1' failed safety check")
.arg(command));
}
return isSafe;
}
bool ExecuteTerminalCommandTool::areArgumentsSafe(const QString &args) const
{
if (args.isEmpty()) {
return true;
}
// Check for null bytes
if (args.contains(QChar('\0'))) {
LOG_MESSAGE("ExecuteTerminalCommandTool: Null byte found in args");
return false;
}
static const QStringList dangerousPatterns = {
";", // Command separator
"&", // Command separator / background execution
"|", // Pipe operator
">", // Output redirection
"<", // Input redirection
"`", // Command substitution
"$(", // Command substitution
"${", // Variable expansion
"\n", // Newline (could start new command)
"\r", // Carriage return
#ifdef Q_OS_WIN
"^", // Escape character in cmd.exe (can bypass other checks)
"%", // Environment variable expansion on Windows
#endif
};
for (const QString &pattern : dangerousPatterns) {
if (args.contains(pattern)) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Dangerous pattern '%1' found in args")
.arg(pattern));
return false;
}
}
return true;
}
QString ExecuteTerminalCommandTool::sanitizeOutput(const QString &output, qint64 totalSize) const
{
if (totalSize > MAX_OUTPUT_SIZE) {
const QString truncated = output.left(MAX_OUTPUT_SIZE / 2);
return QString("%1\n\n... [Output truncated: exceeded maximum size of %2 MB. "
"Total output size was %3 bytes] ...")
.arg(truncated)
.arg(MAX_OUTPUT_SIZE / (1024 * 1024))
.arg(totalSize);
}
return output;
}
QStringList ExecuteTerminalCommandTool::getAllowedCommands() const
{
QString commandsStr;
#ifdef Q_OS_LINUX
commandsStr = Settings::toolsSettings().allowedTerminalCommandsLinux().trimmed();
#elif defined(Q_OS_MACOS)
commandsStr = Settings::toolsSettings().allowedTerminalCommandsMacOS().trimmed();
#elif defined(Q_OS_WIN)
commandsStr = Settings::toolsSettings().allowedTerminalCommandsWindows().trimmed();
#else
commandsStr = Settings::toolsSettings().allowedTerminalCommandsLinux().trimmed(); // fallback
#endif
if (commandsStr.isEmpty()) {
return QStringList();
}
QStringList result;
const QStringList rawCommands = commandsStr.split(',', Qt::SkipEmptyParts);
result.reserve(rawCommands.size());
for (const QString &cmd : rawCommands) {
const QString trimmed = cmd.trimmed();
if (!trimmed.isEmpty()) {
result.append(trimmed);
}
}
return result;
}
int ExecuteTerminalCommandTool::commandTimeoutMs() const
{
return Settings::toolsSettings().terminalCommandTimeout() * 1000;
}
QString ExecuteTerminalCommandTool::getCommandDescription() const
{
const QStringList allowed = getAllowedCommands();
const QString allowedList = allowed.isEmpty() ? "none" : allowed.join(", ");
#ifdef Q_OS_LINUX
const QString osInfo = " Running on Linux.";
#elif defined(Q_OS_MACOS)
const QString osInfo = " Running on macOS.";
#elif defined(Q_OS_WIN)
const QString osInfo = " Running on Windows.";
#else
const QString osInfo = "";
#endif
return QString(
"Execute a terminal command in the project directory. "
"Only commands from the allowed list can be executed. "
"Currently allowed commands for this OS: %1. "
"The command will be executed in the root directory of the active project. "
"Commands have a %2 second timeout. "
"Returns the command output (stdout and stderr) or an error message if the command fails.%3")
.arg(allowedList)
.arg(commandTimeoutMs() / 1000)
.arg(osInfo);
}
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,42 @@
// 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 <LLMQore/BaseTool.hpp>
#include <QObject>
namespace QodeAssist::Tools {
class ExecuteTerminalCommandTool : public ::LLMQore::BaseTool
{
Q_OBJECT
public:
explicit ExecuteTerminalCommandTool(QObject *parent = nullptr);
QString id() const override;
QString displayName() const override;
QString description() const override;
QJsonObject parametersSchema() const override;
QFuture<LLMQore::ToolResult> executeAsync(const QJsonObject &input = QJsonObject()) override;
private:
bool isCommandAllowed(const QString &command) const;
bool isCommandSafe(const QString &command) const;
bool areArgumentsSafe(const QString &args) const;
QStringList getAllowedCommands() const;
QString getCommandDescription() const;
QString sanitizeOutput(const QString &output, qint64 maxSize) const;
int commandTimeoutMs() const;
// Constants for production safety
static constexpr qint64 MAX_OUTPUT_SIZE = 10 * 1024 * 1024; // 10 MB
static constexpr int MAX_COMMAND_LENGTH = 1024;
static constexpr int MAX_ARGS_LENGTH = 4096;
};
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,243 @@
// 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 "FileSearchUtils.hpp"
#include <context/ProjectUtils.hpp>
#include <logger/Logger.hpp>
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
#include <settings/GeneralSettings.hpp>
#include <settings/ToolsSettings.hpp>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QTextStream>
namespace QodeAssist::Tools {
FileSearchUtils::FileMatch FileSearchUtils::findBestMatch(
const QString &query,
const QString &filePattern,
int maxResults,
Context::IgnoreManager *ignoreManager)
{
QList<FileMatch> candidates;
auto projects = ProjectExplorer::ProjectManager::projects();
if (projects.isEmpty()) {
return FileMatch{};
}
QFileInfo queryInfo(query);
if (queryInfo.isAbsolute() && queryInfo.exists() && queryInfo.isFile()) {
FileMatch match;
match.absolutePath = queryInfo.canonicalFilePath();
for (auto project : projects) {
if (!project)
continue;
QString projectDir = project->projectDirectory().path();
if (match.absolutePath.startsWith(projectDir)) {
match.relativePath = QDir(projectDir).relativeFilePath(match.absolutePath);
match.projectName = project->displayName();
match.matchType = MatchType::ExactName;
return match;
}
}
match.relativePath = queryInfo.fileName();
match.projectName = "External";
match.matchType = MatchType::ExactName;
return match;
}
QString lowerQuery = query.toLower();
for (auto project : projects) {
if (!project)
continue;
auto projectFiles = project->files(ProjectExplorer::Project::SourceFiles);
QString projectDir = project->projectDirectory().path();
QString projectName = project->displayName();
for (const auto &filePath : projectFiles) {
QString absolutePath = filePath.path();
if (ignoreManager && ignoreManager->shouldIgnore(absolutePath, project))
continue;
QFileInfo fileInfo(absolutePath);
QString fileName = fileInfo.fileName();
if (!filePattern.isEmpty() && !matchesFilePattern(fileName, filePattern))
continue;
QString relativePath = QDir(projectDir).relativeFilePath(absolutePath);
FileMatch match;
match.absolutePath = absolutePath;
match.relativePath = relativePath;
match.projectName = projectName;
QString lowerFileName = fileName.toLower();
QString lowerRelativePath = relativePath.toLower();
if (lowerFileName == lowerQuery) {
match.matchType = MatchType::ExactName;
candidates.append(match);
} else if (lowerRelativePath.contains(lowerQuery)) {
match.matchType = MatchType::PathMatch;
candidates.append(match);
} else if (lowerFileName.contains(lowerQuery)) {
match.matchType = MatchType::PartialName;
candidates.append(match);
}
}
}
if (candidates.isEmpty() || candidates.first().matchType != MatchType::ExactName) {
for (auto project : projects) {
if (!project)
continue;
QString projectDir = project->projectDirectory().path();
QString projectName = project->displayName();
int depth = 0;
searchInFileSystem(
projectDir,
lowerQuery,
projectName,
projectDir,
project,
candidates,
maxResults,
depth,
5,
ignoreManager);
}
}
if (candidates.isEmpty()) {
return FileMatch{};
}
std::sort(candidates.begin(), candidates.end());
return candidates.first();
}
void FileSearchUtils::searchInFileSystem(
const QString &dirPath,
const QString &query,
const QString &projectName,
const QString &projectDir,
ProjectExplorer::Project *project,
QList<FileMatch> &matches,
int maxResults,
int &currentDepth,
int maxDepth,
Context::IgnoreManager *ignoreManager)
{
if (currentDepth >= maxDepth || matches.size() >= maxResults)
return;
currentDepth++;
QDir dir(dirPath);
if (!dir.exists()) {
currentDepth--;
return;
}
auto entries = dir.entryInfoList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot);
for (const auto &entry : entries) {
if (matches.size() >= maxResults)
break;
QString absolutePath = entry.absoluteFilePath();
if (ignoreManager && ignoreManager->shouldIgnore(absolutePath, project))
continue;
QString fileName = entry.fileName();
if (entry.isDir()) {
searchInFileSystem(
absolutePath,
query,
projectName,
projectDir,
project,
matches,
maxResults,
currentDepth,
maxDepth,
ignoreManager);
continue;
}
QString lowerFileName = fileName.toLower();
QString relativePath = QDir(projectDir).relativeFilePath(absolutePath);
QString lowerRelativePath = relativePath.toLower();
FileMatch match;
match.absolutePath = absolutePath;
match.relativePath = relativePath;
match.projectName = projectName;
if (lowerFileName == query) {
match.matchType = MatchType::ExactName;
matches.append(match);
} else if (lowerRelativePath.contains(query)) {
match.matchType = MatchType::PathMatch;
matches.append(match);
} else if (lowerFileName.contains(query)) {
match.matchType = MatchType::PartialName;
matches.append(match);
}
}
currentDepth--;
}
bool FileSearchUtils::matchesFilePattern(const QString &fileName, const QString &pattern)
{
if (pattern.isEmpty())
return true;
if (pattern.startsWith("*.")) {
QString extension = pattern.mid(1);
return fileName.endsWith(extension, Qt::CaseInsensitive);
}
return fileName.compare(pattern, Qt::CaseInsensitive) == 0;
}
QString FileSearchUtils::readFileContent(const QString &filePath)
{
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
return QString();
}
QString canonicalPath = QFileInfo(filePath).canonicalFilePath();
bool isInProject = Context::ProjectUtils::isFileInProject(canonicalPath);
if (!isInProject) {
const auto &settings = Settings::toolsSettings();
if (!settings.allowAccessOutsideProject()) {
LOG_MESSAGE(QString("Access denied to file outside project: %1").arg(canonicalPath));
return QString();
}
LOG_MESSAGE(QString("Reading file outside project scope: %1").arg(canonicalPath));
}
QTextStream stream(&file);
stream.setAutoDetectUnicode(true);
QString content = stream.readAll();
return content;
}
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,137 @@
// 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 <context/IgnoreManager.hpp>
#include <QString>
#include <QList>
namespace ProjectExplorer {
class Project;
}
namespace QodeAssist::Tools {
/**
* @brief Utility class for file searching and reading operations
*
* Provides common functionality for file operations used by various tools:
* - Fuzzy file searching with multiple match strategies
* - File pattern matching (e.g., *.cpp, *.h)
* - Secure file content reading with project boundary checks
* - Integration with IgnoreManager for respecting .qodeassistignore
*/
class FileSearchUtils
{
public:
/**
* @brief Match quality levels for file search results
*/
enum class MatchType {
ExactName, ///< Exact filename match (highest priority)
PathMatch, ///< Query found in relative path
PartialName ///< Query found in filename (lowest priority)
};
/**
* @brief Represents a file search result with metadata
*/
struct FileMatch
{
QString absolutePath; ///< Full absolute path to the file
QString relativePath; ///< Path relative to project root
QString projectName; ///< Name of the project containing the file
QString content; ///< File content (if read)
MatchType matchType; ///< Quality of the match
bool contentRead = false; ///< Whether content has been read
QString error; ///< Error message if operation failed
/**
* @brief Compare matches by quality (for sorting)
*/
bool operator<(const FileMatch &other) const
{
return static_cast<int>(matchType) < static_cast<int>(other.matchType);
}
};
/**
* @brief Find the best matching file across all open projects
*
* Search strategy:
* 1. Check if query is an absolute path
* 2. Search in project source files (exact, path, partial matches)
* 3. Search filesystem within project directories (respects .qodeassistignore)
*
* @param query Filename, partial name, or path to search for (case-insensitive)
* @param filePattern Optional file pattern filter (e.g., "*.cpp", "*.h")
* @param maxResults Maximum number of candidates to collect
* @param ignoreManager IgnoreManager instance for filtering files
* @return Best matching file, or empty FileMatch if not found
*/
static FileMatch findBestMatch(
const QString &query,
const QString &filePattern = QString(),
int maxResults = 10,
Context::IgnoreManager *ignoreManager = nullptr);
/**
* @brief Check if a filename matches a file pattern
*
* Supports:
* - Wildcard patterns (*.cpp, *.h)
* - Exact filename matching
* - Empty pattern (matches all)
*
* @param fileName File name to check
* @param pattern Pattern to match against
* @return true if filename matches pattern
*/
static bool matchesFilePattern(const QString &fileName, const QString &pattern);
/**
* @brief Read file content with security checks
*
* Performs the following checks:
* - File exists and is readable
* - Respects project boundary settings (allowAccessOutsideProject)
* - Logs access to files outside project scope
*
* @param filePath Absolute path to file
* @return File content as QString, or null QString on error
*/
static QString readFileContent(const QString &filePath);
/**
* @brief Search for files in filesystem directory tree
*
* Recursively searches a directory for files matching the query.
* Respects .qodeassistignore patterns and depth limits.
*
* @param dirPath Directory to search in
* @param query Search query (case-insensitive)
* @param projectName Name of the project for metadata
* @param projectDir Root directory of the project
* @param project Project instance for ignore checking
* @param matches Output list to append matches to
* @param maxResults Stop after finding this many matches
* @param currentDepth Current recursion depth (modified during recursion)
* @param maxDepth Maximum recursion depth
* @param ignoreManager IgnoreManager instance for filtering files
*/
static void searchInFileSystem(
const QString &dirPath,
const QString &query,
const QString &projectName,
const QString &projectDir,
ProjectExplorer::Project *project,
QList<FileMatch> &matches,
int maxResults,
int &currentDepth,
int maxDepth = 5,
Context::IgnoreManager *ignoreManager = nullptr);
};
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,93 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "FindFileTool.hpp"
#include "FileSearchUtils.hpp"
#include <LLMQore/ToolExceptions.hpp>
#include <logger/Logger.hpp>
#include <QJsonArray>
#include <QJsonObject>
#include <QtConcurrent>
namespace QodeAssist::Tools {
FindFileTool::FindFileTool(QObject *parent)
: BaseTool(parent)
, m_ignoreManager(new Context::IgnoreManager(this))
{}
QString FindFileTool::id() const
{
return "find_file";
}
QString FindFileTool::displayName() const
{
return "Finding file";
}
QString FindFileTool::description() const
{
return "Find a file in the current project(s) by name or partial path. "
"Returns the absolute path, project-relative path, and the name of the project "
"that contains the match. Does NOT read file content — use `read_file` separately "
"when you need the content. Use this when you know part of the filename but not "
"the exact location.";
}
QJsonObject FindFileTool::parametersSchema() const
{
QJsonObject properties;
properties["query"] = QJsonObject{
{"type", "string"},
{"description",
"Filename, partial filename, or partial path to look for. Case-insensitive. "
"Exact filename matches rank highest, then path matches, then partial name matches."}};
properties["file_pattern"] = QJsonObject{
{"type", "string"},
{"description",
"Optional wildcard filter applied to the filename. Examples: '*.cpp', '*.h', '*.qml'. "
"Leave empty to match any extension."}};
QJsonObject definition;
definition["type"] = "object";
definition["properties"] = properties;
definition["required"] = QJsonArray{"query"};
return definition;
}
QFuture<LLMQore::ToolResult> FindFileTool::executeAsync(const QJsonObject &input)
{
return QtConcurrent::run([this, input]() -> LLMQore::ToolResult {
QString query = input["query"].toString().trimmed();
if (query.isEmpty()) {
throw LLMQore::ToolInvalidArgument("'query' parameter is required and cannot be empty");
}
QString filePattern = input["file_pattern"].toString();
LOG_MESSAGE(QString("FindFileTool: Searching for '%1' (pattern: %2)")
.arg(query, filePattern.isEmpty() ? "none" : filePattern));
FileSearchUtils::FileMatch match
= FileSearchUtils::findBestMatch(query, filePattern, 10, m_ignoreManager);
if (match.absolutePath.isEmpty()) {
return LLMQore::ToolResult::text(QString("No file found matching '%1'").arg(query));
}
QString result = QString("Found file: %1\nAbsolute path: %2\nProject: %3")
.arg(match.relativePath, match.absolutePath, match.projectName);
return LLMQore::ToolResult::text(result);
});
}
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,32 @@
// 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 <context/IgnoreManager.hpp>
#include <LLMQore/BaseTool.hpp>
#include <QFuture>
#include <QJsonObject>
#include <QObject>
namespace QodeAssist::Tools {
class FindFileTool : public ::LLMQore::BaseTool
{
Q_OBJECT
public:
explicit FindFileTool(QObject *parent = nullptr);
QString id() const override;
QString displayName() const override;
QString description() const override;
QJsonObject parametersSchema() const override;
QFuture<LLMQore::ToolResult> executeAsync(const QJsonObject &input) override;
private:
Context::IgnoreManager *m_ignoreManager;
};
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,228 @@
// 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 "GetIssuesListTool.hpp"
#include "plugin/Version.hpp"
#include <logger/Logger.hpp>
#include <projectexplorer/taskhub.h>
#include <QJsonArray>
#include <QJsonObject>
#include <QMutexLocker>
#include <QtConcurrent>
namespace QodeAssist::Tools {
IssuesTracker &IssuesTracker::instance()
{
static IssuesTracker tracker;
return tracker;
}
IssuesTracker::IssuesTracker(QObject *parent)
: QObject(parent)
{
auto &hub = ProjectExplorer::taskHub();
connect(&hub, &ProjectExplorer::TaskHub::taskAdded, this, &IssuesTracker::onTaskAdded);
connect(&hub, &ProjectExplorer::TaskHub::taskRemoved, this, &IssuesTracker::onTaskRemoved);
connect(&hub, &ProjectExplorer::TaskHub::tasksCleared, this, &IssuesTracker::onTasksCleared);
}
QList<ProjectExplorer::Task> IssuesTracker::getTasks() const
{
QMutexLocker locker(&m_mutex);
return m_tasks;
}
void IssuesTracker::onTaskAdded(const ProjectExplorer::Task &task)
{
QMutexLocker locker(&m_mutex);
m_tasks.append(task);
QString typeStr;
#if QODEASSIST_QT_CREATOR_VERSION >= QT_VERSION_CHECK(18, 0, 0)
auto taskType = task.type();
auto taskFile = task.file();
auto taskLine = task.line();
#else
auto taskType = task.type;
auto taskFile = task.file;
auto taskLine = task.line;
#endif
switch (taskType) {
case ProjectExplorer::Task::Error:
typeStr = "ERROR";
break;
case ProjectExplorer::Task::Warning:
typeStr = "WARNING";
break;
default:
typeStr = "INFO";
break;
}
}
void IssuesTracker::onTaskRemoved(const ProjectExplorer::Task &task)
{
QMutexLocker locker(&m_mutex);
m_tasks.removeOne(task);
}
void IssuesTracker::onTasksCleared(Utils::Id categoryId)
{
QMutexLocker locker(&m_mutex);
if (categoryId.isValid()) {
int beforeCount = m_tasks.size();
m_tasks.erase(
std::remove_if(
m_tasks.begin(),
m_tasks.end(),
[categoryId](const ProjectExplorer::Task &task) {
#if QODEASSIST_QT_CREATOR_VERSION >= QT_VERSION_CHECK(18, 0, 0)
return task.category() == categoryId;
#else
return task.category == categoryId;
#endif
}),
m_tasks.end());
int removedCount = beforeCount - m_tasks.size();
} else {
int clearedCount = m_tasks.size();
m_tasks.clear();
}
}
GetIssuesListTool::GetIssuesListTool(QObject *parent)
: BaseTool(parent)
{
IssuesTracker::instance();
}
QString GetIssuesListTool::id() const
{
return "get_issues_list";
}
QString GetIssuesListTool::displayName() const
{
return "Getting issues list from Qt Creator";
}
QString GetIssuesListTool::description() const
{
return "Read diagnostics from Qt Creator's Issues panel, including the latest build output and "
"live clang-codemodel warnings/errors for open files. Each issue includes file path, "
"line number, severity, and message. Run `build_project` first if you need fresh build "
"diagnostics.";
}
QJsonObject GetIssuesListTool::parametersSchema() const
{
QJsonObject definition;
definition["type"] = "object";
QJsonObject properties;
properties["severity"] = QJsonObject{
{"type", "string"},
{"description", "Filter by severity: 'error', 'warning', or 'all'"},
{"enum", QJsonArray{"error", "warning", "all"}}};
definition["properties"] = properties;
definition["required"] = QJsonArray();
return definition;
}
QFuture<LLMQore::ToolResult> GetIssuesListTool::executeAsync(const QJsonObject &input)
{
return QtConcurrent::run([input]() -> LLMQore::ToolResult {
QString severityFilter = input.value("severity").toString("all");
const auto tasks = IssuesTracker::instance().getTasks();
if (tasks.isEmpty()) {
return LLMQore::ToolResult::text("No issues found in Qt Creator Issues panel.");
}
QStringList results;
results.append(QString("Total issues in panel: %1\n").arg(tasks.size()));
int errorCount = 0;
int warningCount = 0;
int processedCount = 0;
for (const ProjectExplorer::Task &task : tasks) {
#if QODEASSIST_QT_CREATOR_VERSION >= QT_VERSION_CHECK(18, 0, 0)
auto taskType = task.type();
auto taskFile = task.file();
auto taskLine = task.line();
auto taskColumn = task.column();
auto taskCategory = task.category();
#else
auto taskType = task.type;
auto taskFile = task.file;
auto taskLine = task.line;
auto taskColumn = task.column;
auto taskCategory = task.category;
#endif
if (severityFilter == "error" && taskType != ProjectExplorer::Task::Error)
continue;
if (severityFilter == "warning" && taskType != ProjectExplorer::Task::Warning)
continue;
QString typeStr;
switch (taskType) {
case ProjectExplorer::Task::Error:
typeStr = "ERROR";
errorCount++;
break;
case ProjectExplorer::Task::Warning:
typeStr = "WARNING";
warningCount++;
break;
default:
typeStr = "INFO";
break;
}
QString issueText = QString("[%1] %2").arg(typeStr, task.description());
if (!taskFile.isEmpty()) {
issueText += QString("\n File: %1").arg(taskFile.toUrlishString());
if (taskLine > 0) {
issueText += QString(":%1").arg(taskLine);
if (taskColumn > 0) {
issueText += QString(":%1").arg(taskColumn);
}
}
}
if (!taskCategory.toString().isEmpty()) {
issueText += QString("\n Category: %1").arg(taskCategory.toString());
}
results.append(issueText);
processedCount++;
}
QString summary = QString("\nSummary: %1 errors, %2 warnings (processed %3 tasks)")
.arg(errorCount)
.arg(warningCount)
.arg(processedCount);
results.prepend(summary);
return LLMQore::ToolResult::text(results.join("\n\n"));
});
}
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,48 @@
// 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 <LLMQore/BaseTool.hpp>
#include <projectexplorer/task.h>
#include <QList>
#include <QMutex>
namespace QodeAssist::Tools {
class IssuesTracker : public QObject
{
Q_OBJECT
public:
static IssuesTracker &instance();
QList<ProjectExplorer::Task> getTasks() const;
private:
explicit IssuesTracker(QObject *parent = nullptr);
~IssuesTracker() override = default;
void onTaskAdded(const ProjectExplorer::Task &task);
void onTaskRemoved(const ProjectExplorer::Task &task);
void onTasksCleared(Utils::Id categoryId);
QList<ProjectExplorer::Task> m_tasks;
mutable QMutex m_mutex;
};
class GetIssuesListTool : public ::LLMQore::BaseTool
{
Q_OBJECT
public:
explicit GetIssuesListTool(QObject *parent = nullptr);
QString id() const override;
QString displayName() const override;
QString description() const override;
QJsonObject parametersSchema() const override;
QFuture<LLMQore::ToolResult> executeAsync(const QJsonObject &input = QJsonObject()) override;
};
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,129 @@
// 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 "ListProjectFilesTool.hpp"
#include <LLMQore/ToolExceptions.hpp>
#include <logger/Logger.hpp>
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
#include <QDir>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonObject>
#include <QtConcurrent>
namespace QodeAssist::Tools {
ListProjectFilesTool::ListProjectFilesTool(QObject *parent)
: BaseTool(parent)
, m_ignoreManager(new Context::IgnoreManager(this))
{}
QString ListProjectFilesTool::id() const
{
return "list_project_files";
}
QString ListProjectFilesTool::displayName() const
{
return {"Reading project files list"};
}
QString ListProjectFilesTool::description() const
{
return "List every source file tracked by the active Qt Creator project(s), filtered by "
".qodeassistignore. Returns absolute and project-relative paths grouped by project. "
"Useful for discovering the project layout before running focused searches or reads. "
"Takes no parameters.";
}
QJsonObject ListProjectFilesTool::parametersSchema() const
{
QJsonObject definition;
definition["type"] = "object";
definition["properties"] = QJsonObject();
definition["required"] = QJsonArray();
return definition;
}
QFuture<LLMQore::ToolResult> ListProjectFilesTool::executeAsync(const QJsonObject &input)
{
Q_UNUSED(input)
return QtConcurrent::run([this]() -> LLMQore::ToolResult {
QList<ProjectExplorer::Project *> projects = ProjectExplorer::ProjectManager::projects();
if (projects.isEmpty()) {
QString error = "No projects found";
throw LLMQore::ToolRuntimeError(error);
}
QString result;
for (auto project : projects) {
if (!project)
continue;
Utils::FilePaths projectFiles = project->files(ProjectExplorer::Project::SourceFiles);
if (projectFiles.isEmpty()) {
result += QString("Project '%1': No source files found\n\n")
.arg(project->displayName());
continue;
}
QStringList fileList;
QString projectPath = project->projectDirectory().toUrlishString();
QString projectAbsolutePath = project->projectDirectory().toFSPathString();
for (const auto &filePath : projectFiles) {
QString absolutePath = filePath.toUrlishString();
if (m_ignoreManager->shouldIgnore(absolutePath, project)) {
LOG_MESSAGE(
QString("Ignoring file due to .qodeassistignore: %1").arg(absolutePath));
continue;
}
QString relativePath = QDir(projectPath).relativeFilePath(absolutePath);
fileList.append(relativePath);
}
if (fileList.isEmpty()) {
result += QString("Project '%1': No files after applying .qodeassistignore\n\n")
.arg(project->displayName());
continue;
}
fileList.sort();
result += QString("Project '%1' (%2 files):\n")
.arg(project->displayName())
.arg(fileList.size());
result += QString("Project root: %1\n\n").arg(projectAbsolutePath);
for (const QString &file : fileList) {
result += QString("- %1\n").arg(file);
}
result += "\n";
}
return LLMQore::ToolResult::text(result.trimmed());
});
}
QString ListProjectFilesTool::formatFileList(const QStringList &files) const
{
QString result = QString("Project files (%1 total):\n\n").arg(files.size());
for (const QString &file : files) {
result += QString("- %1\n").arg(file);
}
return result;
}
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,31 @@
// 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 <LLMQore/BaseTool.hpp>
#include <context/IgnoreManager.hpp>
namespace QodeAssist::Tools {
class ListProjectFilesTool : public ::LLMQore::BaseTool
{
Q_OBJECT
public:
explicit ListProjectFilesTool(QObject *parent = nullptr);
QString id() const override;
QString displayName() const override;
QString description() const override;
QJsonObject parametersSchema() const override;
QFuture<LLMQore::ToolResult> executeAsync(const QJsonObject &input = QJsonObject()) override;
private:
QString formatFileList(const QStringList &files) const;
Context::IgnoreManager *m_ignoreManager;
};
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,340 @@
// 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 "ProjectSearchTool.hpp"
#include <LLMQore/ToolExceptions.hpp>
#include <cplusplus/Overview.h>
#include <cplusplus/Scope.h>
#include <cplusplus/Symbols.h>
#include <cppeditor/cppmodelmanager.h>
#include <logger/Logger.hpp>
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonObject>
#include <QRegularExpression>
#include <QTextStream>
#include <QtConcurrent>
namespace QodeAssist::Tools {
ProjectSearchTool::ProjectSearchTool(QObject *parent)
: BaseTool(parent)
, m_ignoreManager(new Context::IgnoreManager(this))
{}
QString ProjectSearchTool::id() const
{
return "search_project";
}
QString ProjectSearchTool::displayName() const
{
return "Searching in project";
}
QString ProjectSearchTool::description() const
{
return "Search across all open Qt Creator project(s) for text occurrences or C++ symbol "
"definitions. 'text' mode scans source files line-by-line for literal text or regex. "
"'symbol' mode uses Qt Creator's C++ code model and works only for C++ "
"(not QML, Python, or plain text). Respects .qodeassistignore. "
"Use `find_file` for locating files by name, not this tool.";
}
QJsonObject ProjectSearchTool::parametersSchema() const
{
QJsonObject properties;
properties["query"] = QJsonObject{
{"type", "string"},
{"description", "Text string or symbol name to search for. In text mode with "
"use_regex=true, this is a regular expression."}};
properties["search_type"] = QJsonObject{
{"type", "string"},
{"enum", QJsonArray{"text", "symbol"}},
{"description", "Search mode: 'text' scans file contents, 'symbol' looks up C++ "
"declarations via the code model."}};
properties["symbol_type"] = QJsonObject{
{"type", "string"},
{"enum", QJsonArray{"all", "class", "function", "enum", "variable", "namespace"}},
{"description", "Filter for symbol mode. Default: 'all'. Ignored in text mode."}};
properties["case_sensitive"] = QJsonObject{
{"type", "boolean"},
{"description", "Case-sensitive matching. Default: false."}};
properties["use_regex"] = QJsonObject{
{"type", "boolean"},
{"description", "Treat the query as a regular expression. Default: false."}};
properties["whole_words"] = QJsonObject{
{"type", "boolean"},
{"description", "Match whole words only. Text mode only; ignored in symbol mode. "
"Default: false."}};
properties["file_pattern"] = QJsonObject{
{"type", "string"},
{"description", "Wildcard to restrict which files are searched in text mode, e.g. "
"'*.cpp' or '*.h'. Default: all files."}};
QJsonObject definition;
definition["type"] = "object";
definition["properties"] = properties;
definition["required"] = QJsonArray{"query", "search_type"};
return definition;
}
QFuture<LLMQore::ToolResult> ProjectSearchTool::executeAsync(const QJsonObject &input)
{
return QtConcurrent::run([this, input]() -> LLMQore::ToolResult {
QString query = input["query"].toString().trimmed();
if (query.isEmpty()) {
throw LLMQore::ToolInvalidArgument("Query parameter is required");
}
QString searchTypeStr = input["search_type"].toString();
if (searchTypeStr != "text" && searchTypeStr != "symbol") {
throw LLMQore::ToolInvalidArgument("search_type must be 'text' or 'symbol'");
}
SearchType searchType = (searchTypeStr == "symbol") ? SearchType::Symbol : SearchType::Text;
QList<SearchResult> results;
if (searchType == SearchType::Text) {
bool caseSensitive = input["case_sensitive"].toBool(false);
bool useRegex = input["use_regex"].toBool(false);
bool wholeWords = input["whole_words"].toBool(false);
QString filePattern = input["file_pattern"].toString();
results = searchText(query, caseSensitive, useRegex, wholeWords, filePattern);
} else {
SymbolType symbolType = parseSymbolType(input["symbol_type"].toString());
bool caseSensitive = input["case_sensitive"].toBool(false);
bool useRegex = input["use_regex"].toBool(false);
results = searchSymbols(query, symbolType, caseSensitive, useRegex);
}
if (results.isEmpty()) {
return LLMQore::ToolResult::text(QString("No matches found for '%1'").arg(query));
}
return LLMQore::ToolResult::text(formatResults(results, query));
});
}
QList<ProjectSearchTool::SearchResult> ProjectSearchTool::searchText(
const QString &query,
bool caseSensitive,
bool useRegex,
bool wholeWords,
const QString &filePattern)
{
QList<SearchResult> results;
auto projects = ProjectExplorer::ProjectManager::projects();
if (projects.isEmpty())
return results;
QRegularExpression searchRegex;
if (useRegex) {
QRegularExpression::PatternOptions options = QRegularExpression::MultilineOption;
if (!caseSensitive)
options |= QRegularExpression::CaseInsensitiveOption;
searchRegex.setPattern(query);
searchRegex.setPatternOptions(options);
if (!searchRegex.isValid())
return results;
}
QRegularExpression fileFilter;
if (!filePattern.isEmpty()) {
fileFilter.setPattern(QRegularExpression::wildcardToRegularExpression(filePattern));
}
for (auto project : projects) {
if (!project)
continue;
auto projectFiles = project->files(ProjectExplorer::Project::SourceFiles);
QString projectDir = project->projectDirectory().path();
for (const auto &filePath : projectFiles) {
QString absolutePath = filePath.path();
if (m_ignoreManager->shouldIgnore(absolutePath, project))
continue;
if (!filePattern.isEmpty()) {
QFileInfo fileInfo(absolutePath);
if (!fileFilter.match(fileInfo.fileName()).hasMatch())
continue;
}
QFile file(absolutePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
continue;
QTextStream stream(&file);
int lineNumber = 0;
while (!stream.atEnd()) {
lineNumber++;
QString line = stream.readLine();
bool matched = false;
if (useRegex) {
matched = searchRegex.match(line).hasMatch();
} else {
auto cs = caseSensitive ? Qt::CaseSensitive : Qt::CaseInsensitive;
if (wholeWords) {
QRegularExpression wordRegex(
QString("\\b%1\\b").arg(QRegularExpression::escape(query)),
caseSensitive ? QRegularExpression::NoPatternOption
: QRegularExpression::CaseInsensitiveOption);
matched = wordRegex.match(line).hasMatch();
} else {
matched = line.contains(query, cs);
}
}
if (matched) {
SearchResult result;
result.filePath = absolutePath;
result.relativePath = QDir(projectDir).relativeFilePath(absolutePath);
result.content = line.trimmed();
result.lineNumber = lineNumber;
results.append(result);
}
}
}
}
return results;
}
QList<ProjectSearchTool::SearchResult> ProjectSearchTool::searchSymbols(
const QString &query, SymbolType symbolType, bool caseSensitive, bool useRegex)
{
QList<SearchResult> results;
auto modelManager = CppEditor::CppModelManager::instance();
if (!modelManager)
return results;
QRegularExpression searchRegex;
if (useRegex) {
QRegularExpression::PatternOptions options = caseSensitive
? QRegularExpression::NoPatternOption
: QRegularExpression::CaseInsensitiveOption;
searchRegex.setPattern(query);
searchRegex.setPatternOptions(options);
if (!searchRegex.isValid())
return results;
}
CPlusPlus::Overview overview;
auto snapshot = modelManager->snapshot();
for (auto it = snapshot.begin(); it != snapshot.end(); ++it) {
auto document = it.value();
if (!document || !document->globalNamespace())
continue;
QString filePath = document->filePath().path();
if (m_ignoreManager->shouldIgnore(filePath, nullptr))
continue;
auto searchInScope = [&](auto self, CPlusPlus::Scope *scope) -> void {
if (!scope)
return;
for (unsigned i = 0; i < scope->memberCount(); ++i) {
auto symbol = scope->memberAt(i);
if (!symbol || !symbol->name())
continue;
QString symbolName = overview.prettyName(symbol->name());
bool nameMatches = false;
if (useRegex) {
nameMatches = searchRegex.match(symbolName).hasMatch();
} else {
nameMatches = caseSensitive
? symbolName == query
: symbolName.compare(query, Qt::CaseInsensitive) == 0;
}
bool typeMatches = (symbolType == SymbolType::All)
|| (symbolType == SymbolType::Class && symbol->asClass())
|| (symbolType == SymbolType::Function && symbol->asFunction())
|| (symbolType == SymbolType::Enum && symbol->asEnum())
|| (symbolType == SymbolType::Variable && symbol->asDeclaration())
|| (symbolType == SymbolType::Namespace && symbol->asNamespace());
if (nameMatches && typeMatches) {
SearchResult result;
result.filePath = filePath;
auto projects = ProjectExplorer::ProjectManager::projects();
if (!projects.isEmpty()) {
QString projectDir = projects.first()->projectDirectory().path();
result.relativePath = QDir(projectDir).relativeFilePath(filePath);
} else {
result.relativePath = QFileInfo(filePath).fileName();
}
result.content = symbolName;
result.lineNumber = symbol->line();
result.context = overview.prettyType(symbol->type());
results.append(result);
}
if (auto nestedScope = symbol->asScope()) {
self(self, nestedScope);
}
}
};
searchInScope(searchInScope, document->globalNamespace());
}
return results;
}
ProjectSearchTool::SymbolType ProjectSearchTool::parseSymbolType(const QString &typeStr)
{
if (typeStr == "class")
return SymbolType::Class;
if (typeStr == "function")
return SymbolType::Function;
if (typeStr == "enum")
return SymbolType::Enum;
if (typeStr == "variable")
return SymbolType::Variable;
if (typeStr == "namespace")
return SymbolType::Namespace;
return SymbolType::All;
}
QString ProjectSearchTool::formatResults(const QList<SearchResult> &results, const QString &query)
{
QString output = QString("Query: %1\n Found %2 matches:\n\n").arg(query).arg(results.size());
int count = 0;
for (const auto &r : results) {
if (++count > 100) {
output += QString("... and %1 more matches").arg(results.size() - 20);
break;
}
output += QString("%1:%2: %3\n").arg(r.relativePath).arg(r.lineNumber).arg(r.content);
}
return output;
}
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,57 @@
// 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 <context/IgnoreManager.hpp>
#include <LLMQore/BaseTool.hpp>
#include <QFuture>
#include <QJsonObject>
#include <QObject>
namespace QodeAssist::Tools {
class ProjectSearchTool : public ::LLMQore::BaseTool
{
Q_OBJECT
public:
explicit ProjectSearchTool(QObject *parent = nullptr);
QString id() const override;
QString displayName() const override;
QString description() const override;
QJsonObject parametersSchema() const override;
QFuture<LLMQore::ToolResult> executeAsync(const QJsonObject &input) override;
private:
enum class SearchType { Text, Symbol };
enum class SymbolType { All, Class, Function, Enum, Variable, Namespace };
struct SearchResult
{
QString filePath;
QString relativePath;
QString content;
int lineNumber = 0;
QString context;
};
QList<SearchResult> searchText(
const QString &query,
bool caseSensitive,
bool useRegex,
bool wholeWords,
const QString &filePattern);
QList<SearchResult> searchSymbols(
const QString &query, SymbolType symbolType, bool caseSensitive, bool useRegex);
SymbolType parseSymbolType(const QString &typeStr);
QString formatResults(const QList<SearchResult> &results, const QString &query);
Context::IgnoreManager *m_ignoreManager;
};
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,107 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ReadFileTool.hpp"
#include "FileSearchUtils.hpp"
#include <LLMQore/ToolExceptions.hpp>
#include <context/ProjectUtils.hpp>
#include <logger/Logger.hpp>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonObject>
#include <QtConcurrent>
namespace QodeAssist::Tools {
ReadFileTool::ReadFileTool(QObject *parent)
: BaseTool(parent)
{}
QString ReadFileTool::id() const
{
return "read_file";
}
QString ReadFileTool::displayName() const
{
return "Reading file";
}
QString ReadFileTool::description() const
{
return "Read the text content of a file. Accepts either an absolute path or a path "
"relative to the project root. Reading files outside the active project requires "
"the 'Allow file access outside project' option in settings. Use `find_file` first "
"if you only know a partial filename.";
}
QJsonObject ReadFileTool::parametersSchema() const
{
QJsonObject properties;
properties["file_path"] = QJsonObject{
{"type", "string"},
{"description",
"Absolute path (e.g. /path/to/file.cpp) or project-relative path (e.g. src/main.cpp). "
"Relative paths are resolved against the root of the active project."}};
QJsonObject definition;
definition["type"] = "object";
definition["properties"] = properties;
definition["required"] = QJsonArray{"file_path"};
return definition;
}
QFuture<LLMQore::ToolResult> ReadFileTool::executeAsync(const QJsonObject &input)
{
return QtConcurrent::run([input]() -> LLMQore::ToolResult {
QString rawPath = input["file_path"].toString().trimmed();
if (rawPath.isEmpty()) {
throw LLMQore::ToolInvalidArgument(
"'file_path' parameter is required and cannot be empty");
}
QFileInfo pathInfo(rawPath);
QString absolutePath;
if (pathInfo.isAbsolute()) {
absolutePath = rawPath;
} else {
QString projectRoot = Context::ProjectUtils::getProjectRoot();
if (projectRoot.isEmpty()) {
throw LLMQore::ToolRuntimeError(
QString("Cannot resolve relative path '%1': no project is open. "
"Provide an absolute path or open a project.")
.arg(rawPath));
}
absolutePath = QDir(projectRoot).absoluteFilePath(rawPath);
LOG_MESSAGE(QString("ReadFileTool: Resolved relative path '%1' to '%2'")
.arg(rawPath, absolutePath));
}
QFileInfo finalInfo(absolutePath);
if (!finalInfo.exists() || !finalInfo.isFile()) {
throw LLMQore::ToolRuntimeError(QString("File does not exist: %1").arg(absolutePath));
}
QString content = FileSearchUtils::readFileContent(absolutePath);
if (content.isNull()) {
throw LLMQore::ToolRuntimeError(
QString("Cannot read file '%1'. It may be outside the project scope "
"(enable 'Allow file access outside project' in settings), unreadable, "
"or use an unsupported encoding.")
.arg(absolutePath));
}
QString result = QString("File: %1\n\n%2").arg(absolutePath, content);
return LLMQore::ToolResult::text(result);
});
}
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,28 @@
// 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 <LLMQore/BaseTool.hpp>
#include <QFuture>
#include <QJsonObject>
#include <QObject>
namespace QodeAssist::Tools {
class ReadFileTool : public ::LLMQore::BaseTool
{
Q_OBJECT
public:
explicit ReadFileTool(QObject *parent = nullptr);
QString id() const override;
QString displayName() const override;
QString description() const override;
QJsonObject parametersSchema() const override;
QFuture<LLMQore::ToolResult> executeAsync(const QJsonObject &input) override;
};
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,197 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ReadOriginalHistoryTool.hpp"
#include <LLMQore/ToolExceptions.hpp>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QtConcurrent>
namespace QodeAssist::Tools {
namespace {
QString roleName(int role)
{
switch (role) {
case 0:
return QStringLiteral("system");
case 1:
return QStringLiteral("user");
case 2:
return QStringLiteral("assistant");
case 3:
return QStringLiteral("tool");
case 4:
return QStringLiteral("file_edit");
case 5:
return QStringLiteral("thinking");
default:
return QStringLiteral("unknown");
}
}
QJsonObject readJsonObject(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
return {};
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
return doc.isObject() ? doc.object() : QJsonObject{};
}
QString resolveRootHistoryPath(const QString &sessionPath)
{
QString current = sessionPath;
QString rootPath;
for (int depth = 0; depth < 32; ++depth) {
const QJsonObject obj = readJsonObject(current);
const QString parent = obj.value("compressedFrom").toString();
if (parent.isEmpty() || parent == current)
break;
if (!QFile::exists(parent))
break;
rootPath = parent;
current = parent;
}
return rootPath;
}
} // namespace
ReadOriginalHistoryTool::ReadOriginalHistoryTool(QObject *parent)
: BaseTool(parent)
{}
QString ReadOriginalHistoryTool::id() const
{
return "read_original_history";
}
QString ReadOriginalHistoryTool::displayName() const
{
return "Reading pre-compression history";
}
QString ReadOriginalHistoryTool::description() const
{
return "Read the original, full chat history from before this conversation was "
"compressed into a summary. Use this only when the summary in context is "
"missing a detail you need (an exact code snippet, file path, decision, or "
"wording). The result can be large, so prefer the 'query' parameter to search "
"and 'offset'/'limit' to page through messages. Returns nothing useful if the "
"conversation was never compressed.";
}
QJsonObject ReadOriginalHistoryTool::parametersSchema() const
{
QJsonObject properties;
properties["query"] = QJsonObject{
{"type", "string"},
{"description",
"Optional case-insensitive substring. When set, only messages whose content "
"contains it are returned."}};
properties["role"] = QJsonObject{
{"type", "string"},
{"description",
"Optional role filter: 'user', 'assistant', 'system' or 'tool'."}};
properties["offset"] = QJsonObject{
{"type", "integer"},
{"description", "Index of the first matching message to return (default 0)."}};
properties["limit"] = QJsonObject{
{"type", "integer"},
{"description", "Maximum number of messages to return (default 20)."}};
QJsonObject definition;
definition["type"] = "object";
definition["properties"] = properties;
definition["required"] = QJsonArray{};
return definition;
}
QFuture<LLMQore::ToolResult> ReadOriginalHistoryTool::executeAsync(const QJsonObject &input)
{
QString sessionPath;
{
QMutexLocker locker(&m_mutex);
sessionPath = m_currentSessionId;
}
return QtConcurrent::run([input, sessionPath]() -> LLMQore::ToolResult {
if (sessionPath.isEmpty()) {
throw LLMQore::ToolRuntimeError(
"No active chat session, cannot locate pre-compression history.");
}
const QString rootPath = resolveRootHistoryPath(sessionPath);
if (rootPath.isEmpty()) {
return LLMQore::ToolResult::text(
"This conversation was never compressed; there is no separate "
"pre-compression history. The messages already in context are the full "
"history.");
}
const QJsonObject root = readJsonObject(rootPath);
const QJsonArray messages = root.value("messages").toArray();
const QString query = input.value("query").toString().trimmed();
const QString roleFilter = input.value("role").toString().trimmed().toLower();
const int offset = qMax(0, input.value("offset").toInt(0));
const int limit = qBound(1, input.value("limit").toInt(20), 200);
QStringList matched;
int matchCount = 0;
for (int i = 0; i < messages.size(); ++i) {
const QJsonObject msg = messages.at(i).toObject();
const QString role = roleName(msg.value("role").toInt());
const QString content = msg.value("content").toString();
if (!roleFilter.isEmpty() && role != roleFilter)
continue;
if (!query.isEmpty() && !content.contains(query, Qt::CaseInsensitive))
continue;
++matchCount;
if (matchCount <= offset || matched.size() >= limit)
continue;
matched.append(QString("[#%1 %2]\n%3").arg(i).arg(role, content));
}
const int shown = matched.size();
QString header = QString("Pre-compression history (%1): %2 matching message(s)")
.arg(rootPath)
.arg(matchCount);
if (shown < matchCount || offset > 0) {
header += QString(", showing %1-%2")
.arg(offset + 1)
.arg(offset + shown);
}
if (shown == 0)
return LLMQore::ToolResult::text(header + "\n\nNo messages to display.");
return LLMQore::ToolResult::text(header + "\n\n" + matched.join("\n\n---\n\n"));
});
}
void ReadOriginalHistoryTool::setCurrentSessionId(const QString &sessionId)
{
QMutexLocker locker(&m_mutex);
m_currentSessionId = sessionId;
}
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,35 @@
// 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 <LLMQore/BaseTool.hpp>
#include <QMutex>
#include <QString>
namespace QodeAssist::Tools {
class ReadOriginalHistoryTool : public ::LLMQore::BaseTool
{
Q_OBJECT
public:
explicit ReadOriginalHistoryTool(QObject *parent = nullptr);
QString id() const override;
QString displayName() const override;
QString description() const override;
QJsonObject parametersSchema() const override;
QFuture<LLMQore::ToolResult> executeAsync(const QJsonObject &input = QJsonObject()) override;
void setCurrentSessionId(const QString &sessionId);
private:
mutable QMutex m_mutex;
QString m_currentSessionId;
};
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,85 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "SkillTool.hpp"
#include <LLMQore/ToolExceptions.hpp>
#include <optional>
#include <QJsonArray>
#include <QtConcurrent>
#include "skills/AgentSkill.hpp"
#include "skills/SkillsManager.hpp"
namespace QodeAssist::Tools {
SkillTool::SkillTool(Skills::SkillsManager *skillsManager, QObject *parent)
: BaseTool(parent)
, m_skillsManager(skillsManager)
{}
QString SkillTool::id() const
{
return "load_skill";
}
QString SkillTool::displayName() const
{
return "Loading skill";
}
QString SkillTool::description() const
{
return "Load the full instructions of a skill by name. The Available Skills catalog in "
"the system prompt lists each skill's name and a short description. When a request "
"matches a skill, call this tool with that skill's name to load its complete "
"instructions, then follow them.";
}
QJsonObject SkillTool::parametersSchema() const
{
QJsonObject properties;
properties["name"] = QJsonObject{
{"type", "string"},
{"description",
"Exact name of the skill to load, as shown in the Available Skills catalog."}};
QJsonObject definition;
definition["type"] = "object";
definition["properties"] = properties;
definition["required"] = QJsonArray{"name"};
return definition;
}
QFuture<LLMQore::ToolResult> SkillTool::executeAsync(const QJsonObject &input)
{
const QString name = input["name"].toString().trimmed();
const std::optional<Skills::AgentSkill> found
= m_skillsManager ? m_skillsManager->findByName(name) : std::nullopt;
return QtConcurrent::run([name, found]() -> LLMQore::ToolResult {
if (name.isEmpty()) {
throw LLMQore::ToolInvalidArgument(
"'name' parameter is required and cannot be empty");
}
if (!found) {
throw LLMQore::ToolRuntimeError(
QString("Unknown skill: '%1'. Use a skill name from the Available Skills "
"catalog in the system prompt.")
.arg(name));
}
if (found->body.isEmpty()) {
throw LLMQore::ToolRuntimeError(
QString("Skill '%1' has no instructions.").arg(found->name));
}
return LLMQore::ToolResult::text(
QString("Skill: %1\n\n%2").arg(found->name, found->body));
});
}
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,36 @@
// 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 <LLMQore/BaseTool.hpp>
#include <QFuture>
#include <QJsonObject>
#include <QObject>
#include <QPointer>
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Tools {
class SkillTool : public ::LLMQore::BaseTool
{
Q_OBJECT
public:
explicit SkillTool(Skills::SkillsManager *skillsManager, QObject *parent = nullptr);
QString id() const override;
QString displayName() const override;
QString description() const override;
QJsonObject parametersSchema() const override;
QFuture<LLMQore::ToolResult> executeAsync(const QJsonObject &input) override;
private:
QPointer<Skills::SkillsManager> m_skillsManager;
};
} // namespace QodeAssist::Tools

335
sources/tools/TodoTool.cpp Normal file
View File

@@ -0,0 +1,335 @@
// 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 "TodoTool.hpp"
#include <LLMQore/ToolExceptions.hpp>
#include <QJsonArray>
#include <QJsonObject>
#include <QMutexLocker>
#include <QtConcurrent>
namespace QodeAssist::Tools {
TodoTool::TodoTool(QObject *parent)
: BaseTool(parent)
{}
QString TodoTool::id() const
{
return "todo_tool";
}
QString TodoTool::displayName() const
{
return "Managing TODO list for task tracking";
}
QString TodoTool::description() const
{
return "Track and organize multi-step tasks during complex operations that require multiple "
"sequential steps. "
"**Use when planning 3+ step workflows.** "
"Operations: 'add' - provide array of task descriptions to create full plan at once, "
"'complete' - provide array of task IDs to mark finished steps, 'list' - review "
"progress. "
"Helpful for: large refactorings, feature implementations, debugging workflows. "
"The list persists throughout the conversation.";
}
QJsonObject TodoTool::parametersSchema() const
{
QJsonObject definition;
definition["type"] = "object";
QJsonObject properties;
QJsonObject operationProp;
operationProp["type"] = "string";
operationProp["description"] = "Operation: 'add' (create tasks), 'complete' (mark tasks as "
"done), 'list' (show all tasks)";
QJsonArray operationEnum;
operationEnum.append("add");
operationEnum.append("complete");
operationEnum.append("list");
operationProp["enum"] = operationEnum;
properties["operation"] = operationProp;
QJsonObject tasksProp;
tasksProp["type"] = "array";
QJsonObject tasksItems;
tasksItems["type"] = "string";
tasksProp["items"] = tasksItems;
tasksProp["description"]
= "Array of task descriptions to create (required for 'add' operation). "
"Create all subtasks at once, e.g.: ['Step 1: ...', 'Step 2: ...', 'Step 3: ...']";
properties["tasks"] = tasksProp;
QJsonObject todoIdsProp;
todoIdsProp["type"] = "array";
QJsonObject todoIdsItems;
todoIdsItems["type"] = "integer";
todoIdsProp["items"] = todoIdsItems;
todoIdsProp["description"]
= "Array of todo item IDs to mark as completed (required for 'complete' operation). "
"Example: [1, 2, 5] to complete tasks #1, #2, and #5";
properties["todo_ids"] = todoIdsProp;
definition["properties"] = properties;
QJsonArray required;
required.append("operation");
definition["required"] = required;
return definition;
}
QFuture<LLMQore::ToolResult> TodoTool::executeAsync(const QJsonObject &input)
{
return QtConcurrent::run([this, input]() -> LLMQore::ToolResult {
QMutexLocker sessionLocker(&m_mutex);
QString sessionId = m_currentSessionId.isEmpty() ? "current" : m_currentSessionId;
sessionLocker.unlock();
const QString operation = input.value("operation").toString();
if (operation == "add") {
if (!input.contains("tasks") || !input.value("tasks").isArray()) {
throw LLMQore::ToolRuntimeError(
tr("Error: 'tasks' parameter (array) is required for 'add' operation. "
"Example: {\"operation\": \"add\", \"tasks\": [\"Task 1\", \"Task 2\"]}"));
}
const QJsonArray tasksArray = input.value("tasks").toArray();
if (tasksArray.isEmpty()) {
throw LLMQore::ToolRuntimeError(
tr("Error: 'tasks' array cannot be empty. Provide at least one task."));
}
QStringList tasks;
for (const QJsonValue &taskValue : tasksArray) {
QString task = taskValue.toString().trimmed();
if (!task.isEmpty()) {
tasks.append(task);
}
}
if (tasks.isEmpty()) {
throw LLMQore::ToolRuntimeError(
tr("Error: All tasks in 'tasks' array are empty strings."));
}
return LLMQore::ToolResult::text(addTodos(sessionId, tasks));
} else if (operation == "complete") {
if (!input.contains("todo_ids") || !input.value("todo_ids").isArray()) {
throw LLMQore::ToolRuntimeError(
tr("Error: 'todo_ids' parameter (array) is required for 'complete' operation. "
"Example: {\"operation\": \"complete\", \"todo_ids\": [1, 2, 3]}"));
}
const QJsonArray idsArray = input.value("todo_ids").toArray();
if (idsArray.isEmpty()) {
throw LLMQore::ToolRuntimeError(
tr("Error: 'todo_ids' array cannot be empty. Provide at least one ID."));
}
QList<int> ids;
for (const QJsonValue &idValue : idsArray) {
int id = idValue.toInt(-1);
if (id > 0) {
ids.append(id);
}
}
if (ids.isEmpty()) {
throw LLMQore::ToolRuntimeError(
tr("Error: All IDs in 'todo_ids' array are invalid. IDs must be positive "
"integers."));
}
return LLMQore::ToolResult::text(completeTodos(sessionId, ids));
} else if (operation == "list") {
return LLMQore::ToolResult::text(listTodos(sessionId));
} else {
throw LLMQore::ToolRuntimeError(
tr("Error: Unknown operation '%1'. Valid operations: 'add', 'complete', 'list'")
.arg(operation));
}
});
}
void TodoTool::setCurrentSessionId(const QString &sessionId)
{
QMutexLocker locker(&m_mutex);
m_currentSessionId = sessionId;
}
void TodoTool::clearSession(const QString &sessionId)
{
QMutexLocker locker(&m_mutex);
m_sessionTodos.remove(sessionId);
m_sessionNextId.remove(sessionId);
}
QString TodoTool::addTodos(const QString &sessionId, const QStringList &tasks)
{
QMutexLocker locker(&m_mutex);
if (!m_sessionTodos.contains(sessionId)) {
m_sessionTodos[sessionId] = QHash<int, TodoItem>();
m_sessionNextId[sessionId] = 1;
}
for (const QString &task : tasks) {
const int newId = m_sessionNextId[sessionId]++;
m_sessionTodos[sessionId][newId] = {newId, task, false};
}
const QString summary = (tasks.size() == 1) ? tr("✓ Added 1 new task")
: tr("✓ Added %1 new tasks").arg(tasks.size());
return QString("%1\n\n%2").arg(summary, listTodosLocked(sessionId));
}
QString TodoTool::completeTodos(const QString &sessionId, const QList<int> &todoIds)
{
QMutexLocker locker(&m_mutex);
if (!m_sessionTodos.contains(sessionId)) {
throw LLMQore::ToolRuntimeError(tr("Error: No todos found in this session"));
}
auto &todos = m_sessionTodos[sessionId];
int completedCount = 0;
int alreadyCompletedCount = 0;
QStringList notFound;
for (const int todoId : todoIds) {
if (!todos.contains(todoId)) {
notFound.append(QString("#%1").arg(todoId));
continue;
}
TodoItem &item = todos[todoId];
if (item.completed) {
alreadyCompletedCount++;
} else {
item.completed = true;
completedCount++;
}
}
QStringList messages;
if (completedCount > 0) {
messages.append((completedCount == 1) ? tr("✓ Marked 1 task as completed")
: tr("✓ Marked %1 tasks as completed")
.arg(completedCount));
}
if (alreadyCompletedCount > 0) {
messages.append(tr("⚠ %1 already completed").arg(alreadyCompletedCount));
}
if (!notFound.isEmpty()) {
messages.append(tr("❌ Not found: %1").arg(notFound.join(", ")));
}
const QString summary = messages.join(", ");
return QString("%1\n\n%2").arg(summary, listRemainingTodosLocked(sessionId));
}
QString TodoTool::listTodos(const QString &sessionId) const
{
QMutexLocker locker(&m_mutex);
return listTodosLocked(sessionId);
}
QString TodoTool::listTodosLocked(const QString &sessionId) const
{
const auto it = m_sessionTodos.constFind(sessionId);
if (it == m_sessionTodos.constEnd() || it->isEmpty()) {
return tr("📋 TODO List: (empty)");
}
const auto &todos = *it;
QList<int> ids = todos.keys();
std::sort(ids.begin(), ids.end());
QStringList lines;
lines.reserve(ids.size() + 4);
lines.append(tr("📋 TODO List:"));
lines.append("");
int completedCount = 0;
for (const int id : ids) {
const TodoItem &item = todos[id];
const QString checkbox = item.completed ? "[x]" : "[ ]";
const QString strikethrough = item.completed ? QString("~~") : QString("");
lines.append(QString("%1 **#%2** %3%4%5")
.arg(checkbox)
.arg(id)
.arg(strikethrough, item.task, strikethrough));
if (item.completed) {
completedCount++;
}
}
lines.append("");
const int totalCount = ids.size();
const int percentage = totalCount > 0 ? (completedCount * 100) / totalCount : 0;
lines.append(
tr("Progress: %1/%2 completed (%3%)").arg(completedCount).arg(totalCount).arg(percentage));
return lines.join("\n");
}
QString TodoTool::listRemainingTodosLocked(const QString &sessionId) const
{
const auto it = m_sessionTodos.constFind(sessionId);
if (it == m_sessionTodos.constEnd() || it->isEmpty()) {
return tr("📋 All tasks completed! 🎉");
}
const auto &todos = *it;
QList<int> ids = todos.keys();
std::sort(ids.begin(), ids.end());
int completedCount = 0;
QStringList remainingLines;
for (const int id : ids) {
const TodoItem &item = todos[id];
if (item.completed) {
completedCount++;
} else {
remainingLines.append(QString("[ ] **#%1** %2").arg(id).arg(item.task));
}
}
if (remainingLines.isEmpty()) {
return tr("📋 All tasks completed! 🎉");
}
QStringList lines;
lines.reserve(remainingLines.size() + 4);
lines.append(tr("📋 Remaining tasks:"));
lines.append("");
lines.append(remainingLines);
lines.append("");
const int totalCount = ids.size();
const int percentage = totalCount > 0 ? (completedCount * 100) / totalCount : 0;
lines.append(
tr("Progress: %1/%2 completed (%3%)").arg(completedCount).arg(totalCount).arg(percentage));
return lines.join("\n");
}
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,52 @@
// 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 <LLMQore/BaseTool.hpp>
#include <QHash>
#include <QMutex>
#include <QString>
namespace QodeAssist::Tools {
struct TodoItem
{
int id;
QString task;
bool completed;
};
class TodoTool : public ::LLMQore::BaseTool
{
Q_OBJECT
public:
explicit TodoTool(QObject *parent = nullptr);
QString id() const override;
QString displayName() const override;
QString description() const override;
QJsonObject parametersSchema() const override;
QFuture<LLMQore::ToolResult> executeAsync(const QJsonObject &input = QJsonObject()) override;
void setCurrentSessionId(const QString &sessionId);
void clearSession(const QString &sessionId);
private:
QString addTodos(const QString &sessionId, const QStringList &tasks);
QString completeTodos(const QString &sessionId, const QList<int> &todoIds);
QString listTodos(const QString &sessionId) const;
QString listTodosLocked(const QString &sessionId) const;
QString listRemainingTodosLocked(const QString &sessionId) const;
mutable QMutex m_mutex;
QString m_currentSessionId;
QHash<QString, QHash<int, TodoItem>> m_sessionTodos;
QHash<QString, int> m_sessionNextId;
};
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,54 @@
// 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 <QException>
#include <QString>
namespace QodeAssist::Tools {
class ToolException : public QException
{
public:
explicit ToolException(const QString &message)
: m_message(message)
, m_stdMessage(message.toStdString())
{}
void raise() const override { throw *this; }
ToolException *clone() const override { return new ToolException(*this); }
const char *what() const noexcept override { return m_stdMessage.c_str(); }
QString message() const { return m_message; }
private:
QString m_message;
std::string m_stdMessage;
};
class ToolRuntimeError : public ToolException
{
public:
explicit ToolRuntimeError(const QString &message)
: ToolException(message)
{}
void raise() const override { throw *this; }
ToolRuntimeError *clone() const override { return new ToolRuntimeError(*this); }
};
class ToolInvalidArgument : public ToolException
{
public:
explicit ToolInvalidArgument(const QString &message)
: ToolException(message)
{}
void raise() const override { throw *this; }
ToolInvalidArgument *clone() const override { return new ToolInvalidArgument(*this); }
};
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,93 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ToolsRegistration.hpp"
#include <LLMQore/ToolsManager.hpp>
#include <settings/ToolsSettings.hpp>
#include <utils/aspects.h>
#include "BuildProjectTool.hpp"
#include "CreateNewFileTool.hpp"
#include "EditFileTool.hpp"
#include "ExecuteTerminalCommandTool.hpp"
#include "FindFileTool.hpp"
#include "GetIssuesListTool.hpp"
#include "ListProjectFilesTool.hpp"
#include "ProjectSearchTool.hpp"
#include "ReadFileTool.hpp"
#include "ReadOriginalHistoryTool.hpp"
#include "SkillTool.hpp"
#include "TodoTool.hpp"
namespace QodeAssist::Tools {
namespace {
template<typename ToolT>
void wireTool(::LLMQore::ToolsManager *manager,
Utils::BoolAspect &aspect,
const QString &toolId)
{
auto sync = [manager, toolId, &aspect]() {
const bool wanted = aspect.volatileValue();
const bool present = manager->tool(toolId) != nullptr;
if (wanted && !present) {
manager->addTool(new ToolT(manager));
} else if (!wanted && present) {
manager->removeTool(toolId);
}
};
sync();
QObject::connect(&aspect, &Utils::BoolAspect::volatileValueChanged, manager, sync);
QObject::connect(&aspect, &Utils::BaseAspect::changed, manager, sync);
}
} // namespace
void registerQodeAssistTools(::LLMQore::ToolsManager *manager)
{
auto &s = Settings::toolsSettings();
wireTool<ListProjectFilesTool>(manager, s.enableListProjectFilesTool, "list_project_files");
wireTool<FindFileTool>(manager, s.enableFindFileTool, "find_file");
wireTool<ReadFileTool>(manager, s.enableReadFileTool, "read_file");
wireTool<ProjectSearchTool>(manager, s.enableProjectSearchTool, "search_project");
wireTool<CreateNewFileTool>(manager, s.enableCreateNewFileTool, "create_new_file");
wireTool<EditFileTool>(manager, s.enableEditFileTool, "edit_file");
wireTool<BuildProjectTool>(manager, s.enableBuildProjectTool, "build_project");
wireTool<GetIssuesListTool>(manager, s.enableGetIssuesListTool, "get_issues_list");
wireTool<ExecuteTerminalCommandTool>(
manager, s.enableTerminalCommandTool, "execute_terminal_command");
wireTool<TodoTool>(manager, s.enableTodoTool, "todo_tool");
wireTool<ReadOriginalHistoryTool>(
manager, s.enableReadOriginalHistoryTool, "read_original_history");
}
void registerSkillTool(
::LLMQore::ToolsManager *manager, Skills::SkillsManager *skillsManager)
{
Utils::BoolAspect &aspect = Settings::toolsSettings().enableSkillTool;
const QString toolId = QStringLiteral("load_skill");
auto sync = [manager, toolId, &aspect, skillsManager]() {
const bool wanted = aspect.volatileValue();
const bool present = manager->tool(toolId) != nullptr;
if (wanted && !present) {
manager->addTool(new SkillTool(skillsManager, manager));
} else if (!wanted && present) {
manager->removeTool(toolId);
}
};
sync();
QObject::connect(&aspect, &Utils::BoolAspect::volatileValueChanged, manager, sync);
QObject::connect(&aspect, &Utils::BaseAspect::changed, manager, sync);
}
} // namespace QodeAssist::Tools

View File

@@ -0,0 +1,22 @@
// 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
namespace LLMQore {
class ToolsManager;
}
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Tools {
void registerQodeAssistTools(::LLMQore::ToolsManager *manager);
void registerSkillTool(
::LLMQore::ToolsManager *manager, Skills::SkillsManager *skillsManager);
} // namespace QodeAssist::Tools