Compare commits

...

18 Commits

Author SHA1 Message Date
37e41d3b76 chore: Upgrade plugin version to 0.9.3 2025-11-28 16:21:28 +01:00
2d5667d8ca refactor: Reworking attaching files (#280)
chore: Upgrade chat format version
2025-11-28 16:17:25 +01:00
22377c8f6a feat: Add temp file storage for chat (#279)
* fix: Add signature to chat history
* feat: Add file storage for chat
2025-11-28 13:59:43 +01:00
595895840a fix: Adapt widget to content size 2025-11-28 13:59:14 +01:00
f6d647d5c8 fix: Add signature to chat history 2025-11-28 12:28:07 +01:00
1f9c60ffb2 doc: Fix description for code completion and quick refactor (#278) 2025-11-27 14:02:27 +01:00
6ec4a61c0c chore: Update plugin to 0.9.2 2025-11-27 02:10:51 +01:00
7feb088de3 fix: Change windows command and checks (#277) 2025-11-27 01:53:41 +01:00
627a821115 fix: Edit file tool take only absolute or relative path to file 2025-11-27 01:53:27 +01:00
9b0ae98f02 feat: Improve execute terminal commands tool 2025-11-27 01:12:21 +01:00
85a7bba90e refactor: Change top bar layout and tools/thinking settings 2025-11-27 00:39:37 +01:00
b18ef4c400 feat: Add build folder path to system prompt 2025-11-26 19:48:54 +01:00
bbacdfc22a feat: Add run option to build project tool 2025-11-26 19:23:56 +01:00
670f81c3dd feat: Add side by side refactor widget (#276) 2025-11-26 15:45:22 +01:00
b4f31dee23 feat: Add ollama thinking feature (#275) 2025-11-26 10:32:04 +01:00
dc6ec4fb4f refactor: Add reserve size for commands 2025-11-25 23:33:52 +01:00
07de415346 feat: Add execution command tool (#273) 2025-11-23 12:52:20 +01:00
a15f64a234 feat: Improve build project tool (#272) 2025-11-22 13:15:15 +01:00
62 changed files with 4060 additions and 499 deletions

View File

@ -69,6 +69,7 @@ add_qtc_plugin(QodeAssist
QodeAssistConstants.hpp
QodeAssisttr.h
LLMClientInterface.hpp LLMClientInterface.cpp
RefactorContextHelper.hpp
templates/Templates.hpp
templates/CodeLlamaFim.hpp
templates/Ollama.hpp
@ -121,6 +122,10 @@ add_qtc_plugin(QodeAssist
widgets/QuickRefactorDialog.hpp widgets/QuickRefactorDialog.cpp
widgets/CustomInstructionsManager.hpp widgets/CustomInstructionsManager.cpp
widgets/AddCustomInstructionDialog.hpp widgets/AddCustomInstructionDialog.cpp
widgets/RefactorWidget.hpp widgets/RefactorWidget.cpp
widgets/RefactorWidgetHandler.hpp widgets/RefactorWidgetHandler.cpp
widgets/ContextExtractor.hpp
widgets/DiffStatistics.hpp
QuickRefactorHandler.hpp QuickRefactorHandler.cpp
tools/ToolsFactory.hpp tools/ToolsFactory.cpp
@ -132,6 +137,7 @@ add_qtc_plugin(QodeAssist
tools/CreateNewFileTool.hpp tools/CreateNewFileTool.cpp
tools/EditFileTool.hpp tools/EditFileTool.cpp
tools/BuildProjectTool.hpp tools/BuildProjectTool.cpp
tools/ExecuteTerminalCommandTool.hpp tools/ExecuteTerminalCommandTool.cpp
tools/ProjectSearchTool.hpp tools/ProjectSearchTool.cpp
tools/FindAndReadFileTool.hpp tools/FindAndReadFileTool.cpp
tools/FileSearchUtils.hpp tools/FileSearchUtils.cpp

View File

@ -49,6 +49,8 @@ qt_add_qml_module(QodeAssistChatView
icons/reject-changes-button.svg
icons/thinking-icon-on.svg
icons/thinking-icon-off.svg
icons/tools-icon-on.svg
icons/tools-icon-off.svg
SOURCES
ChatWidget.hpp ChatWidget.cpp
@ -61,6 +63,7 @@ qt_add_qml_module(QodeAssistChatView
ChatView.hpp ChatView.cpp
ChatData.hpp
FileItem.hpp FileItem.cpp
ChatFileManager.hpp ChatFileManager.cpp
)
target_link_libraries(QodeAssistChatView

View File

@ -0,0 +1,206 @@
/*
* Copyright (C) 2024-2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#include "ChatFileManager.hpp"
#include "Logger.hpp"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QStandardPaths>
#include <QUuid>
#include <QDateTime>
#include <QRegularExpression>
#include <coreplugin/icore.h>
namespace QodeAssist::Chat {
ChatFileManager::ChatFileManager(QObject *parent)
: QObject(parent)
, m_intermediateStorageDir(getIntermediateStorageDir())
{}
ChatFileManager::~ChatFileManager() = default;
QStringList ChatFileManager::processDroppedFiles(const QStringList &filePaths)
{
QStringList processedPaths;
processedPaths.reserve(filePaths.size());
for (const QString &filePath : filePaths) {
if (!isFileAccessible(filePath)) {
const QString error = tr("File is not accessible: %1").arg(filePath);
LOG_MESSAGE(error);
emit fileOperationFailed(error);
continue;
}
QString copiedPath = copyToIntermediateStorage(filePath);
if (!copiedPath.isEmpty()) {
processedPaths.append(copiedPath);
emit fileCopiedToStorage(filePath, copiedPath);
LOG_MESSAGE(QString("File copied to storage: %1 -> %2").arg(filePath, copiedPath));
} else {
const QString error = tr("Failed to copy file: %1").arg(filePath);
LOG_MESSAGE(error);
emit fileOperationFailed(error);
}
}
return processedPaths;
}
void ChatFileManager::setChatFilePath(const QString &chatFilePath)
{
m_chatFilePath = chatFilePath;
}
QString ChatFileManager::chatFilePath() const
{
return m_chatFilePath;
}
void ChatFileManager::clearIntermediateStorage()
{
QDir dir(m_intermediateStorageDir);
if (!dir.exists()) {
return;
}
const QFileInfoList files = dir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot);
for (const QFileInfo &fileInfo : files) {
QFile file(fileInfo.absoluteFilePath());
file.setPermissions(QFile::WriteUser | QFile::ReadUser);
if (file.remove()) {
LOG_MESSAGE(QString("Removed intermediate file: %1").arg(fileInfo.fileName()));
} else {
LOG_MESSAGE(QString("Failed to remove intermediate file: %1")
.arg(fileInfo.fileName()));
}
}
}
bool ChatFileManager::isFileAccessible(const QString &filePath)
{
QFileInfo fileInfo(filePath);
return fileInfo.exists() && fileInfo.isFile() && fileInfo.isReadable();
}
void ChatFileManager::cleanupGlobalIntermediateStorage()
{
const QString basePath = Core::ICore::userResourcePath().toFSPathString();
const QString intermediatePath = QDir(basePath).filePath("qodeassist/chat_temp_files");
QDir dir(intermediatePath);
if (!dir.exists()) {
return;
}
const QFileInfoList files = dir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot);
int removedCount = 0;
int failedCount = 0;
for (const QFileInfo &fileInfo : files) {
QFile file(fileInfo.absoluteFilePath());
file.setPermissions(QFile::WriteUser | QFile::ReadUser);
if (file.remove()) {
removedCount++;
} else {
failedCount++;
}
}
if (removedCount > 0 || failedCount > 0) {
LOG_MESSAGE(QString("ChatFileManager global cleanup: removed=%1, failed=%2")
.arg(removedCount)
.arg(failedCount));
}
}
QString ChatFileManager::copyToIntermediateStorage(const QString &filePath)
{
QFileInfo fileInfo(filePath);
if (!fileInfo.exists() || !fileInfo.isFile()) {
LOG_MESSAGE(QString("Source file does not exist or is not a file: %1").arg(filePath));
return QString();
}
if (fileInfo.size() == 0) {
LOG_MESSAGE(QString("Source file is empty: %1").arg(filePath));
}
const QString newFileName = generateIntermediateFileName(filePath);
const QString destinationPath = QDir(m_intermediateStorageDir).filePath(newFileName);
if (QFileInfo::exists(destinationPath)) {
QFile::remove(destinationPath);
}
if (!QFile::copy(filePath, destinationPath)) {
LOG_MESSAGE(QString("Failed to copy file: %1 -> %2").arg(filePath, destinationPath));
return QString();
}
QFile copiedFile(destinationPath);
if (!copiedFile.exists()) {
LOG_MESSAGE(QString("Copied file does not exist after copy: %1").arg(destinationPath));
return QString();
}
copiedFile.setPermissions(QFile::ReadUser | QFile::WriteUser);
return destinationPath;
}
QString ChatFileManager::getIntermediateStorageDir()
{
const QString basePath = Core::ICore::userResourcePath().toFSPathString();
const QString intermediatePath = QDir(basePath).filePath("qodeassist/chat_temp_files");
QDir dir;
if (!dir.exists(intermediatePath) && !dir.mkpath(intermediatePath)) {
LOG_MESSAGE(QString("Failed to create intermediate storage directory: %1")
.arg(intermediatePath));
}
return intermediatePath;
}
QString ChatFileManager::generateIntermediateFileName(const QString &originalPath)
{
const QFileInfo fileInfo(originalPath);
const QString extension = fileInfo.suffix();
QString baseName = fileInfo.completeBaseName().left(30);
static const QRegularExpression specialChars("[^a-zA-Z0-9_-]");
baseName.replace(specialChars, "_");
if (baseName.isEmpty()) {
baseName = "file";
}
const QString timestamp = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss");
const QString uuid = QUuid::createUuid().toString(QUuid::WithoutBraces).left(8);
return QString("%1_%2_%3.%4").arg(baseName, timestamp, uuid, extension);
}
} // namespace QodeAssist::Chat

View File

@ -0,0 +1,59 @@
/*
* Copyright (C) 2024-2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QObject>
#include <QString>
#include <QStringList>
#include <QMap>
namespace QodeAssist::Chat {
class ChatFileManager : public QObject
{
Q_OBJECT
public:
explicit ChatFileManager(QObject *parent = nullptr);
~ChatFileManager();
QStringList processDroppedFiles(const QStringList &filePaths);
void setChatFilePath(const QString &chatFilePath);
QString chatFilePath() const;
void clearIntermediateStorage();
static bool isFileAccessible(const QString &filePath);
static void cleanupGlobalIntermediateStorage();
signals:
void fileOperationFailed(const QString &error);
void fileCopiedToStorage(const QString &originalPath, const QString &newPath);
private:
QString copyToIntermediateStorage(const QString &filePath);
QString getIntermediateStorageDir();
QString generateIntermediateFileName(const QString &originalPath);
QString m_chatFilePath;
QString m_intermediateStorageDir;
};
} // namespace QodeAssist::Chat

View File

@ -78,11 +78,26 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
return message.content;
}
case Roles::Attachments: {
QStringList filenames;
QVariantList attachmentsList;
for (const auto &attachment : message.attachments) {
filenames << attachment.filename;
QVariantMap attachmentMap;
attachmentMap["fileName"] = attachment.filename;
attachmentMap["storedPath"] = attachment.content;
if (!m_chatFilePath.isEmpty()) {
QFileInfo fileInfo(m_chatFilePath);
QString baseName = fileInfo.completeBaseName();
QString dirPath = fileInfo.absolutePath();
QString contentFolder = QDir(dirPath).filePath(baseName + "_content");
QString fullPath = QDir(contentFolder).filePath(attachment.content);
attachmentMap["filePath"] = fullPath;
} else {
attachmentMap["filePath"] = QString();
}
attachmentsList.append(attachmentMap);
}
return filenames;
return attachmentsList;
}
case Roles::IsRedacted: {
return message.isRedacted;
@ -95,13 +110,12 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
imageMap["storedPath"] = image.storedPath;
imageMap["mediaType"] = image.mediaType;
// Generate proper file URL for cross-platform compatibility
if (!m_chatFilePath.isEmpty()) {
QFileInfo fileInfo(m_chatFilePath);
QString baseName = fileInfo.completeBaseName();
QString dirPath = fileInfo.absolutePath();
QString imagesFolder = QDir(dirPath).filePath(baseName + "_images");
QString fullPath = QDir(imagesFolder).filePath(image.storedPath);
QString contentFolder = QDir(dirPath).filePath(baseName + "_content");
QString fullPath = QDir(contentFolder).filePath(image.storedPath);
imageMap["imageUrl"] = QUrl::fromLocalFile(fullPath).toString();
} else {
imageMap["imageUrl"] = QString();
@ -132,29 +146,26 @@ void ChatModel::addMessage(
ChatRole role,
const QString &id,
const QList<Context::ContentFile> &attachments,
const QList<ImageAttachment> &images)
const QList<ImageAttachment> &images,
bool isRedacted,
const QString &signature)
{
QString fullContent = content;
if (!attachments.isEmpty()) {
fullContent += "\n\nAttached files list:";
for (const auto &attachment : attachments) {
fullContent += QString("\nname: %1\nfile content:\n%2")
.arg(attachment.filename, attachment.content);
}
}
if (!m_messages.isEmpty() && !id.isEmpty() && m_messages.last().id == id
&& m_messages.last().role == role) {
Message &lastMessage = m_messages.last();
lastMessage.content = content;
lastMessage.attachments = attachments;
lastMessage.images = images;
lastMessage.isRedacted = isRedacted;
lastMessage.signature = signature;
emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1));
} else {
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
Message newMessage{role, content, id};
newMessage.attachments = attachments;
newMessage.images = images;
newMessage.isRedacted = isRedacted;
newMessage.signature = signature;
m_messages.append(newMessage);
endInsertRows();
@ -450,6 +461,16 @@ void ChatModel::addThinkingBlock(
displayContent += "\n[Signature: " + signature.left(40) + "...]";
}
for (int i = 0; i < m_messages.size(); ++i) {
if (m_messages[i].role == ChatRole::Thinking && m_messages[i].id == requestId) {
m_messages[i].content = displayContent;
m_messages[i].signature = signature;
emit dataChanged(index(i), index(i));
LOG_MESSAGE(QString("Updated existing thinking message at index %1").arg(i));
return;
}
}
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
Message thinkingMessage;
thinkingMessage.role = ChatRole::Thinking;

View File

@ -73,7 +73,9 @@ public:
ChatRole role,
const QString &id,
const QList<Context::ContentFile> &attachments = {},
const QList<ImageAttachment> &images = {});
const QList<ImageAttachment> &images = {},
bool isRedacted = false,
const QString &signature = QString());
Q_INVOKABLE void clear();
Q_INVOKABLE QList<MessagePart> processMessageContent(const QString &content) const;

View File

@ -22,6 +22,7 @@
#include <QClipboard>
#include <QDesktopServices>
#include <QFileDialog>
#include <QFileInfo>
#include <QMessageBox>
#include <coreplugin/editormanager/editormanager.h>
@ -37,14 +38,14 @@
#include "ChatSerializer.hpp"
#include "ConfigurationManager.hpp"
#include "GeneralSettings.hpp"
#include "ToolsSettings.hpp"
#include "Logger.hpp"
#include "ProjectSettings.hpp"
#include "ProvidersManager.hpp"
#include "ToolsSettings.hpp"
#include "context/ChangesManager.h"
#include "context/ContextManager.hpp"
#include "context/TokenUtils.hpp"
#include "llmcore/RulesLoader.hpp"
#include "ProvidersManager.hpp"
namespace QodeAssist::Chat {
@ -53,6 +54,7 @@ ChatRootView::ChatRootView(QQuickItem *parent)
, m_chatModel(new ChatModel(this))
, m_promptProvider(LLMCore::PromptTemplateManager::instance())
, m_clientInterface(new ClientInterface(m_chatModel, &m_promptProvider, this))
, m_fileManager(new ChatFileManager(this))
, m_isRequestInProgress(false)
{
m_isSyncOpenFiles = Settings::chatAssistantSettings().linkOpenFiles();
@ -66,18 +68,18 @@ ChatRootView::ChatRootView(QQuickItem *parent)
connect(
&settings.caModel, &Utils::BaseAspect::changed, this, &ChatRootView::currentTemplateChanged);
connect(&settings.caProvider, &Utils::BaseAspect::changed, this, [this]() {
auto &settings = Settings::generalSettings();
m_currentConfiguration = QString("%1 - %2").arg(settings.caProvider.value(),
settings.caModel.value());
m_currentConfiguration
= QString("%1 - %2").arg(settings.caProvider.value(), settings.caModel.value());
emit currentConfigurationChanged();
});
connect(&settings.caModel, &Utils::BaseAspect::changed, this, [this]() {
auto &settings = Settings::generalSettings();
m_currentConfiguration = QString("%1 - %2").arg(settings.caProvider.value(),
settings.caModel.value());
m_currentConfiguration
= QString("%1 - %2").arg(settings.caProvider.value(), settings.caModel.value());
emit currentConfigurationChanged();
});
@ -97,7 +99,7 @@ ChatRootView::ChatRootView(QQuickItem *parent)
this,
&ChatRootView::updateInputTokensCount);
connect(m_chatModel, &ChatModel::modelReseted, this, [this]() {
connect(m_chatModel, &ChatModel::modelReseted, this, [this]() {
setRecentFilePath(QString{});
m_currentMessageRequestId.clear();
updateCurrentMessageEditsStats();
@ -161,41 +163,42 @@ ChatRootView::ChatRootView(QQuickItem *parent)
m_lastErrorMessage = error;
emit lastErrorMessageChanged();
});
connect(m_clientInterface, &ClientInterface::requestStarted, this, [this](const QString &requestId) {
if (!m_currentMessageRequestId.isEmpty()) {
LOG_MESSAGE(QString("Clearing previous message requestId: %1").arg(m_currentMessageRequestId));
LOG_MESSAGE(
QString("Clearing previous message requestId: %1").arg(m_currentMessageRequestId));
}
m_currentMessageRequestId = requestId;
LOG_MESSAGE(QString("New message request started: %1").arg(requestId));
updateCurrentMessageEditsStats();
});
connect(
&Context::ChangesManager::instance(),
&Context::ChangesManager::fileEditAdded,
this,
[this](const QString &) { updateCurrentMessageEditsStats(); });
connect(
&Context::ChangesManager::instance(),
&Context::ChangesManager::fileEditApplied,
this,
[this](const QString &) { updateCurrentMessageEditsStats(); });
connect(
&Context::ChangesManager::instance(),
&Context::ChangesManager::fileEditRejected,
this,
[this](const QString &) { updateCurrentMessageEditsStats(); });
connect(
&Context::ChangesManager::instance(),
&Context::ChangesManager::fileEditUndone,
this,
[this](const QString &) { updateCurrentMessageEditsStats(); });
connect(
&Context::ChangesManager::instance(),
&Context::ChangesManager::fileEditArchived,
@ -212,26 +215,28 @@ ChatRootView::ChatRootView(QQuickItem *parent)
this,
&ChatRootView::refreshRules);
QSettings appSettings;
m_isAgentMode = appSettings.value("QodeAssist/Chat/AgentMode", false).toBool();
m_isThinkingMode = Settings::chatAssistantSettings().enableThinkingMode();
connect(
&Settings::chatAssistantSettings().enableChatTools,
&Utils::BaseAspect::changed,
this,
&ChatRootView::useToolsChanged);
connect(
&Settings::chatAssistantSettings().enableThinkingMode,
&Utils::BaseAspect::changed,
this,
[this]() { setIsThinkingMode(Settings::chatAssistantSettings().enableThinkingMode()); });
&ChatRootView::useThinkingChanged);
connect(
&Settings::toolsSettings().useTools,
&Utils::BaseAspect::changed,
this,
&ChatRootView::toolsSupportEnabledChanged);
connect(
&Settings::generalSettings().caProvider,
&Utils::BaseAspect::changed,
this,
&ChatRootView::isThinkingSupportChanged);
connect(m_fileManager, &ChatFileManager::fileOperationFailed, this, [this](const QString &error) {
m_lastErrorMessage = error;
emit lastErrorMessageChanged();
});
}
ChatModel *ChatRootView::chatModel() const
@ -265,7 +270,10 @@ void ChatRootView::sendMessage(const QString &message)
}
}
m_clientInterface->sendMessage(message, m_attachmentFiles, m_linkedFiles, m_isAgentMode);
m_clientInterface
->sendMessage(message, m_attachmentFiles, m_linkedFiles, useTools(), useThinking());
m_fileManager->clearIntermediateStorage();
clearAttachmentFiles();
setRequestProgressStatus(true);
}
@ -283,18 +291,23 @@ void ChatRootView::cancelRequest()
void ChatRootView::clearAttachmentFiles()
{
if (!m_attachmentFiles.isEmpty()) {
m_attachmentFiles.clear();
emit attachmentFilesChanged();
if (m_attachmentFiles.isEmpty()) {
return;
}
m_attachmentFiles.clear();
emit attachmentFilesChanged();
m_fileManager->clearIntermediateStorage();
}
void ChatRootView::clearLinkedFiles()
{
if (!m_linkedFiles.isEmpty()) {
m_linkedFiles.clear();
emit linkedFilesChanged();
if (m_linkedFiles.isEmpty()) {
return;
}
m_linkedFiles.clear();
emit linkedFilesChanged();
}
QString ChatRootView::getChatsHistoryDir() const
@ -305,8 +318,8 @@ QString ChatRootView::getChatsHistoryDir() const
Settings::ProjectSettings projectSettings(project);
path = projectSettings.chatHistoryPath().toFSPathString();
} else {
path = QString("%1/qodeassist/chat_history")
.arg(Core::ICore::userResourcePath().toFSPathString());
QDir baseDir(Core::ICore::userResourcePath().toFSPathString());
path = baseDir.filePath("qodeassist/chat_history");
}
QDir dir(path);
@ -342,7 +355,13 @@ void ChatRootView::loadHistory(const QString &filePath)
} else {
setRecentFilePath(filePath);
}
m_fileManager->clearIntermediateStorage();
m_attachmentFiles.clear();
m_linkedFiles.clear();
emit attachmentFilesChanged();
emit linkedFilesChanged();
m_currentMessageRequestId.clear();
updateInputTokensCount();
updateCurrentMessageEditsStats();
@ -410,7 +429,8 @@ QString ChatRootView::getSuggestedFileName() const
shortMessage = firstMessage.split('\n').first().simplified().left(30);
if (shortMessage.isEmpty()) {
QVariantList images = m_chatModel->data(m_chatModel->index(0), ChatModel::Images).toList();
QVariantList images
= m_chatModel->data(m_chatModel->index(0), ChatModel::Images).toList();
if (!images.isEmpty()) {
shortMessage = "image_chat";
}
@ -447,7 +467,8 @@ QString ChatRootView::getAutosaveFilePath() const
return QDir(dir).filePath(getSuggestedFileName() + ".json");
}
QString ChatRootView::getAutosaveFilePath(const QString &firstMessage, const QStringList &attachments) const
QString ChatRootView::getAutosaveFilePath(
const QString &firstMessage, const QStringList &attachments) const
{
if (!m_recentFilePath.isEmpty()) {
return m_recentFilePath;
@ -498,14 +519,16 @@ void ChatRootView::addFilesToAttachList(const QStringList &filePaths)
return;
}
const QStringList processedPaths = m_fileManager->processDroppedFiles(filePaths);
bool filesAdded = false;
for (const QString &filePath : filePaths) {
for (const QString &filePath : processedPaths) {
if (!m_attachmentFiles.contains(filePath)) {
m_attachmentFiles.append(filePath);
filesAdded = true;
}
}
if (filesAdded) {
emit attachmentFilesChanged();
}
@ -513,10 +536,15 @@ void ChatRootView::addFilesToAttachList(const QStringList &filePaths)
void ChatRootView::removeFileFromAttachList(int index)
{
if (index >= 0 && index < m_attachmentFiles.size()) {
m_attachmentFiles.removeAt(index);
emit attachmentFilesChanged();
if (index < 0 || index >= m_attachmentFiles.size()) {
return;
}
const QString removedFile = m_attachmentFiles.at(index);
m_attachmentFiles.removeAt(index);
emit attachmentFilesChanged();
LOG_MESSAGE(QString("Removed attachment file: %1").arg(removedFile));
}
void ChatRootView::showLinkFilesDialog()
@ -541,26 +569,26 @@ void ChatRootView::addFilesToLinkList(const QStringList &filePaths)
bool filesAdded = false;
QStringList imageFiles;
for (const QString &filePath : filePaths) {
if (isImageFile(filePath)) {
imageFiles.append(filePath);
continue;
}
if (!m_linkedFiles.contains(filePath)) {
m_linkedFiles.append(filePath);
filesAdded = true;
}
}
if (!imageFiles.isEmpty()) {
addFilesToAttachList(imageFiles);
m_lastInfoMessage = tr("Images automatically moved to Attach zone (%n file(s))", "", imageFiles.size());
m_lastInfoMessage
= tr("Images automatically moved to Attach zone (%n file(s))", "", imageFiles.size());
emit lastInfoMessageChanged();
}
if (filesAdded) {
emit linkedFilesChanged();
}
@ -568,10 +596,15 @@ void ChatRootView::addFilesToLinkList(const QStringList &filePaths)
void ChatRootView::removeFileFromLinkList(int index)
{
if (index >= 0 && index < m_linkedFiles.size()) {
m_linkedFiles.removeAt(index);
emit linkedFilesChanged();
if (index < 0 || index >= m_linkedFiles.size()) {
return;
}
const QString removedFile = m_linkedFiles.at(index);
m_linkedFiles.removeAt(index);
emit linkedFilesChanged();
LOG_MESSAGE(QString("Removed linked file: %1").arg(removedFile));
}
void ChatRootView::showAddImageDialog()
@ -585,19 +618,7 @@ void ChatRootView::showAddImageDialog()
}
if (dialog.exec() == QDialog::Accepted) {
QStringList newFilePaths = dialog.selectedFiles();
if (!newFilePaths.isEmpty()) {
bool filesAdded = false;
for (const QString &filePath : std::as_const(newFilePaths)) {
if (!m_attachmentFiles.contains(filePath)) {
m_attachmentFiles.append(filePath);
filesAdded = true;
}
}
if (filesAdded) {
emit attachmentFilesChanged();
}
}
addFilesToAttachList(dialog.selectedFiles());
}
}
@ -643,8 +664,8 @@ void ChatRootView::openChatHistoryFolder()
Settings::ProjectSettings projectSettings(project);
path = projectSettings.chatHistoryPath().toFSPathString();
} else {
path = QString("%1/qodeassist/chat_history")
.arg(Core::ICore::userResourcePath().toFSPathString());
QDir baseDir(Core::ICore::userResourcePath().toFSPathString());
path = baseDir.filePath("qodeassist/chat_history");
}
QDir dir(path);
@ -664,7 +685,7 @@ void ChatRootView::openRulesFolder()
}
QString projectPath = project->projectDirectory().toFSPathString();
QString rulesPath = projectPath + "/.qodeassist/rules";
QString rulesPath = QDir(projectPath).filePath(".qodeassist/rules");
QDir dir(rulesPath);
if (!dir.exists()) {
@ -760,6 +781,7 @@ void ChatRootView::setRecentFilePath(const QString &filePath)
if (m_recentFilePath != filePath) {
m_recentFilePath = filePath;
m_clientInterface->setChatFilePath(filePath);
m_fileManager->setChatFilePath(filePath);
emit chatFileNameChanged();
}
}
@ -867,43 +889,26 @@ void ChatRootView::refreshRules()
emit activeRulesCountChanged();
}
bool ChatRootView::isAgentMode() const
bool ChatRootView::useTools() const
{
return m_isAgentMode;
return Settings::chatAssistantSettings().enableChatTools();
}
void ChatRootView::setIsAgentMode(bool newIsAgentMode)
void ChatRootView::setUseTools(bool enabled)
{
if (m_isAgentMode != newIsAgentMode) {
m_isAgentMode = newIsAgentMode;
QSettings settings;
settings.setValue("QodeAssist/Chat/AgentMode", newIsAgentMode);
emit isAgentModeChanged();
}
Settings::chatAssistantSettings().enableChatTools.setValue(enabled);
Settings::chatAssistantSettings().writeSettings();
}
bool ChatRootView::isThinkingMode() const
bool ChatRootView::useThinking() const
{
return m_isThinkingMode;
return Settings::chatAssistantSettings().enableThinkingMode();
}
void ChatRootView::setIsThinkingMode(bool newIsThinkingMode)
void ChatRootView::setUseThinking(bool enabled)
{
if (m_isThinkingMode != newIsThinkingMode) {
m_isThinkingMode = newIsThinkingMode;
Settings::chatAssistantSettings().enableThinkingMode.setValue(newIsThinkingMode);
Settings::chatAssistantSettings().writeSettings();
emit isThinkingModeChanged();
}
}
bool ChatRootView::toolsSupportEnabled() const
{
return Settings::toolsSettings().useTools();
Settings::chatAssistantSettings().enableThinkingMode.setValue(enabled);
Settings::chatAssistantSettings().writeSettings();
}
void ChatRootView::applyFileEdit(const QString &editId)
@ -912,13 +917,13 @@ void ChatRootView::applyFileEdit(const QString &editId)
if (Context::ChangesManager::instance().applyFileEdit(editId)) {
m_lastInfoMessage = QString("File edit applied successfully");
emit lastInfoMessageChanged();
updateFileEditStatus(editId, "applied");
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
m_lastErrorMessage = edit.statusMessage.isEmpty()
? QString("Failed to apply file edit")
: QString("Failed to apply file edit: %1").arg(edit.statusMessage);
m_lastErrorMessage = edit.statusMessage.isEmpty()
? QString("Failed to apply file edit")
: QString("Failed to apply file edit: %1").arg(edit.statusMessage);
emit lastErrorMessageChanged();
}
}
@ -929,13 +934,13 @@ void ChatRootView::rejectFileEdit(const QString &editId)
if (Context::ChangesManager::instance().rejectFileEdit(editId)) {
m_lastInfoMessage = QString("File edit rejected");
emit lastInfoMessageChanged();
updateFileEditStatus(editId, "rejected");
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
m_lastErrorMessage = edit.statusMessage.isEmpty()
? QString("Failed to reject file edit")
: QString("Failed to reject file edit: %1").arg(edit.statusMessage);
m_lastErrorMessage = edit.statusMessage.isEmpty()
? QString("Failed to reject file edit")
: QString("Failed to reject file edit: %1").arg(edit.statusMessage);
emit lastErrorMessageChanged();
}
}
@ -946,13 +951,13 @@ void ChatRootView::undoFileEdit(const QString &editId)
if (Context::ChangesManager::instance().undoFileEdit(editId)) {
m_lastInfoMessage = QString("File edit undone successfully");
emit lastInfoMessageChanged();
updateFileEditStatus(editId, "rejected");
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
m_lastErrorMessage = edit.statusMessage.isEmpty()
? QString("Failed to undo file edit")
: QString("Failed to undo file edit: %1").arg(edit.statusMessage);
m_lastErrorMessage = edit.statusMessage.isEmpty()
? QString("Failed to undo file edit")
: QString("Failed to undo file edit: %1").arg(edit.statusMessage);
emit lastErrorMessageChanged();
}
}
@ -960,37 +965,36 @@ void ChatRootView::undoFileEdit(const QString &editId)
void ChatRootView::openFileEditInEditor(const QString &editId)
{
LOG_MESSAGE(QString("Opening file edit in editor: %1").arg(editId));
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
if (edit.editId.isEmpty()) {
m_lastErrorMessage = QString("File edit not found: %1").arg(editId);
emit lastErrorMessageChanged();
return;
}
Utils::FilePath filePath = Utils::FilePath::fromString(edit.filePath);
Core::IEditor *editor = Core::EditorManager::openEditor(filePath);
if (!editor) {
m_lastErrorMessage = QString("Failed to open file in editor: %1").arg(edit.filePath);
emit lastErrorMessageChanged();
return;
}
auto *textEditor = qobject_cast<TextEditor::BaseTextEditor *>(editor);
if (textEditor && textEditor->editorWidget()) {
QTextDocument *doc = textEditor->editorWidget()->document();
if (doc) {
QString currentContent = doc->toPlainText();
int position = -1;
if (edit.status == Context::ChangesManager::Applied && !edit.newContent.isEmpty()) {
position = currentContent.indexOf(edit.newContent);
}
else if (!edit.oldContent.isEmpty()) {
} else if (!edit.oldContent.isEmpty()) {
position = currentContent.indexOf(edit.oldContent);
}
if (position >= 0) {
QTextCursor cursor(doc);
cursor.setPosition(position);
@ -999,7 +1003,7 @@ void ChatRootView::openFileEditInEditor(const QString &editId)
}
}
}
LOG_MESSAGE(QString("Opened file in editor: %1").arg(edit.filePath));
}
@ -1009,33 +1013,35 @@ void ChatRootView::updateFileEditStatus(const QString &editId, const QString &st
for (int i = 0; i < messages.size(); ++i) {
if (messages[i].role == Chat::ChatModel::FileEdit && messages[i].id == editId) {
QString content = messages[i].content;
const QString marker = "QODEASSIST_FILE_EDIT:";
int markerPos = content.indexOf(marker);
QString jsonStr = content;
if (markerPos >= 0) {
jsonStr = content.mid(markerPos + marker.length());
}
QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8());
if (doc.isObject()) {
QJsonObject obj = doc.object();
obj["status"] = status;
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
if (!edit.statusMessage.isEmpty()) {
obj["status_message"] = edit.statusMessage;
}
QString updatedContent = marker + QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact));
QString updatedContent = marker
+ QString::fromUtf8(
QJsonDocument(obj).toJson(QJsonDocument::Compact));
m_chatModel->updateMessageContent(editId, updatedContent);
LOG_MESSAGE(QString("Updated file edit status to: %1").arg(status));
}
break;
}
}
updateCurrentMessageEditsStats();
}
@ -1046,37 +1052,39 @@ void ChatRootView::applyAllFileEditsForCurrentMessage()
emit lastErrorMessageChanged();
return;
}
LOG_MESSAGE(QString("Applying all file edits for message: %1").arg(m_currentMessageRequestId));
QString errorMsg;
bool success = Context::ChangesManager::instance()
.reapplyAllEditsForRequest(m_currentMessageRequestId, &errorMsg);
if (success) {
m_lastInfoMessage = QString("All file edits applied successfully");
emit lastInfoMessageChanged();
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId);
auto edits = Context::ChangesManager::instance().getEditsForRequest(
m_currentMessageRequestId);
for (const auto &edit : edits) {
if (edit.status == Context::ChangesManager::Applied) {
updateFileEditStatus(edit.editId, "applied");
}
}
} else {
m_lastErrorMessage = errorMsg.isEmpty()
? QString("Failed to apply some file edits")
: QString("Failed to apply some file edits:\n%1").arg(errorMsg);
m_lastErrorMessage = errorMsg.isEmpty()
? QString("Failed to apply some file edits")
: QString("Failed to apply some file edits:\n%1").arg(errorMsg);
emit lastErrorMessageChanged();
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId);
auto edits = Context::ChangesManager::instance().getEditsForRequest(
m_currentMessageRequestId);
for (const auto &edit : edits) {
if (edit.status == Context::ChangesManager::Applied) {
updateFileEditStatus(edit.editId, "applied");
}
}
}
updateCurrentMessageEditsStats();
}
@ -1087,45 +1095,47 @@ void ChatRootView::undoAllFileEditsForCurrentMessage()
emit lastErrorMessageChanged();
return;
}
LOG_MESSAGE(QString("Undoing all file edits for message: %1").arg(m_currentMessageRequestId));
QString errorMsg;
bool success = Context::ChangesManager::instance()
.undoAllEditsForRequest(m_currentMessageRequestId, &errorMsg);
if (success) {
m_lastInfoMessage = QString("All file edits undone successfully");
emit lastInfoMessageChanged();
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId);
auto edits = Context::ChangesManager::instance().getEditsForRequest(
m_currentMessageRequestId);
for (const auto &edit : edits) {
if (edit.status == Context::ChangesManager::Rejected) {
updateFileEditStatus(edit.editId, "rejected");
}
}
} else {
m_lastErrorMessage = errorMsg.isEmpty()
? QString("Failed to undo some file edits")
: QString("Failed to undo some file edits:\n%1").arg(errorMsg);
m_lastErrorMessage = errorMsg.isEmpty()
? QString("Failed to undo some file edits")
: QString("Failed to undo some file edits:\n%1").arg(errorMsg);
emit lastErrorMessageChanged();
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId);
auto edits = Context::ChangesManager::instance().getEditsForRequest(
m_currentMessageRequestId);
for (const auto &edit : edits) {
if (edit.status == Context::ChangesManager::Rejected) {
updateFileEditStatus(edit.editId, "rejected");
}
}
}
updateCurrentMessageEditsStats();
}
void ChatRootView::updateCurrentMessageEditsStats()
{
if (m_currentMessageRequestId.isEmpty()) {
if (m_currentMessageTotalEdits != 0 || m_currentMessageAppliedEdits != 0 ||
m_currentMessagePendingEdits != 0 || m_currentMessageRejectedEdits != 0) {
if (m_currentMessageTotalEdits != 0 || m_currentMessageAppliedEdits != 0
|| m_currentMessagePendingEdits != 0 || m_currentMessageRejectedEdits != 0) {
m_currentMessageTotalEdits = 0;
m_currentMessageAppliedEdits = 0;
m_currentMessagePendingEdits = 0;
@ -1134,14 +1144,14 @@ void ChatRootView::updateCurrentMessageEditsStats()
}
return;
}
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId);
int total = edits.size();
int applied = 0;
int pending = 0;
int rejected = 0;
for (const auto &edit : edits) {
switch (edit.status) {
case Context::ChangesManager::Applied:
@ -1158,7 +1168,7 @@ void ChatRootView::updateCurrentMessageEditsStats()
break;
}
}
bool changed = false;
if (m_currentMessageTotalEdits != total) {
m_currentMessageTotalEdits = total;
@ -1176,10 +1186,14 @@ void ChatRootView::updateCurrentMessageEditsStats()
m_currentMessageRejectedEdits = rejected;
changed = true;
}
if (changed) {
LOG_MESSAGE(QString("Updated message edits stats: total=%1, applied=%2, pending=%3, rejected=%4")
.arg(total).arg(applied).arg(pending).arg(rejected));
LOG_MESSAGE(
QString("Updated message edits stats: total=%1, applied=%2, pending=%3, rejected=%4")
.arg(total)
.arg(applied)
.arg(pending)
.arg(rejected));
emit currentMessageEditsStatsChanged();
}
}
@ -1268,9 +1282,7 @@ bool ChatRootView::hasImageAttachments(const QStringList &attachments) const
bool ChatRootView::isImageFile(const QString &filePath) const
{
static const QSet<QString> imageExtensions = {
"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"
};
static const QSet<QString> imageExtensions = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"};
QFileInfo fileInfo(filePath);
return imageExtensions.contains(fileInfo.suffix().toLower());
@ -1318,7 +1330,8 @@ void ChatRootView::applyConfiguration(const QString &configName)
settings.caModel.setValue(config.model);
settings.caTemplate.setValue(config.templateName);
settings.caUrl.setValue(config.url);
settings.caEndpointMode.setValue(settings.caEndpointMode.indexForDisplay(config.endpointMode));
settings.caEndpointMode.setValue(
settings.caEndpointMode.indexForDisplay(config.endpointMode));
settings.caCustomEndpoint.setValue(config.customEndpoint);
settings.writeSettings();

View File

@ -23,6 +23,7 @@
#include "ChatModel.hpp"
#include "ClientInterface.hpp"
#include "ChatFileManager.hpp"
#include "llmcore/PromptProviderChat.hpp"
#include <coreplugin/editormanager/editormanager.h>
@ -48,10 +49,8 @@ class ChatRootView : public QQuickItem
Q_PROPERTY(QString lastInfoMessage READ lastInfoMessage NOTIFY lastInfoMessageChanged FINAL)
Q_PROPERTY(QVariantList activeRules READ activeRules NOTIFY activeRulesChanged FINAL)
Q_PROPERTY(int activeRulesCount READ activeRulesCount NOTIFY activeRulesCountChanged FINAL)
Q_PROPERTY(bool isAgentMode READ isAgentMode WRITE setIsAgentMode NOTIFY isAgentModeChanged FINAL)
Q_PROPERTY(bool isThinkingMode READ isThinkingMode WRITE setIsThinkingMode NOTIFY isThinkingModeChanged FINAL)
Q_PROPERTY(
bool toolsSupportEnabled READ toolsSupportEnabled NOTIFY toolsSupportEnabledChanged FINAL)
Q_PROPERTY(bool useTools READ useTools WRITE setUseTools NOTIFY useToolsChanged FINAL)
Q_PROPERTY(bool useThinking READ useThinking WRITE setUseThinking NOTIFY useThinkingChanged FINAL)
Q_PROPERTY(int currentMessageTotalEdits READ currentMessageTotalEdits NOTIFY currentMessageEditsStatsChanged FINAL)
Q_PROPERTY(int currentMessageAppliedEdits READ currentMessageAppliedEdits NOTIFY currentMessageEditsStatsChanged FINAL)
@ -127,11 +126,10 @@ public:
Q_INVOKABLE QString getRuleContent(int index);
Q_INVOKABLE void refreshRules();
bool isAgentMode() const;
void setIsAgentMode(bool newIsAgentMode);
bool isThinkingMode() const;
void setIsThinkingMode(bool newIsThinkingMode);
bool toolsSupportEnabled() const;
bool useTools() const;
void setUseTools(bool enabled);
bool useThinking() const;
void setUseThinking(bool enabled);
Q_INVOKABLE void applyFileEdit(const QString &editId);
Q_INVOKABLE void rejectFileEdit(const QString &editId);
@ -184,9 +182,8 @@ signals:
void activeRulesChanged();
void activeRulesCountChanged();
void isAgentModeChanged();
void isThinkingModeChanged();
void toolsSupportEnabledChanged();
void useToolsChanged();
void useThinkingChanged();
void currentMessageEditsStatsChanged();
void isThinkingSupportChanged();
@ -203,6 +200,7 @@ private:
ChatModel *m_chatModel;
LLMCore::PromptProviderChat m_promptProvider;
ClientInterface *m_clientInterface;
ChatFileManager *m_fileManager;
QString m_currentTemplate;
QString m_recentFilePath;
QStringList m_attachmentFiles;
@ -214,8 +212,6 @@ private:
bool m_isRequestInProgress;
QString m_lastErrorMessage;
QVariantList m_activeRules;
bool m_isAgentMode;
bool m_isThinkingMode;
QString m_currentMessageRequestId;
int m_currentMessageTotalEdits{0};

View File

@ -30,7 +30,7 @@
namespace QodeAssist::Chat {
const QString ChatSerializer::VERSION = "0.1";
const QString ChatSerializer::VERSION = "0.2";
SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QString &filePath)
{
@ -38,11 +38,11 @@ SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QSt
return {false, "Failed to create directory structure"};
}
QString imagesFolder = getChatImagesFolder(filePath);
QString contentFolder = getChatContentFolder(filePath);
QDir dir;
if (!dir.exists(imagesFolder)) {
if (!dir.mkpath(imagesFolder)) {
LOG_MESSAGE(QString("Warning: Failed to create images folder: %1").arg(imagesFolder));
if (!dir.exists(contentFolder)) {
if (!dir.mkpath(contentFolder)) {
LOG_MESSAGE(QString("Warning: Failed to create content folder: %1").arg(contentFolder));
}
}
@ -94,11 +94,26 @@ QJsonObject ChatSerializer::serializeMessage(const ChatModel::Message &message,
messageObj["role"] = static_cast<int>(message.role);
messageObj["content"] = message.content;
messageObj["id"] = message.id;
messageObj["isRedacted"] = message.isRedacted;
if (message.isRedacted) {
messageObj["isRedacted"] = true;
}
if (!message.signature.isEmpty()) {
messageObj["signature"] = message.signature;
}
if (!message.attachments.isEmpty()) {
QJsonArray attachmentsArray;
for (const auto &attachment : message.attachments) {
QJsonObject attachmentObj;
attachmentObj["fileName"] = attachment.filename;
attachmentObj["storedPath"] = attachment.content;
attachmentsArray.append(attachmentObj);
}
messageObj["attachments"] = attachmentsArray;
}
if (!message.images.isEmpty()) {
QJsonArray imagesArray;
for (const auto &image : message.images) {
@ -123,6 +138,17 @@ ChatModel::Message ChatSerializer::deserializeMessage(const QJsonObject &json, c
message.isRedacted = json["isRedacted"].toBool(false);
message.signature = json["signature"].toString();
if (json.contains("attachments")) {
QJsonArray attachmentsArray = json["attachments"].toArray();
for (const auto &attachmentValue : attachmentsArray) {
QJsonObject attachmentObj = attachmentValue.toObject();
Context::ContentFile attachment;
attachment.filename = attachmentObj["fileName"].toString();
attachment.content = attachmentObj["storedPath"].toString();
message.attachments.append(attachment);
}
}
if (json.contains("images")) {
QJsonArray imagesArray = json["images"].toArray();
for (const auto &imageValue : imagesArray) {
@ -167,8 +193,11 @@ bool ChatSerializer::deserializeChat(ChatModel *model, const QJsonObject &json,
model->setLoadingFromHistory(true);
for (const auto &message : messages) {
model->addMessage(message.content, message.role, message.id, message.attachments, message.images);
LOG_MESSAGE(QString("Loaded message with %1 image(s)").arg(message.images.size()));
model->addMessage(message.content, message.role, message.id, message.attachments, message.images, message.isRedacted, message.signature);
LOG_MESSAGE(QString("Loaded message with %1 image(s), isRedacted=%2, signature length=%3")
.arg(message.images.size())
.arg(message.isRedacted)
.arg(message.signature.length()));
}
model->setLoadingFromHistory(false);
@ -185,27 +214,36 @@ bool ChatSerializer::ensureDirectoryExists(const QString &filePath)
bool ChatSerializer::validateVersion(const QString &version)
{
return version == VERSION;
if (version == VERSION) {
return true;
}
if (version == "0.1") {
LOG_MESSAGE("Loading chat from old format 0.1 - images folder structure has changed from _images to _content");
return true;
}
return false;
}
QString ChatSerializer::getChatImagesFolder(const QString &chatFilePath)
QString ChatSerializer::getChatContentFolder(const QString &chatFilePath)
{
QFileInfo fileInfo(chatFilePath);
QString baseName = fileInfo.completeBaseName();
QString dirPath = fileInfo.absolutePath();
return QDir(dirPath).filePath(baseName + "_images");
return QDir(dirPath).filePath(baseName + "_content");
}
bool ChatSerializer::saveImageToStorage(const QString &chatFilePath,
const QString &fileName,
const QString &base64Data,
QString &storedPath)
bool ChatSerializer::saveContentToStorage(const QString &chatFilePath,
const QString &fileName,
const QString &base64Data,
QString &storedPath)
{
QString imagesFolder = getChatImagesFolder(chatFilePath);
QString contentFolder = getChatContentFolder(chatFilePath);
QDir dir;
if (!dir.exists(imagesFolder)) {
if (!dir.mkpath(imagesFolder)) {
LOG_MESSAGE(QString("Failed to create images folder: %1").arg(imagesFolder));
if (!dir.exists(contentFolder)) {
if (!dir.mkpath(contentFolder)) {
LOG_MESSAGE(QString("Failed to create content folder: %1").arg(contentFolder));
return false;
}
}
@ -218,43 +256,43 @@ bool ChatSerializer::saveImageToStorage(const QString &chatFilePath,
.arg(QUuid::createUuid().toString(QUuid::WithoutBraces).left(8))
.arg(extension);
QString fullPath = QDir(imagesFolder).filePath(uniqueName);
QString fullPath = QDir(contentFolder).filePath(uniqueName);
QByteArray imageData = QByteArray::fromBase64(base64Data.toUtf8());
QByteArray contentData = QByteArray::fromBase64(base64Data.toUtf8());
QFile file(fullPath);
if (!file.open(QIODevice::WriteOnly)) {
LOG_MESSAGE(QString("Failed to open file for writing: %1").arg(fullPath));
return false;
}
if (file.write(imageData) == -1) {
LOG_MESSAGE(QString("Failed to write image data: %1").arg(file.errorString()));
if (file.write(contentData) == -1) {
LOG_MESSAGE(QString("Failed to write content data: %1").arg(file.errorString()));
return false;
}
file.close();
storedPath = uniqueName;
LOG_MESSAGE(QString("Saved image: %1 to %2").arg(fileName, fullPath));
LOG_MESSAGE(QString("Saved content: %1 to %2").arg(fileName, fullPath));
return true;
}
QString ChatSerializer::loadImageFromStorage(const QString &chatFilePath, const QString &storedPath)
QString ChatSerializer::loadContentFromStorage(const QString &chatFilePath, const QString &storedPath)
{
QString imagesFolder = getChatImagesFolder(chatFilePath);
QString fullPath = QDir(imagesFolder).filePath(storedPath);
QString contentFolder = getChatContentFolder(chatFilePath);
QString fullPath = QDir(contentFolder).filePath(storedPath);
QFile file(fullPath);
if (!file.open(QIODevice::ReadOnly)) {
LOG_MESSAGE(QString("Failed to open image file: %1").arg(fullPath));
LOG_MESSAGE(QString("Failed to open content file: %1").arg(fullPath));
return QString();
}
QByteArray imageData = file.readAll();
QByteArray contentData = file.readAll();
file.close();
return imageData.toBase64();
return contentData.toBase64();
}
} // namespace QodeAssist::Chat

View File

@ -45,13 +45,13 @@ public:
static QJsonObject serializeChat(const ChatModel *model, const QString &chatFilePath);
static bool deserializeChat(ChatModel *model, const QJsonObject &json, const QString &chatFilePath);
// Image management
static QString getChatImagesFolder(const QString &chatFilePath);
static bool saveImageToStorage(const QString &chatFilePath,
const QString &fileName,
const QString &base64Data,
QString &storedPath);
static QString loadImageFromStorage(const QString &chatFilePath, const QString &storedPath);
// Content management (images and text files)
static QString getChatContentFolder(const QString &chatFilePath);
static bool saveContentToStorage(const QString &chatFilePath,
const QString &fileName,
const QString &base64Data,
QString &storedPath);
static QString loadContentFromStorage(const QString &chatFilePath, const QString &storedPath);
private:
static const QString VERSION;

View File

@ -19,6 +19,8 @@
#include "ClientInterface.hpp"
#include <projectexplorer/buildconfiguration.h>
#include <projectexplorer/target.h>
#include <texteditor/textdocument.h>
#include <QFile>
#include <QFileInfo>
@ -41,12 +43,12 @@
#include "ChatAssistantSettings.hpp"
#include "ChatSerializer.hpp"
#include "GeneralSettings.hpp"
#include "ToolsSettings.hpp"
#include "Logger.hpp"
#include "ProvidersManager.hpp"
#include "RequestConfig.hpp"
#include <context/ChangesManager.h>
#include "ToolsSettings.hpp"
#include <RulesLoader.hpp>
#include <context/ChangesManager.h>
namespace QodeAssist::Chat {
@ -67,16 +69,17 @@ void ClientInterface::sendMessage(
const QString &message,
const QList<QString> &attachments,
const QList<QString> &linkedFiles,
bool useAgentMode)
bool useTools,
bool useThinking)
{
cancelRequest();
m_accumulatedResponses.clear();
Context::ChangesManager::instance().archiveAllNonArchivedEdits();
QList<QString> imageFiles;
QList<QString> textFiles;
for (const QString &filePath : attachments) {
if (isImageFile(filePath)) {
imageFiles.append(filePath);
@ -85,8 +88,25 @@ void ClientInterface::sendMessage(
}
}
auto attachFiles = m_contextManager->getContentFiles(textFiles);
QList<Context::ContentFile> storedAttachments;
if (!textFiles.isEmpty() && !m_chatFilePath.isEmpty()) {
auto attachFiles = m_contextManager->getContentFiles(textFiles);
for (const auto &file : attachFiles) {
QString storedPath;
if (ChatSerializer::saveContentToStorage(
m_chatFilePath, file.filename, file.content.toUtf8().toBase64(), storedPath)) {
Context::ContentFile storedFile;
storedFile.filename = file.filename;
storedFile.content = storedPath;
storedAttachments.append(storedFile);
LOG_MESSAGE(QString("Stored text file %1 as %2").arg(file.filename, storedPath));
}
}
} else if (!textFiles.isEmpty()) {
LOG_MESSAGE(QString("Warning: Chat file path not set, cannot save %1 text file(s)")
.arg(textFiles.size()));
}
QList<ChatModel::ImageAttachment> imageAttachments;
if (!imageFiles.isEmpty() && !m_chatFilePath.isEmpty()) {
for (const QString &imagePath : imageFiles) {
@ -94,24 +114,26 @@ void ClientInterface::sendMessage(
if (base64Data.isEmpty()) {
continue;
}
QString storedPath;
QFileInfo fileInfo(imagePath);
if (ChatSerializer::saveImageToStorage(m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) {
if (ChatSerializer::saveContentToStorage(
m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) {
ChatModel::ImageAttachment imageAttachment;
imageAttachment.fileName = fileInfo.fileName();
imageAttachment.storedPath = storedPath;
imageAttachment.mediaType = getMediaTypeForImage(imagePath);
imageAttachments.append(imageAttachment);
LOG_MESSAGE(QString("Stored image %1 as %2").arg(fileInfo.fileName(), storedPath));
}
}
} else if (!imageFiles.isEmpty()) {
LOG_MESSAGE(QString("Warning: Chat file path not set, cannot save %1 image(s)").arg(imageFiles.size()));
LOG_MESSAGE(QString("Warning: Chat file path not set, cannot save %1 image(s)")
.arg(imageFiles.size()));
}
m_chatModel->addMessage(message, ChatModel::ChatRole::User, "", attachFiles, imageAttachments);
m_chatModel->addMessage(message, ChatModel::ChatRole::User, "", storedAttachments, imageAttachments);
auto &chatAssistantSettings = Settings::chatAssistantSettings();
@ -133,7 +155,7 @@ void ClientInterface::sendMessage(
LLMCore::ContextData context;
const bool isToolsEnabled = Settings::toolsSettings().useTools() && useAgentMode;
const bool isToolsEnabled = useTools;
if (chatAssistantSettings.useSystemPrompt()) {
QString systemPrompt = chatAssistantSettings.systemPrompt();
@ -142,7 +164,15 @@ void ClientInterface::sendMessage(
if (project) {
systemPrompt += QString("\n# Active project name: %1").arg(project->displayName());
systemPrompt += QString("\n# Active Project path: %1").arg(project->projectDirectory().toUrlishString());
systemPrompt += QString("\n# Active Project path: %1")
.arg(project->projectDirectory().toUrlishString());
if (auto target = project->activeTarget()) {
if (auto buildConfig = target->activeBuildConfiguration()) {
systemPrompt += QString("\n# Active Build directory: %1")
.arg(buildConfig->buildDirectory().toUrlishString());
}
}
QString projectRules
= LLMCore::RulesLoader::loadRulesForProject(project, LLMCore::RulesContext::Chat);
@ -165,29 +195,42 @@ void ClientInterface::sendMessage(
if (msg.role == ChatModel::ChatRole::Tool || msg.role == ChatModel::ChatRole::FileEdit) {
continue;
}
LLMCore::Message apiMessage;
apiMessage.role = msg.role == ChatModel::ChatRole::User ? "user" : "assistant";
apiMessage.content = msg.content;
if (!msg.attachments.isEmpty() && !m_chatFilePath.isEmpty()) {
apiMessage.content += "\n\nAttached files:";
for (const auto &attachment : msg.attachments) {
QString fileContent = ChatSerializer::loadContentFromStorage(m_chatFilePath, attachment.content);
if (!fileContent.isEmpty()) {
QString decodedContent = QString::fromUtf8(QByteArray::fromBase64(fileContent.toUtf8()));
apiMessage.content += QString("\n\nFile: %1\n```\n%2\n```")
.arg(attachment.filename, decodedContent);
}
}
}
apiMessage.isThinking = (msg.role == ChatModel::ChatRole::Thinking);
apiMessage.isRedacted = msg.isRedacted;
apiMessage.signature = msg.signature;
if (provider->supportImage() && !m_chatFilePath.isEmpty() && !msg.images.isEmpty()) {
auto apiImages = loadImagesFromStorage(msg.images);
if (!apiImages.isEmpty()) {
apiMessage.images = apiImages;
}
}
messages.append(apiMessage);
}
if (!imageFiles.isEmpty() && !provider->supportImage()) {
LOG_MESSAGE(QString("Provider %1 doesn't support images, %2 ignored")
.arg(provider->name(), QString::number(imageFiles.size())));
}
context.history = messages;
LLMCore::LLMConfig config;
@ -215,14 +258,14 @@ void ClientInterface::sendMessage(
promptTemplate,
context,
LLMCore::RequestType::Chat,
isToolsEnabled,
Settings::chatAssistantSettings().enableThinkingMode());
useTools,
useThinking);
QString requestId = QUuid::createUuid().toString();
QJsonObject request{{"id", requestId}};
m_activeRequests[requestId] = {request, provider};
emit requestStarted(requestId);
connect(
@ -386,14 +429,14 @@ void ClientInterface::handleFullResponse(const QString &requestId, const QString
const RequestContext &ctx = it.value();
QString finalText = !fullText.isEmpty() ? fullText : m_accumulatedResponses[requestId];
QString applyError;
bool applySuccess = Context::ChangesManager::instance()
.applyPendingEditsForRequest(requestId, &applyError);
bool applySuccess
= Context::ChangesManager::instance().applyPendingEditsForRequest(requestId, &applyError);
if (!applySuccess) {
LOG_MESSAGE(QString("Some edits for request %1 were not auto-applied: %2")
.arg(requestId, applyError));
.arg(requestId, applyError));
}
LOG_MESSAGE(
@ -434,35 +477,32 @@ void ClientInterface::handleCleanAccumulatedData(const QString &requestId)
bool ClientInterface::isImageFile(const QString &filePath) const
{
static const QSet<QString> imageExtensions = {
"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"
};
static const QSet<QString> imageExtensions = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"};
QFileInfo fileInfo(filePath);
QString extension = fileInfo.suffix().toLower();
return imageExtensions.contains(extension);
}
QString ClientInterface::getMediaTypeForImage(const QString &filePath) const
{
static const QHash<QString, QString> mediaTypes = {
{"png", "image/png"},
{"jpg", "image/jpeg"},
{"jpeg", "image/jpeg"},
{"gif", "image/gif"},
{"webp", "image/webp"},
{"bmp", "image/bmp"},
{"svg", "image/svg+xml"}
};
static const QHash<QString, QString> mediaTypes
= {{"png", "image/png"},
{"jpg", "image/jpeg"},
{"jpeg", "image/jpeg"},
{"gif", "image/gif"},
{"webp", "image/webp"},
{"bmp", "image/bmp"},
{"svg", "image/svg+xml"}};
QFileInfo fileInfo(filePath);
QString extension = fileInfo.suffix().toLower();
if (mediaTypes.contains(extension)) {
return mediaTypes[extension];
}
QMimeDatabase mimeDb;
QMimeType mimeType = mimeDb.mimeTypeForFile(filePath);
return mimeType.name();
@ -475,32 +515,34 @@ QString ClientInterface::encodeImageToBase64(const QString &filePath) const
LOG_MESSAGE(QString("Failed to open image file: %1").arg(filePath));
return QString();
}
QByteArray imageData = file.readAll();
file.close();
return imageData.toBase64();
}
QVector<LLMCore::ImageAttachment> ClientInterface::loadImagesFromStorage(const QList<ChatModel::ImageAttachment> &storedImages) const
QVector<LLMCore::ImageAttachment> ClientInterface::loadImagesFromStorage(
const QList<ChatModel::ImageAttachment> &storedImages) const
{
QVector<LLMCore::ImageAttachment> apiImages;
for (const auto &storedImage : storedImages) {
QString base64Data = ChatSerializer::loadImageFromStorage(m_chatFilePath, storedImage.storedPath);
QString base64Data
= ChatSerializer::loadContentFromStorage(m_chatFilePath, storedImage.storedPath);
if (base64Data.isEmpty()) {
LOG_MESSAGE(QString("Warning: Failed to load image: %1").arg(storedImage.storedPath));
continue;
}
LLMCore::ImageAttachment apiImage;
apiImage.data = base64Data;
apiImage.mediaType = storedImage.mediaType;
apiImage.isUrl = false;
apiImages.append(apiImage);
}
return apiImages;
}

View File

@ -43,7 +43,8 @@ public:
const QString &message,
const QList<QString> &attachments = {},
const QList<QString> &linkedFiles = {},
bool useAgentMode = false);
bool useTools = false,
bool useThinking = false);
void clearMessages();
void cancelRequest();

View File

@ -1,4 +1,4 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.4445 9.32233C17.7036 7.28556 21.8559 7.75441 25.8713 9.68854C27.428 9.4057 30.1744 8.91006 31.6477 9.47565C34.5351 10.5309 36.6339 12.7385 37.0285 14.9805C37.81 15.3756 38.4502 15.9932 38.8635 16.751C39.7282 18.3354 39.8498 19.9232 39.2678 21.2061C39.8159 22.277 39.9974 23.4913 39.7844 24.67C39.663 25.4561 39.3556 26.2047 38.8869 26.8555C38.4183 27.5062 37.8013 28.0419 37.0842 28.42C36.8857 28.5274 34.5887 28.6167 34.3713 28.6885C34.6443 32.2168 30.9868 33.5005 27.8889 32.6602L29.0403 36.586L26.0803 36.6885L23.8713 31.6885L21.8713 29.6885C20.125 30.1697 17.0919 30.168 15.76 28.0831C15.639 27.8916 15.5299 27.693 15.4319 27.4893C15.0931 27.5567 14.7474 27.5909 14.4016 27.5919C13.415 27.5918 11.771 27.3037 10.9358 26.7393C10.2736 26.3112 9.74862 25.7095 9.42014 25.004C7.64097 25.2413 6.13134 24.8334 5.14474 23.8262C3.8951 22.5721 3.72021 18.9738 4.37131 16.751C5.22965 13.7841 7.6818 12.9427 12.8713 11.6885C13.3214 11.1426 13.8387 9.69851 14.4445 9.32233ZM21.2551 15.0001L20.9358 16.1114L19.8723 16.4444L19.3401 15.5557L18.4895 16.3331L19.0217 17.2217L18.383 18.2217L17.2131 18.0001L17.0002 18.8887L18.0637 19.4444V20.5557L17.0002 21.1114L17.2131 22.0001L18.383 21.7774L19.0217 22.7774L18.4895 23.6671L19.3401 24.4444L19.8723 23.5557L20.9358 23.8887L21.2551 25.0001H22.7444L23.0637 23.8887L24.1272 23.5557L24.6594 24.4444L25.511 23.6671L24.9787 22.7774L25.6174 21.7774L26.7873 22.0001L27.0002 21.1114L25.9358 20.5557V19.4444L27.0002 18.8887L26.7873 18.0001L25.6174 18.2217L24.9787 17.2217L25.6174 16.4444L24.6594 15.5557L24.1272 16.4444L23.0637 16.1114L22.7444 15.0001H21.2551Z" fill="black"/>
<path d="M6 35L38 6" stroke="black" stroke-width="4" stroke-linecap="round"/>
<path d="M14.4445 9.32233C17.7036 7.28556 21.8559 7.75441 25.8713 9.68854C27.428 9.4057 30.1744 8.91006 31.6477 9.47565C34.5351 10.5309 36.6339 12.7385 37.0285 14.9805C37.81 15.3756 38.4502 15.9932 38.8635 16.751C39.7282 18.3354 39.8498 19.9232 39.2678 21.2061C39.8159 22.277 39.9974 23.4913 39.7844 24.67C39.663 25.4561 39.3556 26.2047 38.8869 26.8555C38.4183 27.5062 37.8013 28.0419 37.0842 28.42C36.8857 28.5274 34.5887 28.6167 34.3713 28.6885C34.6443 32.2168 30.9868 33.5005 27.8889 32.6602L29.0403 36.586L26.0803 36.6885L23.8713 31.6885L21.8713 29.6885C20.125 30.1697 17.0919 30.168 15.76 28.0831C15.639 27.8916 15.5299 27.693 15.4319 27.4893C15.0931 27.5567 14.7474 27.5909 14.4016 27.5919C13.415 27.5918 11.771 27.3037 10.9358 26.7393C10.2736 26.3112 9.74862 25.7095 9.42014 25.004C7.64097 25.2413 6.13134 24.8334 5.14474 23.8262C3.8951 22.5721 3.72021 18.9738 4.37131 16.751C5.22965 13.7841 7.6818 12.9427 12.8713 11.6885C13.3214 11.1426 13.8387 9.69851 14.4445 9.32233ZM21.2551 15.0001L20.9358 16.1114L19.8723 16.4444L19.3401 15.5557L18.4895 16.3331L19.0217 17.2217L18.383 18.2217L17.2131 18.0001L17.0002 18.8887L18.0637 19.4444V20.5557L17.0002 21.1114L17.2131 22.0001L18.383 21.7774L19.0217 22.7774L18.4895 23.6671L19.3401 24.4444L19.8723 23.5557L20.9358 23.8887L21.2551 25.0001H22.7444L23.0637 23.8887L24.1272 23.5557L24.6594 24.4444L25.511 23.6671L24.9787 22.7774L25.6174 21.7774L26.7873 22.0001L27.0002 21.1114L25.9358 20.5557V19.4444L27.0002 18.8887L26.7873 18.0001L25.6174 18.2217L24.9787 17.2217L25.6174 16.4444L24.6594 15.5557L24.1272 16.4444L23.0637 16.1114L22.7444 15.0001H21.2551Z" fill="black" fill-opacity="0.6"/>
<path d="M6 35L38 6" stroke="black" stroke-opacity="0.6" stroke-width="4" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,11 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_82_71)">
<path d="M10.7777 0.0435181C14.2316 -0.253961 17.6161 0.979215 20.0629 3.42633C23.4139 6.77767 24.3012 11.6719 22.7299 15.8433C22.9016 15.988 23.0706 16.1419 23.2377 16.3072L42.2221 34.2203C42.2288 34.2268 42.2353 34.2344 42.2426 34.2408C44.4752 36.4735 44.4752 40.1064 42.2426 42.3394C40.0096 44.5717 36.4035 44.5446 34.1713 42.3121C34.1617 42.3031 34.1528 42.2937 34.144 42.2838L16.3871 23.1519C16.2254 22.9894 16.0746 22.8196 15.933 22.6451C11.7604 24.2194 6.86327 23.3335 3.50919 19.98C1.06298 17.5327 -0.171482 14.1483 0.126373 10.6949C0.160109 10.3034 0.41818 9.96685 0.78653 9.83258C1.15602 9.69759 1.57009 9.78945 1.84805 10.067L7.53555 15.7535L13.8402 13.7574L15.8363 7.4527L10.1488 1.7652C9.87057 1.48716 9.77945 1.07345 9.91348 0.704651C10.0489 0.335072 10.3852 0.0774496 10.7777 0.0435181ZM37.3656 34.7496L37.3129 34.9302L37.1586 35.4673L36.8363 35.5679L36.6195 35.2047L36.4623 34.942L36.2357 35.148L35.725 35.6148L35.5746 35.7525L35.6791 35.9283L35.9184 36.3287L35.7104 36.6548L35.1742 36.5543L34.9408 36.5093L34.8852 36.7418L34.7572 37.275L34.7123 37.4644L34.8842 37.5543L35.3891 37.8179V38.1802L34.8842 38.4449L34.7123 38.5347L34.7572 38.7242L34.8852 39.2574L34.9408 39.4898L35.1742 39.4449L35.7104 39.3433L35.9184 39.6695L35.6791 40.0709L35.5746 40.2466L35.725 40.3843L36.2357 40.8511L36.4623 41.0572L36.6195 40.7945L36.8363 40.4302L37.1586 40.5308L37.3129 41.0689L37.3656 41.2496H38.6352L38.6879 41.0689L38.8412 40.5308L39.1635 40.4302L39.3813 40.7945L39.5385 41.0572L39.765 40.8511L40.2758 40.3843L40.4262 40.2466L40.3217 40.0709L40.0815 39.6695L40.2895 39.3433L40.8266 39.4449L41.06 39.4898L41.1156 39.2574L41.2436 38.7242L41.2885 38.5347L41.1166 38.4449L40.6117 38.1802V37.8179L41.1166 37.5543L41.2885 37.4644L41.2436 37.275L41.1156 36.7418L41.06 36.5093L40.8266 36.5543L40.2895 36.6548L40.0815 36.3287L40.3217 35.9283L40.4262 35.7525L40.2758 35.6148L39.765 35.148L39.5385 34.942L39.3813 35.2047L39.1635 35.5679L38.8412 35.4673L38.6879 34.9302L38.6352 34.7496H37.3656Z" fill="black" fill-opacity="0.6"/>
<path d="M6 36L38 7" stroke="black" stroke-opacity="0.6" stroke-width="4" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_82_71">
<rect width="44" height="44" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,10 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_82_50)">
<path d="M10.7775 0.0441895C14.2315 -0.253375 17.6159 0.979824 20.0627 3.427C23.4135 6.77842 24.3011 11.6726 22.7297 15.844C22.9013 15.9886 23.0714 16.1416 23.2385 16.3069L42.2219 34.2209C42.2285 34.2274 42.2352 34.2341 42.2424 34.2405C44.475 36.4732 44.475 40.1061 42.2424 42.3391C40.0094 44.5715 36.4033 44.5444 34.1711 42.3118C34.1615 42.3028 34.1525 42.2934 34.1437 42.2834L16.3869 23.1516C16.2251 22.9891 16.0745 22.8193 15.9328 22.6448C11.7602 24.2191 6.86304 23.3333 3.50897 19.9797C1.06276 17.5324 -0.171773 14.148 0.12616 10.6946C0.159908 10.3029 0.418723 9.96644 0.787292 9.83228C1.15667 9.69748 1.56997 9.78926 1.84784 10.0667L7.53534 15.7532L13.84 13.7571L15.8361 7.45239L10.1486 1.76489C9.87052 1.48684 9.78022 1.07306 9.91425 0.704346C10.0498 0.334991 10.3852 0.0781082 10.7775 0.0441895ZM37.3654 34.7502L37.3127 34.9309L37.1584 35.468L36.8361 35.5686L36.6193 35.2053L36.4621 34.9426L36.2355 35.1487L35.7248 35.6155L35.5744 35.7532L35.6789 35.929L35.9182 36.3293L35.7101 36.6555L35.174 36.5549L34.9406 36.51L34.8849 36.7424L34.757 37.2756L34.7121 37.4651L34.884 37.5549L35.3889 37.8186V38.1809L34.884 38.4456L34.7121 38.5354L34.757 38.7249L34.8849 39.2581L34.9406 39.4905L35.174 39.4456L35.7101 39.344L35.9182 39.6702L35.6789 40.0715L35.5744 40.2473L35.7248 40.385L36.2355 40.8518L36.4621 41.0579L36.6193 40.7952L36.8361 40.4309L37.1584 40.5315L37.3127 41.0696L37.3654 41.2502H38.6349L38.6877 41.0696L38.841 40.5315L39.1633 40.4309L39.381 40.7952L39.5383 41.0579L39.7648 40.8518L40.2756 40.385L40.426 40.2473L40.3215 40.0715L40.0812 39.6702L40.2892 39.344L40.8264 39.4456L41.0598 39.4905L41.1154 39.2581L41.2433 38.7249L41.2883 38.5354L41.1164 38.4456L40.6115 38.1809V37.8186L41.1164 37.5549L41.2883 37.4651L41.2433 37.2756L41.1154 36.7424L41.0598 36.51L40.8264 36.5549L40.2892 36.6555L40.0812 36.3293L40.3215 35.929L40.426 35.7532L40.2756 35.6155L39.7648 35.1487L39.5383 34.9426L39.381 35.2053L39.1633 35.5686L38.841 35.468L38.6877 34.9309L38.6349 34.7502H37.3654Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_82_50">
<rect width="44" height="44" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -60,6 +60,7 @@ ChatRootView {
SplitDropZone {
anchors.fill: parent
z: 99
onFilesDroppedToAttach: (urlStrings) => {
var localPaths = root.convertUrlsToLocalPaths(urlStrings)
@ -103,18 +104,17 @@ ChatRootView {
checked: typeof _chatview !== 'undefined' ? _chatview.isPin : false
onCheckedChanged: _chatview.isPin = topBar.pinButton.checked
}
agentModeSwitch {
checked: root.isAgentMode
enabled: root.toolsSupportEnabled
onToggled: {
root.isAgentMode = agentModeSwitch.checked
toolsButton {
checked: root.useTools
onCheckedChanged: {
root.useTools = toolsButton.checked
}
}
thinkingMode {
checked: root.isThinkingMode
checked: root.useThinking
enabled: root.isThinkingSupport
onCheckedChanged: {
root.isThinkingMode = thinkingMode.checked
root.useThinking = thinkingMode.checked
}
}
configSelector {

View File

@ -121,24 +121,11 @@ Rectangle {
Repeater {
id: attachmentsModel
delegate: Rectangle {
delegate: AttachmentComponent {
required property int index
required property var modelData
height: attachText.implicitHeight + 8
width: attachText.implicitWidth + 16
radius: 4
color: palette.button
border.width: 1
border.color: palette.mid
Text {
id: attachText
anchors.centerIn: parent
text: modelData
color: palette.text
}
itemData: modelData
}
}
}
@ -239,6 +226,68 @@ Rectangle {
codeFontSize: root.codeFontSize
}
component AttachmentComponent : Rectangle {
required property var itemData
height: attachFileText.implicitHeight + 8
width: attachFileText.implicitWidth + 16
radius: 4
color: attachFileMouseArea.containsMouse ? Qt.lighter(palette.button, 1.1) : palette.button
border.width: 1
border.color: palette.mid
Behavior on color { ColorAnimation { duration: 100 } }
FileItem {
id: fileItem
filePath: itemData.filePath || ""
}
Text {
id: attachFileText
anchors.centerIn: parent
text: (itemData.fileName || "")
color: palette.buttonText
font.pointSize: root.textFontSize - 1
}
MouseArea {
id: attachFileMouseArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: (mouse) => {
if (mouse.button === Qt.LeftButton) {
fileItem.openFileInEditor()
} else if (mouse.button === Qt.RightButton) {
attachmentContextMenu.popup()
}
}
ToolTip.visible: containsMouse
ToolTip.text: qsTr("Left click: Open in Qt Creator\nRight click: More options")
ToolTip.delay: 500
}
Menu {
id: attachmentContextMenu
MenuItem {
text: qsTr("Open in Qt Creator")
onTriggered: fileItem.openFileInEditor()
}
MenuItem {
text: qsTr("Open in System Editor")
onTriggered: fileItem.openFileInExternalEditor()
}
}
}
component ImageComponent : Rectangle {
required property var itemData
@ -248,10 +297,17 @@ Rectangle {
width: Math.min(imageDisplay.implicitWidth, maxImageWidth) + 16
height: imageDisplay.implicitHeight + fileNameText.implicitHeight + 16
radius: 4
color: palette.base
color: imageMouseArea.containsMouse ? Qt.lighter(palette.base, 1.05) : palette.base
border.width: 1
border.color: palette.mid
Behavior on color { ColorAnimation { duration: 100 } }
FileItem {
id: imageFileItem
filePath: itemData.imageUrl ? itemData.imageUrl.toString().replace("file://", "") : ""
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
@ -259,6 +315,7 @@ Rectangle {
Image {
id: imageDisplay
Layout.alignment: Qt.AlignHCenter
Layout.maximumWidth: parent.parent.maxImageWidth
Layout.maximumHeight: parent.parent.maxImageHeight
@ -289,6 +346,7 @@ Rectangle {
Text {
id: fileNameText
Layout.fillWidth: true
text: itemData.fileName || ""
color: palette.text
@ -297,5 +355,40 @@ Rectangle {
horizontalAlignment: Text.AlignHCenter
}
}
MouseArea {
id: imageMouseArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: (mouse) => {
if (mouse.button === Qt.LeftButton) {
imageFileItem.openFileInEditor()
} else if (mouse.button === Qt.RightButton) {
imageContextMenu.popup()
}
}
ToolTip.visible: containsMouse
ToolTip.text: qsTr("Left click: Open in System\nRight click: More options")
ToolTip.delay: 500
}
Menu {
id: imageContextMenu
MenuItem {
text: qsTr("Open in Qt Creator")
onTriggered: imageFileItem.openFileInEditor()
}
MenuItem {
text: qsTr("Open in System Viewer")
onTriggered: imageFileItem.openFileInExternalEditor()
}
}
}
}

View File

@ -23,10 +23,12 @@ import QtQuick.Controls
Item {
id: root
signal filesDroppedToAttach(var urlStrings) // Array of URL strings (file://...)
signal filesDroppedToLink(var urlStrings) // Array of URL strings (file://...)
signal filesDroppedToAttach(var urlStrings)
signal filesDroppedToLink(var urlStrings)
property string activeZone: ""
property int filesCount: 0
property bool isDragActive: false
Item {
id: splitDropOverlay
@ -34,12 +36,39 @@ Item {
anchors.fill: parent
visible: false
z: 999
opacity: 0
Behavior on opacity {
NumberAnimation { duration: 200; easing.type: Easing.InOutQuad }
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(palette.shadow.r, palette.shadow.g, palette.shadow.b, 0.6)
}
Rectangle {
anchors {
top: parent.top
horizontalCenter: parent.horizontalCenter
topMargin: 30
}
width: fileCountText.width + 40
height: 50
color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.9)
radius: 25
visible: root.filesCount > 0
Text {
id: fileCountText
anchors.centerIn: parent
text: qsTr("%n file(s) to drop", "", root.filesCount)
font.pixelSize: 16
font.bold: true
color: palette.highlightedText
}
}
Rectangle {
id: leftZone
@ -76,19 +105,20 @@ Item {
color: root.activeZone === "left" ? palette.highlightedText : palette.text
opacity: 0.8
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("(for one-time use)")
font.pixelSize: 12
font.italic: true
color: root.activeZone === "left" ? palette.highlightedText : palette.text
opacity: 0.6
}
}
Behavior on color {
ColorAnimation { duration: 150 }
}
Behavior on border.width {
NumberAnimation { duration: 150 }
}
Behavior on border.color {
ColorAnimation { duration: 150 }
}
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.width { NumberAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
}
Rectangle {
@ -127,19 +157,20 @@ Item {
color: root.activeZone === "right" ? palette.highlightedText : palette.text
opacity: 0.8
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("(added to context)")
font.pixelSize: 12
font.italic: true
color: root.activeZone === "right" ? palette.highlightedText : palette.text
opacity: 0.6
}
}
Behavior on color {
ColorAnimation { duration: 150 }
}
Behavior on border.width {
NumberAnimation { duration: 150 }
}
Behavior on border.color {
ColorAnimation { duration: 150 }
}
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.width { NumberAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
}
Rectangle {
@ -193,42 +224,67 @@ Item {
onEntered: (drag) => {
if (drag.hasUrls) {
root.isDragActive = true
root.filesCount = drag.urls.length
splitDropOverlay.visible = true
splitDropOverlay.opacity = 1
root.activeZone = ""
}
}
onExited: {
splitDropOverlay.visible = false
root.activeZone = ""
root.isDragActive = false
root.filesCount = 0
splitDropOverlay.opacity = 0
Qt.callLater(function() {
if (!root.isDragActive) {
splitDropOverlay.visible = false
root.activeZone = ""
}
})
}
onPositionChanged: (drag) => {
if (drag.x < globalDropArea.width / 2) {
root.activeZone = "left"
} else {
root.activeZone = "right"
if (drag.hasUrls) {
root.activeZone = drag.x < globalDropArea.width / 2 ? "left" : "right"
}
}
onDropped: (drop) => {
var targetZone = root.activeZone
splitDropOverlay.visible = false
root.activeZone = ""
const targetZone = root.activeZone
root.isDragActive = false
root.filesCount = 0
splitDropOverlay.opacity = 0
if (drop.hasUrls && drop.urls.length > 0) {
// Convert URLs to array of strings for C++ processing
var urlStrings = []
for (var i = 0; i < drop.urls.length; i++) {
urlStrings.push(drop.urls[i].toString())
}
if (targetZone === "right") {
root.filesDroppedToLink(urlStrings)
} else {
root.filesDroppedToAttach(urlStrings)
Qt.callLater(function() {
splitDropOverlay.visible = false
root.activeZone = ""
})
if (!drop.hasUrls || drop.urls.length === 0) {
return
}
var urlStrings = []
for (var i = 0; i < drop.urls.length; i++) {
var urlString = drop.urls[i].toString()
if (urlString.startsWith("file://") || urlString.indexOf("://") === -1) {
urlStrings.push(urlString)
}
}
if (urlStrings.length === 0) {
return
}
drop.accept(Qt.CopyAction)
if (targetZone === "right") {
root.filesDroppedToLink(urlStrings)
} else {
root.filesDroppedToAttach(urlStrings)
}
}
}
}

View File

@ -34,7 +34,7 @@ Rectangle {
property alias openChatHistory: openChatHistoryId
property alias pinButton: pinButtonId
property alias rulesButton: rulesButtonId
property alias agentModeSwitch: agentModeSwitchId
property alias toolsButton: toolsButtonId
property alias thinkingMode: thinkingModeId
property alias activeRulesCount: activeRulesCountId.text
property alias configSelector: configSelectorId
@ -53,7 +53,8 @@ Rectangle {
spacing: 10
Row {
height: agentModeSwitchId.height
id: firstRow
spacing: 10
QoAButton {
@ -75,23 +76,44 @@ Rectangle {
: qsTr("Pin chat window to the top")
}
QoATextSlider {
id: agentModeSwitchId
QoAComboBox {
id: configSelectorId
implicitHeight: 25
model: []
currentIndex: 0
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Switch AI configuration")
}
QoAButton {
id: toolsButtonId
anchors.verticalCenter: parent.verticalCenter
leftText: "chat"
rightText: "AI Agent"
checkable: true
opacity: enabled ? 1.0 : 0.2
icon {
source: checked ? "qrc:/qt/qml/ChatView/icons/tools-icon-on.svg"
: "qrc:/qt/qml/ChatView/icons/tools-icon-off.svg"
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
height: 15
width: 15
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: {
if (!agentModeSwitchId.enabled) {
if (!toolsButtonId.enabled) {
return qsTr("Tools are disabled in General Settings")
}
return checked
? qsTr("Agent Mode: AI can use tools to read files, search project, and build code")
: qsTr("Chat Mode: Simple conversation without tool access")
? qsTr("Tools enabled: AI can use tools to read files, search project, and build code")
: qsTr("Tools disabled: Simple conversation without tool access")
}
}
@ -120,7 +142,7 @@ Rectangle {
}
Item {
height: agentModeSwitchId.height
height: firstRow.height
width: recentPathId.width
Text {
@ -144,7 +166,10 @@ Rectangle {
}
RowLayout {
id: secondRow
Layout.preferredWidth: root.width
Layout.preferredHeight: firstRow.height
spacing: 10
@ -239,17 +264,6 @@ Rectangle {
ToolTip.delay: 250
ToolTip.text: qsTr("Current amount tokens in chat and LLM limit threshold")
}
QoAComboBox {
id: configSelectorId
model: []
currentIndex: 0
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Switch AI configuration")
}
}
}
}

View File

@ -1,7 +1,7 @@
{
"Id" : "qodeassist",
"Name" : "QodeAssist",
"Version" : "0.9.1",
"Version" : "0.9.3",
"CompatVersion" : "${IDE_VERSION}",
"Vendor" : "Petr Mironychev",
"VendorId" : "petrmironychev",

View File

@ -40,6 +40,9 @@
#include "settings/CodeCompletionSettings.hpp"
#include "settings/GeneralSettings.hpp"
#include "settings/ProjectSettings.hpp"
#include "settings/QuickRefactorSettings.hpp"
#include "widgets/RefactorWidgetHandler.hpp"
#include "RefactorContextHelper.hpp"
#include <context/ChangesManager.h>
#include <logger/Logger.hpp>
@ -71,12 +74,14 @@ QodeAssistClient::QodeAssistClient(LLMClientInterface *clientInterface)
connect(&m_hintHideTimer, &QTimer::timeout, this, [this]() { m_hintHandler.hideHint(); });
m_refactorHoverHandler = new RefactorSuggestionHoverHandler();
m_refactorWidgetHandler = new RefactorWidgetHandler(this);
}
QodeAssistClient::~QodeAssistClient()
{
cleanupConnections();
delete m_refactorHoverHandler;
delete m_refactorWidgetHandler;
}
void QodeAssistClient::openDocument(TextEditor::TextDocument *document)
@ -451,11 +456,25 @@ void QodeAssistClient::handleRefactoringResult(const RefactorResult &result)
return;
}
TextEditorWidget *editorWidget = result.editor;
int displayMode = Settings::quickRefactorSettings().displayMode();
if (displayMode == 0) {
displayRefactoringWidget(result);
} else {
displayRefactoringSuggestion(result);
}
}
auto toTextPos = [](const Utils::Text::Position &pos) {
return Utils::Text::Position{pos.line, pos.column};
};
namespace {
Utils::Text::Position toTextPos(const Utils::Text::Position &pos)
{
return Utils::Text::Position{pos.line, pos.column};
}
} // anonymous namespace
void QodeAssistClient::displayRefactoringSuggestion(const RefactorResult &result)
{
TextEditorWidget *editorWidget = result.editor;
Utils::Text::Range range{toTextPos(result.insertRange.begin), toTextPos(result.insertRange.end)};
Utils::Text::Position pos = toTextPos(result.insertRange.begin);
@ -510,6 +529,81 @@ void QodeAssistClient::handleRefactoringResult(const RefactorResult &result)
LOG_MESSAGE("Displaying refactoring suggestion with hover handler");
}
void QodeAssistClient::displayRefactoringWidget(const RefactorResult &result)
{
TextEditorWidget *editorWidget = result.editor;
Utils::Text::Range range{toTextPos(result.insertRange.begin), toTextPos(result.insertRange.end)};
RefactorContext ctx = RefactorContextHelper::extractContext(editorWidget, range);
QString displayOriginal;
QString displayRefactored;
QString textToApply = result.newText;
if (ctx.isInsertion) {
bool isMultiline = result.newText.contains('\n');
if (isMultiline) {
displayOriginal = ctx.textBeforeCursor;
displayRefactored = ctx.textBeforeCursor + result.newText;
} else {
displayOriginal = ctx.textBeforeCursor + ctx.textAfterCursor;
displayRefactored = ctx.textBeforeCursor + result.newText + ctx.textAfterCursor;
}
if (!ctx.textBeforeCursor.isEmpty() || !ctx.textAfterCursor.isEmpty()) {
textToApply = result.newText;
}
} else {
displayOriginal = ctx.originalText;
displayRefactored = result.newText;
}
m_refactorWidgetHandler->setApplyCallback([this, editorWidget, result](const QString &editedText) {
applyRefactoringEdit(editorWidget, result.insertRange, editedText);
});
m_refactorWidgetHandler->setDeclineCallback([]() {});
m_refactorWidgetHandler->showRefactorWidget(
editorWidget, displayOriginal, displayRefactored, range,
ctx.contextBefore, ctx.contextAfter);
m_refactorWidgetHandler->setTextToApply(textToApply);
}
void QodeAssistClient::applyRefactoringEdit(TextEditor::TextEditorWidget *editor,
const Utils::Text::Range &range,
const QString &text)
{
const QTextCursor startCursor = range.begin.toTextCursor(editor->document());
const QTextCursor endCursor = range.end.toTextCursor(editor->document());
const int startPos = startCursor.position();
const int endPos = endCursor.position();
QTextCursor editCursor(editor->document());
editCursor.beginEditBlock();
if (startPos == endPos) {
bool isMultiline = text.contains('\n');
editCursor.setPosition(startPos);
if (isMultiline) {
editCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
editCursor.removeSelectedText();
}
editCursor.insertText(text);
} else {
editCursor.setPosition(startPos);
editCursor.setPosition(endPos, QTextCursor::KeepAnchor);
editCursor.removeSelectedText();
editCursor.insertText(text);
}
editCursor.endEditBlock();
}
void QodeAssistClient::handleAutoRequestTrigger(TextEditor::TextEditorWidget *widget,
int charsAdded,
bool isSpaceOrTab)

View File

@ -34,6 +34,7 @@
#include "widgets/CompletionErrorHandler.hpp"
#include "widgets/CompletionHintHandler.hpp"
#include "widgets/EditorChatButtonHandler.hpp"
#include "widgets/RefactorWidgetHandler.hpp"
#include <languageclient/client.h>
#include <llmcore/IPromptProvider.hpp>
#include <llmcore/IProviderRegistry.hpp>
@ -70,6 +71,9 @@ private:
void setupConnections();
void cleanupConnections();
void handleRefactoringResult(const RefactorResult &result);
void displayRefactoringSuggestion(const RefactorResult &result);
void displayRefactoringWidget(const RefactorResult &result);
void applyRefactoringEdit(TextEditor::TextEditorWidget *editor, const Utils::Text::Range &range, const QString &text);
void handleAutoRequestTrigger(TextEditor::TextEditorWidget *widget, int charsAdded, bool isSpaceOrTab);
void handleHintBasedTrigger(TextEditor::TextEditorWidget *widget, int charsAdded, bool isSpaceOrTab, QTextCursor &cursor);
@ -88,6 +92,7 @@ private:
EditorChatButtonHandler m_chatButtonHandler;
QuickRefactorHandler *m_refactorHandler{nullptr};
RefactorSuggestionHoverHandler *m_refactorHoverHandler{nullptr};
RefactorWidgetHandler *m_refactorWidgetHandler{nullptr};
LLMClientInterface *m_llmClient;
};

View File

@ -24,6 +24,7 @@
#include <QUuid>
#include <context/DocumentContextReader.hpp>
#include <llmcore/ResponseCleaner.hpp>
#include <context/DocumentReaderQtCreator.hpp>
#include <context/Utils.hpp>
#include <llmcore/PromptTemplateManager.hpp>
@ -301,17 +302,65 @@ LLMCore::ContextData QuickRefactorHandler::prepareContext(
systemPrompt += "\nLanguage: " + documentInfo.mimeType;
systemPrompt += "\nFile path: " + documentInfo.filePath;
systemPrompt += "\n\nCode context with position markers:";
systemPrompt += taggedContent;
systemPrompt += "\n\n# Code Context with Position Markers\n" + taggedContent;
systemPrompt += "\n\nOutput format:";
systemPrompt += "\n- Generate ONLY the code that should replace the current selection "
"between<selection_start><selection_end> or be "
"inserted at cursor position<cursor>";
systemPrompt += "\n- Do not include any explanations, comments about the code, or markdown "
"code block markers";
systemPrompt += "\n- The output should be ready to insert directly into the editor";
systemPrompt += "\n- Follow the existing code style and indentation patterns";
systemPrompt += "\n\n# Output Requirements\n## What to Generate:";
systemPrompt += cursor.hasSelection()
? "\n- Generate ONLY the code that should REPLACE the selected text between "
"<selection_start> and <selection_end> markers"
"\n- Your output will completely replace the selected code"
: "\n- Generate ONLY the code that should be INSERTED at the <cursor> position"
"\n- Your output will be inserted at the cursor location";
systemPrompt += "\n\n## Formatting Rules:"
"\n- Output ONLY the code itself, without ANY explanations or descriptions"
"\n- Do NOT include markdown code blocks (no ```, no language tags)"
"\n- Do NOT add comments explaining what you changed"
"\n- Do NOT repeat existing code, be precise with context"
"\n- Do NOT send in answer <cursor> or </cursor> and other tags"
"\n- The output must be ready to insert directly into the editor as-is";
systemPrompt += "\n\n## Indentation and Whitespace:";
if (cursor.hasSelection()) {
QTextBlock startBlock = documentInfo.document->findBlock(cursor.selectionStart());
int leadingSpaces = 0;
for (QChar c : startBlock.text()) {
if (c == ' ') leadingSpaces++;
else if (c == '\t') leadingSpaces += 4;
else break;
}
if (leadingSpaces > 0) {
systemPrompt += QString("\n- CRITICAL: The code to replace starts with %1 spaces of indentation"
"\n- Your output MUST start with exactly %1 spaces (or equivalent tabs)"
"\n- Each line in your output must maintain this base indentation")
.arg(leadingSpaces);
}
systemPrompt += "\n- PRESERVE all indentation from the original code";
} else {
QTextBlock block = documentInfo.document->findBlock(cursorPos);
QString lineText = block.text();
int leadingSpaces = 0;
for (QChar c : lineText) {
if (c == ' ') leadingSpaces++;
else if (c == '\t') leadingSpaces += 4;
else break;
}
if (leadingSpaces > 0) {
systemPrompt += QString("\n- CRITICAL: Current line has %1 spaces of indentation"
"\n- If generating multiline code, EVERY line must start with at least %1 spaces"
"\n- If generating single-line code, it will be inserted inline (no indentation needed)")
.arg(leadingSpaces);
}
}
systemPrompt += "\n- Use the same indentation style (spaces or tabs) as the surrounding code"
"\n- Maintain consistent indentation for nested blocks"
"\n- Do NOT remove or reduce the base indentation level"
"\n\n## Code Style:"
"\n- Match the coding style of the surrounding code (naming, spacing, braces, etc.)"
"\n- Preserve the original code structure when possible"
"\n- Only change what is necessary to fulfill the user's request";
if (Settings::codeCompletionSettings().useOpenFilesInQuickRefactor()) {
systemPrompt += "\n\n" + m_contextManager.openedFilesContext({documentInfo.filePath});
@ -338,19 +387,7 @@ void QuickRefactorHandler::handleLLMResponse(
if (isComplete) {
m_isRefactoringInProgress = false;
QString cleanedResponse = response.trimmed();
if (cleanedResponse.startsWith("```")) {
int firstNewLine = cleanedResponse.indexOf('\n');
int lastFence = cleanedResponse.lastIndexOf("```");
if (firstNewLine != -1 && lastFence > firstNewLine) {
cleanedResponse
= cleanedResponse.mid(firstNewLine + 1, lastFence - firstNewLine - 1).trimmed();
} else if (lastFence != -1) {
cleanedResponse = cleanedResponse.mid(3, lastFence - 3).trimmed();
}
}
QString cleanedResponse = LLMCore::ResponseCleaner::clean(response);
RefactorResult result;
result.newText = cleanedResponse;

View File

@ -155,14 +155,14 @@ QodeAssist supports multiple LLM providers. Choose your preferred provider and f
QodeAssist offers two trigger modes for code completion:
**Hint-based (Default, Recommended)**
**Hint-based**
- Shows a hint indicator near cursor when you type 3+ characters
- Press **Space** (or custom key) to request completion
- **Best for**: Paid APIs (Claude, OpenAI), conscious control
- **Benefits**: No unexpected API charges, full control over requests, no workflow interruption
- **Visual**: Clear indicator shows when completion is ready
**Automatic**
**Automatic(Default)**
- Automatically requests completion after typing threshold
- Works immediately without additional keypresses
- **Best for**: Local models (Ollama, llama.cpp), maximum automation
@ -182,8 +182,6 @@ Configure in: `Tools → Options → QodeAssist → Code Completion → General
### Quick Refactoring
- Inline code refactoring directly in the editor with AI assistance
- Selection-based improvements with instant code replacement
- Built-in quick actions (repeat, improve, alternative)
- **Custom instructions library** with search and autocomplete
- Create, edit, and manage reusable refactoring templates
- Combine base instructions with specific details

115
RefactorContextHelper.hpp Normal file
View File

@ -0,0 +1,115 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QString>
#include <QTextCursor>
#include <QTextBlock>
#include <texteditor/texteditor.h>
#include <utils/textutils.h>
namespace QodeAssist {
struct RefactorContext
{
QString originalText;
QString textBeforeCursor;
QString textAfterCursor;
QString contextBefore;
QString contextAfter;
int startPos{0};
int endPos{0};
bool isInsertion{false};
};
class RefactorContextHelper
{
public:
static RefactorContext extractContext(TextEditor::TextEditorWidget *editor,
const Utils::Text::Range &range,
int contextLinesBefore = 3,
int contextLinesAfter = 3)
{
RefactorContext ctx;
if (!editor) {
return ctx;
}
QTextDocument *doc = editor->document();
ctx.startPos = range.begin.toPositionInDocument(doc);
ctx.endPos = range.end.toPositionInDocument(doc);
ctx.isInsertion = (ctx.startPos == ctx.endPos);
if (!ctx.isInsertion) {
QTextCursor cursor(doc);
cursor.setPosition(ctx.startPos);
cursor.setPosition(ctx.endPos, QTextCursor::KeepAnchor);
ctx.originalText = cursor.selectedText();
ctx.originalText.replace(QChar(0x2029), "\n");
} else {
QTextCursor cursor(doc);
cursor.setPosition(ctx.startPos);
int posInBlock = cursor.positionInBlock();
cursor.movePosition(QTextCursor::StartOfBlock);
cursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, posInBlock);
ctx.textBeforeCursor = cursor.selectedText();
cursor.setPosition(ctx.startPos);
cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
ctx.textAfterCursor = cursor.selectedText();
}
ctx.contextBefore = extractContextLines(doc, ctx.startPos, contextLinesBefore, true);
ctx.contextAfter = extractContextLines(doc, ctx.endPos, contextLinesAfter, false);
return ctx;
}
private:
static QString extractContextLines(QTextDocument *doc, int position, int lineCount, bool before)
{
QTextCursor cursor(doc);
cursor.setPosition(position);
QTextBlock currentBlock = cursor.block();
QStringList lines;
if (before) {
QTextBlock block = currentBlock.previous();
for (int i = 0; i < lineCount && block.isValid(); ++i) {
lines.prepend(block.text());
block = block.previous();
}
} else {
QTextBlock block = currentBlock.next();
for (int i = 0; i < lineCount && block.isValid(); ++i) {
lines.append(block.text());
block = block.next();
}
}
return lines.join('\n');
}
};
} // namespace QodeAssist

View File

@ -29,8 +29,10 @@ Basic.ComboBox {
indicator: Image {
id: dropdownIcon
x: control.width - width - 10
y: control.topPadding + (control.availableHeight - height) / 2
width: 12
height: 8
source: palette.window.hslLightness > 0.5
@ -101,6 +103,8 @@ Basic.ComboBox {
implicitHeight: contentHeight
model: control.popup.visible ? control.delegateModel : null
currentIndex: control.highlightedIndex
boundsBehavior: ListView.StopAtBounds
highlightMoveDuration: 0
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded

View File

@ -43,11 +43,17 @@ ContextManager::ContextManager(QObject *parent)
QString ContextManager::readFile(const QString &filePath) const
{
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
LOG_MESSAGE(QString("Failed to open file for reading: %1 - %2")
.arg(filePath, file.errorString()));
return QString();
}
QTextStream in(&file);
return in.readAll();
QString content = in.readAll();
file.close();
return content;
}
QList<ContentFile> ContextManager::getContentFiles(const QStringList &filePaths) const

View File

@ -70,4 +70,18 @@ QString ProjectUtils::findFileInProject(const QString &filename)
return QString();
}
QString ProjectUtils::getProjectRoot()
{
QList<ProjectExplorer::Project *> projects = ProjectExplorer::ProjectManager::projects();
if (!projects.isEmpty()) {
auto project = projects.first();
if (project) {
return project->projectDirectory().toFSPathString();
}
}
return QString();
}
} // namespace QodeAssist::Context

View File

@ -52,6 +52,16 @@ public:
* @return Absolute file path if found, empty string otherwise
*/
static QString findFileInProject(const QString &filename);
/**
* @brief Get the project root directory
*
* Returns the root directory of the first open project.
* If multiple projects are open, returns the first one.
*
* @return Absolute path to project root, or empty string if no project is open
*/
static QString getProjectRoot();
};
} // namespace QodeAssist::Context

View File

@ -28,6 +28,44 @@ ollama run qwen2.5-coder:32b
You're all set! QodeAssist is now ready to use in Qt Creator.
## Extended Thinking Mode
Ollama supports extended thinking mode for models that are capable of deep reasoning (such as DeepSeek-R1, QwQ, and similar reasoning models). This mode allows the model to show its step-by-step reasoning process before providing the final answer.
### How to Enable
**For Chat Assistant:**
1. Navigate to Qt Creator > Preferences > QodeAssist > Chat Assistant
2. In the "Extended Thinking (Claude, Ollama)" section, check "Enable extended thinking mode"
3. Select a reasoning-capable model (e.g., deepseek-r1:8b, qwq:32b)
4. Click Apply
**For Quick Refactoring:**
1. Navigate to Qt Creator > Preferences > QodeAssist > Quick Refactor
2. Check "Enable Thinking Mode"
3. Configure thinking budget and max tokens as needed
4. Click Apply
### Supported Models
Thinking mode works best with models specifically designed for reasoning:
- **DeepSeek-R1** series (deepseek-r1:8b, deepseek-r1:14b, deepseek-r1:32b)
- **QwQ** series (qwq:32b)
- Other models trained for chain-of-thought reasoning
### How It Works
When thinking mode is enabled:
1. The model generates internal reasoning (visible in the chat as "Thinking" blocks)
2. After reasoning, it provides the final answer
3. You can collapse/expand thinking blocks to focus on the final answer
4. Temperature is automatically set to 1.0 for optimal reasoning performance
**Technical Details:**
- Thinking mode adds the `enable_thinking: true` parameter to requests sent to Ollama
- This is natively supported by the Ollama API for compatible models
- Works in both Chat Assistant and Quick Refactoring contexts
<details>
<summary>Example of Ollama settings: (click to expand)</summary>

View File

@ -37,6 +37,7 @@ Configure:
- **Advanced Options**: Penalties, context window size
- **Features**: Tool calling, extended thinking mode
- **System Prompt**: Customize the base prompt for refactoring
- **How quick refactor looks**: Display type and sizes
## Using Quick Refactoring

View File

@ -33,6 +33,31 @@ If issues persist, you can reset settings to their default values:
- API keys are preserved during reset
- You will need to re-select your model after reset
## Chat History Migration
### Images not showing in old chats (version 0.5.x → 0.6.x)
If you have chat histories from QodeAssist version 0.5.x or earlier, images may not display correctly due to a storage structure change.
**Solution:** Rename the content folder for each affected chat:
```bash
# Navigate to your chat history folder
cd ~/path/to/chat_history
# For each chat file, rename its folder
mv chat_name_images chat_name_content
```
**Example:**
```bash
mv my_conversation_2024-11-28_images my_conversation_2024-11-28_content
```
**What changed:**
- Old format (v0.1): Stored files in `chat_name_images/`
- New format (v0.2): Stores all content in `chat_name_content/` (both images and text files)
## Common Issues
### Plugin doesn't appear after installation

View File

@ -18,6 +18,7 @@ add_library(LLMCore STATIC
BaseTool.hpp BaseTool.cpp
ContentBlocks.hpp
RulesLoader.hpp RulesLoader.cpp
ResponseCleaner.hpp
)
target_link_libraries(LLMCore

119
llmcore/ResponseCleaner.hpp Normal file
View File

@ -0,0 +1,119 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QString>
#include <QStringList>
#include <QRegularExpression>
namespace QodeAssist::LLMCore {
class ResponseCleaner
{
public:
static QString clean(const QString &response)
{
QString cleaned = removeCodeBlocks(response);
cleaned = trimWhitespace(cleaned);
cleaned = removeExplanations(cleaned);
return cleaned;
}
private:
static QString removeCodeBlocks(const QString &text)
{
if (!text.contains("```")) {
return text;
}
QRegularExpression codeBlockRegex("```\\w*\\n([\\s\\S]*?)```");
QRegularExpressionMatch match = codeBlockRegex.match(text);
if (match.hasMatch()) {
return match.captured(1);
}
int firstFence = text.indexOf("```");
int lastFence = text.lastIndexOf("```");
if (firstFence != -1 && lastFence > firstFence) {
int firstNewLine = text.indexOf('\n', firstFence);
if (firstNewLine != -1) {
return text.mid(firstNewLine + 1, lastFence - firstNewLine - 1);
}
}
return text;
}
static QString trimWhitespace(const QString &text)
{
QString result = text;
while (result.startsWith('\n') || result.startsWith('\r')) {
result = result.mid(1);
}
while (result.endsWith('\n') || result.endsWith('\r')) {
result.chop(1);
}
return result;
}
static QString removeExplanations(const QString &text)
{
static const QStringList explanationPrefixes = {
"here's the", "here is the", "here's", "here is",
"the refactored", "refactored code:", "code:",
"i've refactored", "i refactored", "i've changed", "i changed"
};
QStringList lines = text.split('\n');
int startLine = 0;
for (int i = 0; i < qMin(3, lines.size()); ++i) {
QString line = lines[i].trimmed().toLower();
bool isExplanation = false;
for (const QString &prefix : explanationPrefixes) {
if (line.startsWith(prefix) || line.contains(prefix + " code")) {
isExplanation = true;
break;
}
}
if (line.length() < 50 && line.endsWith(':')) {
isExplanation = true;
}
if (isExplanation) {
startLine = i + 1;
} else if (!line.isEmpty()) {
break;
}
}
if (startLine > 0 && startLine < lines.size()) {
lines = lines.mid(startLine);
return lines.join('\n');
}
return text;
}
};
} // namespace QodeAssist::LLMCore

View File

@ -70,6 +70,22 @@ void OllamaMessage::handleToolCall(const QJsonObject &toolCall)
LOG_MESSAGE(
QString("OllamaMessage: Structured tool call detected - name=%1, id=%2").arg(name, toolId));
}
void OllamaMessage::handleThinkingDelta(const QString &thinking)
{
LLMCore::ThinkingContent *thinkingContent = getOrCreateThinkingContent();
thinkingContent->appendThinking(thinking);
}
void OllamaMessage::handleThinkingComplete(const QString &signature)
{
if (m_currentThinkingContent) {
m_currentThinkingContent->setSignature(signature);
LOG_MESSAGE(QString("OllamaMessage: Set thinking signature, length=%1")
.arg(signature.length()));
}
}
void OllamaMessage::handleDone(bool done)
{
m_done = done;
@ -216,6 +232,7 @@ QJsonObject OllamaMessage::toProviderFormat() const
QString textContent;
QJsonArray toolCalls;
QString thinkingContent;
for (auto block : m_currentBlocks) {
if (!block)
@ -228,9 +245,15 @@ QJsonObject OllamaMessage::toProviderFormat() const
toolCall["type"] = "function";
toolCall["function"] = QJsonObject{{"name", tool->name()}, {"arguments", tool->input()}};
toolCalls.append(toolCall);
} else if (auto thinking = qobject_cast<LLMCore::ThinkingContent *>(block)) {
thinkingContent += thinking->thinking();
}
}
if (!thinkingContent.isEmpty()) {
message["thinking"] = thinkingContent;
}
if (!textContent.isEmpty()) {
message["content"] = textContent;
}
@ -275,6 +298,17 @@ QList<LLMCore::ToolUseContent *> OllamaMessage::getCurrentToolUseContent() const
return toolBlocks;
}
QList<LLMCore::ThinkingContent *> OllamaMessage::getCurrentThinkingContent() const
{
QList<LLMCore::ThinkingContent *> thinkingBlocks;
for (auto block : m_currentBlocks) {
if (auto thinkingContent = qobject_cast<LLMCore::ThinkingContent *>(block)) {
thinkingBlocks.append(thinkingContent);
}
}
return thinkingBlocks;
}
void OllamaMessage::startNewContinuation()
{
LOG_MESSAGE(QString("OllamaMessage: Starting new continuation"));
@ -284,6 +318,7 @@ void OllamaMessage::startNewContinuation()
m_done = false;
m_state = LLMCore::MessageState::Building;
m_contentAddedToTextBlock = false;
m_currentThinkingContent = nullptr;
}
void OllamaMessage::updateStateFromDone()
@ -309,4 +344,22 @@ LLMCore::TextContent *OllamaMessage::getOrCreateTextContent()
return addCurrentContent<LLMCore::TextContent>();
}
LLMCore::ThinkingContent *OllamaMessage::getOrCreateThinkingContent()
{
if (m_currentThinkingContent) {
return m_currentThinkingContent;
}
for (auto block : m_currentBlocks) {
if (auto thinkingContent = qobject_cast<LLMCore::ThinkingContent *>(block)) {
m_currentThinkingContent = thinkingContent;
return m_currentThinkingContent;
}
}
m_currentThinkingContent = addCurrentContent<LLMCore::ThinkingContent>();
LOG_MESSAGE(QString("OllamaMessage: Created new ThinkingContent block"));
return m_currentThinkingContent;
}
} // namespace QodeAssist::Providers

View File

@ -31,6 +31,8 @@ public:
void handleContentDelta(const QString &content);
void handleToolCall(const QJsonObject &toolCall);
void handleThinkingDelta(const QString &thinking);
void handleThinkingComplete(const QString &signature);
void handleDone(bool done);
QJsonObject toProviderFormat() const;
@ -38,6 +40,7 @@ public:
LLMCore::MessageState state() const { return m_state; }
QList<LLMCore::ToolUseContent *> getCurrentToolUseContent() const;
QList<LLMCore::ThinkingContent *> getCurrentThinkingContent() const;
QList<LLMCore::ContentBlock *> currentBlocks() const { return m_currentBlocks; }
void startNewContinuation();
@ -48,11 +51,13 @@ private:
QList<LLMCore::ContentBlock *> m_currentBlocks;
QString m_accumulatedContent;
bool m_contentAddedToTextBlock = false;
LLMCore::ThinkingContent *m_currentThinkingContent = nullptr;
void updateStateFromDone();
bool tryParseToolCall();
bool isLikelyToolCallJson(const QString &content) const;
LLMCore::TextContent *getOrCreateTextContent();
LLMCore::ThinkingContent *getOrCreateThinkingContent();
template<typename T, typename... Args>
T *addCurrentContent(Args &&...args)

View File

@ -1,4 +1,4 @@
/*
/*
* Copyright (C) 2024-2025 Petr Mironychev
*
* This file is part of QodeAssist.
@ -104,12 +104,31 @@ void OllamaProvider::prepareRequest(
request["keep_alive"] = settings.ollamaLivetime();
};
auto applyThinkingMode = [&request]() {
request["enable_thinking"] = true;
QJsonObject options = request["options"].toObject();
options["temperature"] = 1.0;
request["options"] = options;
};
if (type == LLMCore::RequestType::CodeCompletion) {
applySettings(Settings::codeCompletionSettings());
} else if (type == LLMCore::RequestType::QuickRefactoring) {
applySettings(Settings::quickRefactorSettings());
const auto &qrSettings = Settings::quickRefactorSettings();
applySettings(qrSettings);
if (isThinkingEnabled) {
applyThinkingMode();
LOG_MESSAGE(QString("OllamaProvider: Thinking mode enabled for QuickRefactoring"));
}
} else {
applySettings(Settings::chatAssistantSettings());
const auto &chatSettings = Settings::chatAssistantSettings();
applySettings(chatSettings);
if (isThinkingEnabled) {
applyThinkingMode();
LOG_MESSAGE(QString("OllamaProvider: Thinking mode enabled for Chat"));
}
}
if (isToolsEnabled) {
@ -247,6 +266,11 @@ bool OllamaProvider::supportImage() const
return true;
}
bool OllamaProvider::supportThinking() const
{
return true;
}
void OllamaProvider::cancelRequest(const LLMCore::RequestID &requestId)
{
LOG_MESSAGE(QString("OllamaProvider: Cancelling request %1").arg(requestId));
@ -405,12 +429,43 @@ void OllamaProvider::processStreamData(const QString &requestId, const QJsonObje
LOG_MESSAGE(QString("Cleared message state for continuation request %1").arg(requestId));
}
if (data.contains("thinking")) {
QString thinkingDelta = data["thinking"].toString();
if (!thinkingDelta.isEmpty()) {
message->handleThinkingDelta(thinkingDelta);
LOG_MESSAGE(QString("OllamaProvider: Received thinking delta, length=%1")
.arg(thinkingDelta.length()));
}
}
if (data.contains("message")) {
QJsonObject messageObj = data["message"].toObject();
if (messageObj.contains("thinking")) {
QString thinkingDelta = messageObj["thinking"].toString();
if (!thinkingDelta.isEmpty()) {
message->handleThinkingDelta(thinkingDelta);
if (!m_thinkingStarted.contains(requestId)) {
auto thinkingBlocks = message->getCurrentThinkingContent();
if (!thinkingBlocks.isEmpty() && thinkingBlocks.first()) {
QString currentThinking = thinkingBlocks.first()->thinking();
QString displayThinking = currentThinking.length() > 50
? QString("%1...").arg(currentThinking.left(50))
: currentThinking;
emit thinkingBlockReceived(requestId, displayThinking, "");
m_thinkingStarted.insert(requestId);
}
}
}
}
if (messageObj.contains("content")) {
QString content = messageObj["content"].toString();
if (!content.isEmpty()) {
emitThinkingBlocks(requestId, message);
message->handleContentDelta(content);
bool hasTextContent = false;
@ -460,6 +515,13 @@ void OllamaProvider::processStreamData(const QString &requestId, const QJsonObje
}
if (data["done"].toBool()) {
if (data.contains("signature")) {
QString signature = data["signature"].toString();
message->handleThinkingComplete(signature);
LOG_MESSAGE(QString("OllamaProvider: Set thinking signature, length=%1")
.arg(signature.length()));
}
message->handleDone(true);
handleMessageComplete(requestId);
}
@ -472,6 +534,8 @@ void OllamaProvider::handleMessageComplete(const QString &requestId)
OllamaMessage *message = m_messages[requestId];
emitThinkingBlocks(requestId, message);
if (message->state() == LLMCore::MessageState::RequiresToolExecution) {
LOG_MESSAGE(QString("Ollama message requires tool execution for %1").arg(requestId));
@ -517,6 +581,32 @@ void OllamaProvider::cleanupRequest(const LLMCore::RequestID &requestId)
m_dataBuffers.remove(requestId);
m_requestUrls.remove(requestId);
m_originalRequests.remove(requestId);
m_thinkingEmitted.remove(requestId);
m_thinkingStarted.remove(requestId);
m_toolsManager->cleanupRequest(requestId);
}
void OllamaProvider::emitThinkingBlocks(const QString &requestId, OllamaMessage *message)
{
if (!message || m_thinkingEmitted.contains(requestId)) {
return;
}
auto thinkingBlocks = message->getCurrentThinkingContent();
if (thinkingBlocks.isEmpty()) {
return;
}
for (auto thinkingContent : thinkingBlocks) {
emit thinkingBlockReceived(
requestId, thinkingContent->thinking(), thinkingContent->signature());
LOG_MESSAGE(QString("Emitted thinking block for request %1, thinking length=%2, signature "
"length=%3")
.arg(requestId)
.arg(thinkingContent->thinking().length())
.arg(thinkingContent->signature().length()));
}
m_thinkingEmitted.insert(requestId);
}
} // namespace QodeAssist::Providers

View File

@ -55,6 +55,7 @@ public:
bool supportsTools() const override;
bool supportImage() const override;
bool supportThinking() const override;
void cancelRequest(const LLMCore::RequestID &requestId) override;
public slots:
@ -73,10 +74,13 @@ private:
void processStreamData(const QString &requestId, const QJsonObject &data);
void handleMessageComplete(const QString &requestId);
void cleanupRequest(const LLMCore::RequestID &requestId);
void emitThinkingBlocks(const QString &requestId, OllamaMessage *message);
QHash<QodeAssist::LLMCore::RequestID, OllamaMessage *> m_messages;
QHash<QodeAssist::LLMCore::RequestID, QUrl> m_requestUrls;
QHash<QodeAssist::LLMCore::RequestID, QJsonObject> m_originalRequests;
QSet<QString> m_thinkingEmitted;
QSet<QString> m_thinkingStarted;
Tools::ToolsManager *m_toolsManager;
};

View File

@ -62,6 +62,7 @@
#include "widgets/CustomInstructionsManager.hpp"
#include "widgets/QuickRefactorDialog.hpp"
#include <ChatView/ChatView.hpp>
#include <ChatView/ChatFileManager.hpp>
#include <coreplugin/actionmanager/actioncontainer.h>
#include <coreplugin/actionmanager/actionmanager.h>
#include <texteditor/textdocument.h>
@ -87,6 +88,8 @@ public:
~QodeAssistPlugin() final
{
Chat::ChatFileManager::cleanupGlobalIntermediateStorage();
delete m_qodeAssistClient;
if (m_chatOutputPane) {
delete m_chatOutputPane;
@ -249,6 +252,8 @@ public:
editorContextMenu->addAction(closeChatViewAction.command(),
Core::Constants::G_DEFAULT_THREE);
}
Chat::ChatFileManager::cleanupGlobalIntermediateStorage();
}
void extensionsInitialized() final {}

View File

@ -68,6 +68,10 @@ ChatAssistantSettings::ChatAssistantSettings()
enableChatInNavigationPanel.setLabelText(Tr::tr("Enable chat in navigation panel"));
enableChatInNavigationPanel.setDefaultValue(false);
enableChatTools.setSettingsKey(Constants::CA_ENABLE_CHAT_TOOLS);
enableChatTools.setLabelText(Tr::tr("Enable tools/function calling"));
enableChatTools.setToolTip(Tr::tr("When enabled, AI can use tools to read files, search project, and build code"));
enableChatTools.setDefaultValue(false);
// General Parameters Settings
temperature.setSettingsKey(Constants::CA_TEMPERATURE);
@ -146,10 +150,11 @@ ChatAssistantSettings::ChatAssistantSettings()
// Extended Thinking Settings
enableThinkingMode.setSettingsKey(Constants::CA_ENABLE_THINKING_MODE);
enableThinkingMode.setLabelText(Tr::tr("Enable extended thinking mode (Claude only).\n Temperature is 1.0 accordingly API requerement"));
enableThinkingMode.setLabelText(Tr::tr("Enable extended thinking mode."));
enableThinkingMode.setToolTip(
Tr::tr("Enable Claude's extended thinking mode for complex reasoning tasks. "
"This provides step-by-step reasoning before the final answer."));
Tr::tr("Enable extended thinking mode for complex reasoning tasks."
"This provides step-by-step reasoning before the final answer."
"Temperature is 1.0 accordingly API requirement"));
enableThinkingMode.setDefaultValue(false);
thinkingBudgetTokens.setSettingsKey(Constants::CA_THINKING_BUDGET_TOKENS);
@ -283,6 +288,14 @@ ChatAssistantSettings::ChatAssistantSettings()
enableChatInBottomToolBar,
enableChatInNavigationPanel}},
Space{8},
Group{
title(Tr::tr("Tools")),
Column{enableChatTools}},
Space{8},
Group{
title(Tr::tr("Extended Thinking (if provider/model supports)")),
Column{enableThinkingMode, Row{thinkingGrid, Stretch{1}}}},
Space{8},
Group{
title(Tr::tr("General Parameters")),
Row{genGrid, Stretch{1}},
@ -297,9 +310,6 @@ ChatAssistantSettings::ChatAssistantSettings()
systemPrompt,
}},
Group{title(Tr::tr("Ollama Settings")), Column{Row{ollamaGrid, Stretch{1}}}},
Group{
title(Tr::tr("Extended Thinking (Claude Only)")),
Column{enableThinkingMode, Row{thinkingGrid, Stretch{1}}}},
Group{title(Tr::tr("Chat Settings")), Row{chatViewSettingsGrid, Stretch{1}}},
Stretch{1}};
});
@ -343,6 +353,7 @@ void ChatAssistantSettings::resetSettingsToDefaults()
resetAspect(thinkingBudgetTokens);
resetAspect(thinkingMaxTokens);
resetAspect(linkOpenFiles);
resetAspect(enableChatTools);
resetAspect(textFontFamily);
resetAspect(codeFontFamily);
resetAspect(textFontSize);

View File

@ -38,6 +38,7 @@ public:
Utils::BoolAspect autosave{this};
Utils::BoolAspect enableChatInBottomToolBar{this};
Utils::BoolAspect enableChatInNavigationPanel{this};
Utils::BoolAspect enableChatTools{this};
// General Parameters Settings
Utils::DoubleAspect temperature{this};

View File

@ -155,6 +155,50 @@ QuickRefactorSettings::QuickRefactorSettings()
readStringsAfterCursor.setRange(0, 10000);
readStringsAfterCursor.setDefaultValue(30);
displayMode.setSettingsKey(Constants::QR_DISPLAY_MODE);
displayMode.setLabelText(Tr::tr("Display Mode:"));
displayMode.setToolTip(
Tr::tr("Choose how to display refactoring suggestions:\n"
"- Inline Widget: Shows refactor in a widget overlay with Apply/Decline buttons (default)\n"
"- Qt Creator Suggestion: Uses Qt Creator's built-in suggestion system"));
displayMode.addOption(Tr::tr("Inline Widget"));
displayMode.addOption(Tr::tr("Qt Creator Suggestion"));
displayMode.setDefaultValue(0);
widgetOrientation.setSettingsKey(Constants::QR_WIDGET_ORIENTATION);
widgetOrientation.setLabelText(Tr::tr("Widget Orientation:"));
widgetOrientation.setToolTip(
Tr::tr("Choose default orientation for refactor widget:\n"
"- Horizontal: Original and refactored code side by side (default)\n"
"- Vertical: Original and refactored code stacked vertically"));
widgetOrientation.addOption(Tr::tr("Horizontal"));
widgetOrientation.addOption(Tr::tr("Vertical"));
widgetOrientation.setDefaultValue(0);
widgetMinWidth.setSettingsKey(Constants::QR_WIDGET_MIN_WIDTH);
widgetMinWidth.setLabelText(Tr::tr("Widget Minimum Width:"));
widgetMinWidth.setToolTip(Tr::tr("Minimum width for the refactor widget (in pixels)"));
widgetMinWidth.setRange(200, 999999);
widgetMinWidth.setDefaultValue(400);
widgetMaxWidth.setSettingsKey(Constants::QR_WIDGET_MAX_WIDTH);
widgetMaxWidth.setLabelText(Tr::tr("Widget Maximum Width:"));
widgetMaxWidth.setToolTip(Tr::tr("Maximum width for the refactor widget (in pixels)"));
widgetMaxWidth.setRange(400, 999999);
widgetMaxWidth.setDefaultValue(9999);
widgetMinHeight.setSettingsKey(Constants::QR_WIDGET_MIN_HEIGHT);
widgetMinHeight.setLabelText(Tr::tr("Widget Minimum Height:"));
widgetMinHeight.setToolTip(Tr::tr("Minimum height for the refactor widget (in pixels)"));
widgetMinHeight.setRange(80, 999999);
widgetMinHeight.setDefaultValue(100);
widgetMaxHeight.setSettingsKey(Constants::QR_WIDGET_MAX_HEIGHT);
widgetMaxHeight.setLabelText(Tr::tr("Widget Maximum Height:"));
widgetMaxHeight.setToolTip(Tr::tr("Maximum height for the refactor widget (in pixels)"));
widgetMaxHeight.setRange(200, 999999);
widgetMaxHeight.setDefaultValue(9999);
systemPrompt.setSettingsKey(Constants::QR_SYSTEM_PROMPT);
systemPrompt.setLabelText(Tr::tr("System Prompt:"));
systemPrompt.setDisplayStyle(Utils::StringAspect::TextEditDisplay);
@ -198,6 +242,12 @@ QuickRefactorSettings::QuickRefactorSettings()
contextGrid.addRow({Row{readFullFile}});
contextGrid.addRow({Row{readFileParts, readStringsBeforeCursor, readStringsAfterCursor}});
auto displayGrid = Grid{};
displayGrid.addRow({Row{displayMode}});
displayGrid.addRow({Row{widgetOrientation}});
displayGrid.addRow({Row{widgetMinWidth, widgetMaxWidth}});
displayGrid.addRow({Row{widgetMinHeight, widgetMaxHeight}});
return Column{
Row{Stretch{1}, resetToDefaults},
Space{8},
@ -212,6 +262,8 @@ QuickRefactorSettings::QuickRefactorSettings()
Space{8},
Group{title(Tr::tr("Context Settings")), Column{Row{contextGrid, Stretch{1}}}},
Space{8},
Group{title(Tr::tr("Display Settings")), Column{Row{displayGrid, Stretch{1}}}},
Space{8},
Group{title(Tr::tr("Prompt Settings")), Column{Row{systemPrompt}}},
Space{8},
Group{title(Tr::tr("Ollama Settings")), Column{Row{ollamaGrid, Stretch{1}}}},
@ -240,6 +292,32 @@ void QuickRefactorSettings::setupConnections()
writeSettings();
}
});
// Enable/disable widgetOrientation based on displayMode
// 0 = Inline Widget, 1 = Qt Creator Suggestion
auto updateWidgetOrientationEnabled = [this]() {
bool isInlineWidget = (displayMode.volatileValue() == 0);
widgetOrientation.setEnabled(isInlineWidget);
};
connect(&displayMode, &Utils::SelectionAspect::volatileValueChanged,
this, updateWidgetOrientationEnabled);
updateWidgetOrientationEnabled();
auto validateWidgetSizes = [this]() {
if (widgetMinWidth.volatileValue() > widgetMaxWidth.volatileValue()) {
widgetMaxWidth.setValue(widgetMinWidth.volatileValue());
}
if (widgetMinHeight.volatileValue() > widgetMaxHeight.volatileValue()) {
widgetMaxHeight.setValue(widgetMinHeight.volatileValue());
}
};
connect(&widgetMinWidth, &Utils::IntegerAspect::volatileValueChanged, this, validateWidgetSizes);
connect(&widgetMaxWidth, &Utils::IntegerAspect::volatileValueChanged, this, validateWidgetSizes);
connect(&widgetMinHeight, &Utils::IntegerAspect::volatileValueChanged, this, validateWidgetSizes);
connect(&widgetMaxHeight, &Utils::IntegerAspect::volatileValueChanged, this, validateWidgetSizes);
}
void QuickRefactorSettings::resetSettingsToDefaults()
@ -272,6 +350,12 @@ void QuickRefactorSettings::resetSettingsToDefaults()
resetAspect(readFileParts);
resetAspect(readStringsBeforeCursor);
resetAspect(readStringsAfterCursor);
resetAspect(displayMode);
resetAspect(widgetOrientation);
resetAspect(widgetMinWidth);
resetAspect(widgetMaxWidth);
resetAspect(widgetMinHeight);
resetAspect(widgetMaxHeight);
resetAspect(systemPrompt);
writeSettings();
}

View File

@ -67,6 +67,14 @@ public:
Utils::IntegerAspect readStringsBeforeCursor{this};
Utils::IntegerAspect readStringsAfterCursor{this};
// Display Settings
Utils::SelectionAspect displayMode{this};
Utils::SelectionAspect widgetOrientation{this};
Utils::IntegerAspect widgetMinWidth{this};
Utils::IntegerAspect widgetMaxWidth{this};
Utils::IntegerAspect widgetMinHeight{this};
Utils::IntegerAspect widgetMaxHeight{this};
// Prompt Settings
Utils::StringAspect systemPrompt{this};

View File

@ -102,12 +102,19 @@ const char CC_CUSTOM_LANGUAGES[] = "QodeAssist.ccCustomLanguages";
const char CA_ENABLE_CHAT_IN_BOTTOM_TOOLBAR[] = "QodeAssist.caEnableChatInBottomToolbar";
const char CA_ENABLE_CHAT_IN_NAVIGATION_PANEL[] = "QodeAssist.caEnableChatInNavigationPanel";
const char CA_ENABLE_CHAT_TOOLS[] = "QodeAssist.caEnableChatTools";
const char CA_USE_TOOLS[] = "QodeAssist.caUseTools";
const char CA_ALLOW_FILE_SYSTEM_READ[] = "QodeAssist.caAllowFileSystemRead";
const char CA_ALLOW_FILE_SYSTEM_WRITE[] = "QodeAssist.caAllowFileSystemWrite";
const char CA_ALLOW_NETWORK_ACCESS[] = "QodeAssist.caAllowNetworkAccess";
const char CA_ALLOW_ACCESS_OUTSIDE_PROJECT[] = "QodeAssist.caAllowAccessOutsideProject";
const char CA_ENABLE_EDIT_FILE_TOOL[] = "QodeAssist.caEnableEditFileTool";
const char CA_ENABLE_BUILD_PROJECT_TOOL[] = "QodeAssist.caEnableBuildProjectTool";
const char CA_ENABLE_TERMINAL_COMMAND_TOOL[] = "QodeAssist.caEnableTerminalCommandTool";
const char CA_ALLOWED_TERMINAL_COMMANDS[] = "QodeAssist.caAllowedTerminalCommands";
const char CA_ALLOWED_TERMINAL_COMMANDS_LINUX[] = "QodeAssist.caAllowedTerminalCommandsLinux";
const char CA_ALLOWED_TERMINAL_COMMANDS_MACOS[] = "QodeAssist.caAllowedTerminalCommandsMacOS";
const char CA_ALLOWED_TERMINAL_COMMANDS_WINDOWS[] = "QodeAssist.caAllowedTerminalCommandsWindows";
const char QODE_ASSIST_GENERAL_OPTIONS_ID[] = "QodeAssist.GeneralOptions";
const char QODE_ASSIST_GENERAL_SETTINGS_PAGE_ID[] = "QodeAssist.1GeneralSettingsPageId";
@ -218,5 +225,11 @@ const char QR_READ_FULL_FILE[] = "QodeAssist.qrReadFullFile";
const char QR_READ_STRINGS_BEFORE_CURSOR[] = "QodeAssist.qrReadStringsBeforeCursor";
const char QR_READ_STRINGS_AFTER_CURSOR[] = "QodeAssist.qrReadStringsAfterCursor";
const char QR_SYSTEM_PROMPT[] = "QodeAssist.qrSystemPrompt";
const char QR_DISPLAY_MODE[] = "QodeAssist.qrDisplayMode";
const char QR_WIDGET_ORIENTATION[] = "QodeAssist.qrWidgetOrientation";
const char QR_WIDGET_MIN_WIDTH[] = "QodeAssist.qrWidgetMinWidth";
const char QR_WIDGET_MAX_WIDTH[] = "QodeAssist.qrWidgetMaxWidth";
const char QR_WIDGET_MIN_HEIGHT[] = "QodeAssist.qrWidgetMinHeight";
const char QR_WIDGET_MAX_HEIGHT[] = "QodeAssist.qrWidgetMaxHeight";
} // namespace QodeAssist::Constants

View File

@ -42,13 +42,6 @@ ToolsSettings::ToolsSettings()
setDisplayName(Tr::tr("Tools"));
useTools.setSettingsKey(Constants::CA_USE_TOOLS);
useTools.setLabelText(Tr::tr("Enable tools"));
useTools.setToolTip(Tr::tr(
"Enable tool use capabilities for the assistant (OpenAI function calling, Claude tools "
"and etc) if plugin and provider support"));
useTools.setDefaultValue(true);
allowFileSystemRead.setSettingsKey(Constants::CA_ALLOW_FILE_SYSTEM_READ);
allowFileSystemRead.setLabelText(Tr::tr("Allow File System Read Access for tools"));
allowFileSystemRead.setToolTip(
@ -61,6 +54,13 @@ ToolsSettings::ToolsSettings()
Tr::tr("Allow tools to write and modify files on disk (WARNING: Use with caution!)"));
allowFileSystemWrite.setDefaultValue(false);
allowNetworkAccess.setSettingsKey(Constants::CA_ALLOW_NETWORK_ACCESS);
allowNetworkAccess.setLabelText(Tr::tr("Allow Network Access for tools"));
allowNetworkAccess.setToolTip(
Tr::tr("Allow tools to make network requests (e.g., execute commands like git, curl, wget). "
"Required for ExecuteTerminalCommandTool with network-capable commands."));
allowNetworkAccess.setDefaultValue(false);
allowAccessOutsideProject.setSettingsKey(Constants::CA_ALLOW_ACCESS_OUTSIDE_PROJECT);
allowAccessOutsideProject.setLabelText(Tr::tr("Allow file access outside project"));
allowAccessOutsideProject.setToolTip(
@ -89,6 +89,38 @@ ToolsSettings::ToolsSettings()
"project. This feature is under testing and may have unexpected behavior."));
enableBuildProjectTool.setDefaultValue(false);
enableTerminalCommandTool.setSettingsKey(Constants::CA_ENABLE_TERMINAL_COMMAND_TOOL);
enableTerminalCommandTool.setLabelText(Tr::tr("Enable Terminal Command Tool (Experimental)"));
enableTerminalCommandTool.setToolTip(
Tr::tr("Enable the experimental execute_terminal_command tool that allows AI to execute "
"terminal commands from the allowed list. This feature is under testing and may have "
"unexpected behavior."));
enableTerminalCommandTool.setDefaultValue(false);
allowedTerminalCommandsLinux.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_LINUX);
allowedTerminalCommandsLinux.setLabelText(Tr::tr("Allowed Commands (Linux)"));
allowedTerminalCommandsLinux.setToolTip(
Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on Linux. "
"Example: git, ls, cat, grep, find, cmake"));
allowedTerminalCommandsLinux.setDisplayStyle(Utils::StringAspect::LineEditDisplay);
allowedTerminalCommandsLinux.setDefaultValue("git, ls, cat, grep, find");
allowedTerminalCommandsMacOS.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_MACOS);
allowedTerminalCommandsMacOS.setLabelText(Tr::tr("Allowed Commands (macOS)"));
allowedTerminalCommandsMacOS.setToolTip(
Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on macOS. "
"Example: git, ls, cat, grep, find, cmake"));
allowedTerminalCommandsMacOS.setDisplayStyle(Utils::StringAspect::LineEditDisplay);
allowedTerminalCommandsMacOS.setDefaultValue("git, ls, cat, grep, find");
allowedTerminalCommandsWindows.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_WINDOWS);
allowedTerminalCommandsWindows.setLabelText(Tr::tr("Allowed Commands (Windows)"));
allowedTerminalCommandsWindows.setToolTip(
Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on Windows. "
"Example: git, dir, type, findstr, where, cmake"));
allowedTerminalCommandsWindows.setDisplayStyle(Utils::StringAspect::LineEditDisplay);
allowedTerminalCommandsWindows.setDefaultValue("git, dir, type, findstr, where");
resetToDefaults.m_buttonText = Tr::tr("Reset Page to Defaults");
readSettings();
@ -98,22 +130,36 @@ ToolsSettings::ToolsSettings()
setLayouter([this]() {
using namespace Layouting;
#ifdef Q_OS_LINUX
auto &currentOsCommands = allowedTerminalCommandsLinux;
#elif defined(Q_OS_MACOS)
auto &currentOsCommands = allowedTerminalCommandsMacOS;
#elif defined(Q_OS_WIN)
auto &currentOsCommands = allowedTerminalCommandsWindows;
#else
auto &currentOsCommands = allowedTerminalCommandsLinux; // fallback
#endif
return Column{
Row{Stretch{1}, resetToDefaults},
Space{8},
Group{
title(Tr::tr("Tool Settings")),
Column{
useTools,
Space{8},
allowFileSystemRead,
allowFileSystemWrite,
allowNetworkAccess,
allowAccessOutsideProject
}},
Space{8},
Group{
title(Tr::tr("Experimental Features")),
Column{enableEditFileTool, enableBuildProjectTool, autoApplyFileEdits}},
Column{
enableEditFileTool,
enableBuildProjectTool,
enableTerminalCommandTool,
currentOsCommands,
autoApplyFileEdits}},
Stretch{1}};
});
}
@ -137,13 +183,17 @@ void ToolsSettings::resetSettingsToDefaults()
QMessageBox::Yes | QMessageBox::No);
if (reply == QMessageBox::Yes) {
resetAspect(useTools);
resetAspect(allowFileSystemRead);
resetAspect(allowFileSystemWrite);
resetAspect(allowNetworkAccess);
resetAspect(allowAccessOutsideProject);
resetAspect(autoApplyFileEdits);
resetAspect(enableEditFileTool);
resetAspect(enableBuildProjectTool);
resetAspect(enableTerminalCommandTool);
resetAspect(allowedTerminalCommandsLinux);
resetAspect(allowedTerminalCommandsMacOS);
resetAspect(allowedTerminalCommandsWindows);
writeSettings();
}
}

View File

@ -32,14 +32,18 @@ public:
ButtonAspect resetToDefaults{this};
Utils::BoolAspect useTools{this};
Utils::BoolAspect allowFileSystemRead{this};
Utils::BoolAspect allowFileSystemWrite{this};
Utils::BoolAspect allowNetworkAccess{this};
Utils::BoolAspect allowAccessOutsideProject{this};
// Experimental features
Utils::BoolAspect enableEditFileTool{this};
Utils::BoolAspect enableBuildProjectTool{this};
Utils::BoolAspect enableTerminalCommandTool{this};
Utils::StringAspect allowedTerminalCommandsLinux{this};
Utils::StringAspect allowedTerminalCommandsMacOS{this};
Utils::StringAspect allowedTerminalCommandsWindows{this};
Utils::BoolAspect autoApplyFileEdits{this};
private:

View File

@ -44,6 +44,11 @@ public:
if (msg.role == "system") continue;
if (msg.isThinking) {
// Claude API requires signature for thinking blocks
if (msg.signature.isEmpty()) {
continue;
}
QJsonArray content;
QJsonObject thinkingBlock;
thinkingBlock["type"] = msg.isRedacted ? "redacted_thinking" : "thinking";
@ -57,9 +62,7 @@ public:
if (!msg.isRedacted) {
thinkingBlock["thinking"] = thinkingText;
}
if (!msg.signature.isEmpty()) {
thinkingBlock["signature"] = msg.signature;
}
thinkingBlock["signature"] = msg.signature;
content.append(thinkingBlock);
messages.append(QJsonObject{{"role", "assistant"}, {"content", content}});

View File

@ -46,36 +46,58 @@ public:
QJsonObject content;
QJsonArray parts;
if (!msg.content.isEmpty()) {
parts.append(QJsonObject{{"text", msg.content}});
}
if (msg.images && !msg.images->isEmpty()) {
for (const auto &image : msg.images.value()) {
QJsonObject imagePart;
if (image.isUrl) {
QJsonObject fileData;
fileData["mime_type"] = image.mediaType;
fileData["file_uri"] = image.data;
imagePart["file_data"] = fileData;
} else {
QJsonObject inlineData;
inlineData["mime_type"] = image.mediaType;
inlineData["data"] = image.data;
imagePart["inline_data"] = inlineData;
}
parts.append(imagePart);
if (msg.isThinking) {
if (!msg.content.isEmpty()) {
QJsonObject thinkingPart;
thinkingPart["text"] = msg.content;
thinkingPart["thought"] = true;
parts.append(thinkingPart);
}
if (!msg.signature.isEmpty()) {
QJsonObject signaturePart;
signaturePart["thoughtSignature"] = msg.signature;
parts.append(signaturePart);
}
if (parts.isEmpty()) {
continue;
}
content["role"] = "model";
} else {
if (!msg.content.isEmpty()) {
parts.append(QJsonObject{{"text", msg.content}});
}
if (msg.images && !msg.images->isEmpty()) {
for (const auto &image : msg.images.value()) {
QJsonObject imagePart;
if (image.isUrl) {
QJsonObject fileData;
fileData["mime_type"] = image.mediaType;
fileData["file_uri"] = image.data;
imagePart["file_data"] = fileData;
} else {
QJsonObject inlineData;
inlineData["mime_type"] = image.mediaType;
inlineData["data"] = image.data;
imagePart["inline_data"] = inlineData;
}
parts.append(imagePart);
}
}
QString role = msg.role;
if (role == "assistant") {
role = "model";
}
content["role"] = role;
}
QString role = msg.role;
if (role == "assistant") {
role = "model";
}
content["role"] = role;
content["parts"] = parts;
contents.append(content);
}
@ -95,11 +117,15 @@ public:
" },\n"
" {\n"
" \"role\": \"model\",\n"
" \"parts\": [{\"text\": \"<assistant response>\"}]\n"
" \"parts\": [\n"
" {\"text\": \"<thinking>\", \"thought\": true},\n"
" {\"thoughtSignature\": \"<signature>\"},\n"
" {\"text\": \"<assistant response>\"}\n"
" ]\n"
" }\n"
" ]\n"
"}\n\n"
"Supports proper role mapping, including model/user roles.";
"Supports proper role mapping (model/user roles), images, and thinking blocks.";
}
bool isSupportProvider(LLMCore::ProviderID id) const override

View File

@ -1,4 +1,4 @@
/*
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
@ -19,15 +19,24 @@
#include "BuildProjectTool.hpp"
#include "GetIssuesListTool.hpp"
#include <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 {
@ -36,6 +45,20 @@ BuildProjectTool::BuildProjectTool(QObject *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::name() const
{
return "build_project";
@ -43,14 +66,18 @@ QString BuildProjectTool::name() const
QString BuildProjectTool::stringName() const
{
return "Building project";
return "Building and running project";
}
QString BuildProjectTool::description() const
{
return "Build the current project in Qt Creator. "
"No returns simultaneously build status and any compilation errors/warnings. "
"Optional 'rebuild' parameter: set to true to force a clean rebuild (default: false).";
return "Build the current project in Qt Creator and wait for completion. "
"Optionally run the project after successful build. "
"Returns build status (success/failure) and any compilation errors/warnings after "
"the build finishes. "
"Optional 'rebuild' parameter: set to true to force a clean rebuild (default: false). "
"Optional 'run_after_build' parameter: set to true to run the project after successful build (default: false). "
"Note: This operation may take some time depending on project size.";
}
QJsonObject BuildProjectTool::getDefinition(LLMCore::ToolSchemaFormat format) const
@ -62,6 +89,9 @@ QJsonObject BuildProjectTool::getDefinition(LLMCore::ToolSchemaFormat format) co
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();
@ -82,30 +112,55 @@ QJsonObject BuildProjectTool::getDefinition(LLMCore::ToolSchemaFormat format) co
LLMCore::ToolPermissions BuildProjectTool::requiredPermissions() const
{
return LLMCore::ToolPermission::None;
return LLMCore::ToolPermission::FileSystemRead
| LLMCore::ToolPermission::FileSystemWrite;
}
QFuture<QString> BuildProjectTool::executeAsync(const QJsonObject &input)
{
auto *project = ProjectExplorer::ProjectManager::startupProject();
if (!project) {
LOG_MESSAGE("BuildProjectTool: No active project found");
return QtFuture::makeReadyFuture(
QString("Error: No active project found. Please open a project in Qt Creator."));
}
LOG_MESSAGE(QString("BuildProjectTool: Active project is '%1'").arg(project->displayName()));
if (ProjectExplorer::BuildManager::isBuilding(project)) {
LOG_MESSAGE("BuildProjectTool: Build is already in progress");
return QtFuture::makeReadyFuture(
QString("Error: Build is already in progress. Please wait for it to complete."));
}
bool rebuild = input.value("rebuild").toBool(false);
if (m_activeBuilds.contains(project)) {
return QtFuture::makeReadyFuture(
QString("Error: Build is already being tracked for project '%1'.")
.arg(project->displayName()));
}
LOG_MESSAGE(QString("BuildProjectTool: Starting %1")
.arg(rebuild ? QString("rebuild") : QString("build")));
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<QString>>::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,
@ -120,10 +175,166 @@ QFuture<QString> BuildProjectTool::executeAsync(const QJsonObject &input)
},
Qt::QueuedConnection);
return QtFuture::makeReadyFuture(
QString("Build %1 started for project '%2'. Check the Compile Output pane for progress.")
.arg(rebuild ? QString("rebuild") : QString("build"))
.arg(project->displayName()));
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(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

@ -20,15 +20,34 @@
#pragma once
#include <llmcore/BaseTool.hpp>
#include <QHash>
#include <QObject>
#include <QPointer>
#include <QPromise>
#include <QSharedPointer>
namespace ProjectExplorer {
class Project;
}
namespace QodeAssist::Tools {
struct BuildInfo
{
QSharedPointer<QPromise<QString>> promise;
QPointer<ProjectExplorer::Project> project;
QString projectName;
bool isRebuild = false;
bool runAfterBuild = false;
QMetaObject::Connection buildFinishedConnection;
};
class BuildProjectTool : public LLMCore::BaseTool
{
Q_OBJECT
public:
explicit BuildProjectTool(QObject *parent = nullptr);
~BuildProjectTool() override;
QString name() const override;
QString stringName() const override;
@ -37,6 +56,18 @@ public:
LLMCore::ToolPermissions requiredPermissions() const override;
QFuture<QString> 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

@ -25,6 +25,7 @@
#include <logger/Logger.hpp>
#include <settings/GeneralSettings.hpp>
#include <settings/ToolsSettings.hpp>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
@ -36,7 +37,6 @@ namespace QodeAssist::Tools {
EditFileTool::EditFileTool(QObject *parent)
: BaseTool(parent)
, m_ignoreManager(new Context::IgnoreManager(this))
{}
QString EditFileTool::name() const
@ -52,11 +52,12 @@ QString EditFileTool::stringName() const
QString EditFileTool::description() const
{
return "Edit a file by replacing old content with new content. "
"Provide the filename (or absolute path), old_content to find and replace, "
"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 read the 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 "
@ -77,8 +78,8 @@ QJsonObject EditFileTool::getDefinition(LLMCore::ToolSchemaFormat format) const
QJsonObject filenameProperty;
filenameProperty["type"] = "string";
filenameProperty["description"]
= "The filename or absolute path of the file to edit. If only filename is provided, "
"it will be searched in the project";
= "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;
@ -139,24 +140,22 @@ QFuture<QString> EditFileTool::executeAsync(const QJsonObject &input)
}
QString filePath;
QFileInfo fileInfo(filename);
QString filePath;
if (fileInfo.isAbsolute() && fileInfo.exists()) {
if (fileInfo.isAbsolute()) {
filePath = filename;
} else {
FileSearchUtils::FileMatch match = FileSearchUtils::findBestMatch(
filename, QString(), 10, m_ignoreManager);
if (match.absolutePath.isEmpty()) {
QString projectRoot = Context::ProjectUtils::getProjectRoot();
if (projectRoot.isEmpty()) {
throw ToolRuntimeError(
QString("File '%1' not found in project. "
"Please provide a valid filename or absolute path.")
QString("Cannot resolve relative path '%1': no project is open. "
"Please provide an absolute path or open a project.")
.arg(filename));
}
filePath = match.absolutePath;
LOG_MESSAGE(QString("EditFileTool: Found file '%1' at '%2'")
filePath = QDir(projectRoot).absoluteFilePath(filename);
LOG_MESSAGE(QString("EditFileTool: Resolved relative path '%1' to '%2'")
.arg(filename, filePath));
}

View File

@ -19,9 +19,6 @@
#pragma once
#include "FileSearchUtils.hpp"
#include <context/IgnoreManager.hpp>
#include <llmcore/BaseTool.hpp>
namespace QodeAssist::Tools {
@ -39,9 +36,6 @@ public:
LLMCore::ToolPermissions requiredPermissions() const override;
QFuture<QString> executeAsync(const QJsonObject &input = QJsonObject()) override;
private:
Context::IgnoreManager *m_ignoreManager;
};
} // namespace QodeAssist::Tools

View File

@ -0,0 +1,526 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#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 <QSharedPointer>
#include <QTimer>
namespace QodeAssist::Tools {
ExecuteTerminalCommandTool::ExecuteTerminalCommandTool(QObject *parent)
: BaseTool(parent)
{
}
QString ExecuteTerminalCommandTool::name() const
{
return "execute_terminal_command";
}
QString ExecuteTerminalCommandTool::stringName() const
{
return "Executing terminal command";
}
QString ExecuteTerminalCommandTool::description() const
{
return getCommandDescription();
}
QJsonObject ExecuteTerminalCommandTool::getDefinition(LLMCore::ToolSchemaFormat format) const
{
QJsonObject definition;
definition["type"] = "object";
const QString commandDesc = getCommandDescription();
QJsonObject properties;
properties["command"] = QJsonObject{
{"type", "string"},
{"description", commandDesc}};
properties["args"] = QJsonObject{
{"type", "string"},
{"description",
"Optional arguments for the command. Arguments with spaces should be properly quoted. "
"Example: '--file \"path with spaces.txt\" --verbose'"}};
definition["properties"] = properties;
definition["required"] = QJsonArray{"command"};
switch (format) {
case LLMCore::ToolSchemaFormat::OpenAI:
return customizeForOpenAI(definition);
case LLMCore::ToolSchemaFormat::Claude:
return customizeForClaude(definition);
case LLMCore::ToolSchemaFormat::Ollama:
return customizeForOllama(definition);
case LLMCore::ToolSchemaFormat::Google:
return customizeForGoogle(definition);
}
return definition;
}
LLMCore::ToolPermissions ExecuteTerminalCommandTool::requiredPermissions() const
{
return LLMCore::ToolPermission::FileSystemRead
| LLMCore::ToolPermission::FileSystemWrite
| LLMCore::ToolPermission::NetworkAccess;
}
QFuture<QString> ExecuteTerminalCommandTool::executeAsync(const QJsonObject &input)
{
const QString command = input.value("command").toString().trimmed();
const QString args = input.value("args").toString().trimmed();
if (command.isEmpty()) {
LOG_MESSAGE("ExecuteTerminalCommandTool: Command is empty");
return QtFuture::makeReadyFuture(QString("Error: Command parameter is required."));
}
if (command.length() > MAX_COMMAND_LENGTH) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command too long (%1 chars)")
.arg(command.length()));
return QtFuture::makeReadyFuture(
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(
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(
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(
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(
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(
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<QString>>::create();
QFuture<QString> future = promise->future();
promise->start();
QProcess *process = new QProcess();
process->setWorkingDirectory(workingDir);
process->setProcessChannelMode(QProcess::MergedChannels);
process->setReadChannel(QProcess::StandardOutput);
QTimer *timeoutTimer = new QTimer();
timeoutTimer->setSingleShot(true);
timeoutTimer->setInterval(COMMAND_TIMEOUT_MS);
auto outputSize = QSharedPointer<qint64>::create(0);
QObject::connect(timeoutTimer, &QTimer::timeout, [process, promise, command, args, timeoutTimer]() {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1 %2' timed out after %3ms")
.arg(command)
.arg(args)
.arg(COMMAND_TIMEOUT_MS));
process->terminate();
QTimer::singleShot(1000, process, [process]() {
if (process->state() == QProcess::Running) {
LOG_MESSAGE("ExecuteTerminalCommandTool: Forcefully killing process after timeout");
process->kill();
}
});
promise->addResult(QString("Error: Command '%1 %2' timed out after %3 seconds. "
"The process has been terminated.")
.arg(command)
.arg(args.isEmpty() ? "" : args)
.arg(COMMAND_TIMEOUT_MS / 1000));
promise->finish();
process->deleteLater();
timeoutTimer->deleteLater();
});
QObject::connect(
process,
QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
[this, process, promise, command, args, timeoutTimer, outputSize](
int exitCode, QProcess::ExitStatus exitStatus) {
timeoutTimer->stop();
timeoutTimer->deleteLater();
const QByteArray rawOutput = process->readAll();
*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(
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(
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(
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, command, args, timeoutTimer](
QProcess::ProcessError error) {
if (promise->future().isFinished()) {
return;
}
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(QString("Error: %1").arg(errorMessage));
promise->finish();
process->deleteLater();
});
QString fullCommand = command;
if (!args.isEmpty()) {
fullCommand += " " + 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));
process->start("cmd.exe", QStringList() << "/c" << fullCommand);
} else {
#endif
QStringList splitCommand = QProcess::splitCommand(fullCommand);
if (splitCommand.isEmpty()) {
LOG_MESSAGE("ExecuteTerminalCommandTool: Failed to parse command");
promise->addResult(QString("Error: Failed to parse command '%1'").arg(fullCommand));
promise->finish();
process->deleteLater();
timeoutTimer->deleteLater();
return future;
}
const QString program = splitCommand.takeFirst();
process->start(program, splitCommand);
#ifdef Q_OS_WIN
}
#endif
if (!process->waitForStarted(PROCESS_START_TIMEOUT_MS)) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Failed to start command '%1' within %2ms")
.arg(fullCommand)
.arg(PROCESS_START_TIMEOUT_MS));
const QString errorString = process->errorString();
promise->addResult(QString("Error: Failed to start command '%1': %2\n\n"
"Possible reasons:\n"
"- Command not found in PATH\n"
"- Insufficient permissions\n"
"- Invalid command syntax")
.arg(fullCommand)
.arg(errorString));
promise->finish();
process->deleteLater();
timeoutTimer->deleteLater();
return future;
}
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Process started successfully (PID: %1)")
.arg(process->processId()));
timeoutTimer->start();
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;
}
static const QStringList dangerousPatterns = {
";", // Command separator
"&&", // AND operator
"||", // OR operator
"|", // Pipe operator
">", // Output redirection
">>", // Append redirection
"<", // Input redirection
"`", // Command substitution
"$(", // Command substitution
"$()", // Command substitution
"\\n", // Newline (could start new command)
"\\r" // Carriage return
};
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
{
static QString cachedCommandsStr;
static QStringList cachedCommands;
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 == cachedCommandsStr && !cachedCommands.isEmpty()) {
return cachedCommands;
}
cachedCommandsStr = commandsStr;
cachedCommands.clear();
if (commandsStr.isEmpty()) {
return QStringList();
}
const QStringList rawCommands = commandsStr.split(',', Qt::SkipEmptyParts);
cachedCommands.reserve(rawCommands.size());
for (const QString &cmd : rawCommands) {
const QString trimmed = cmd.trimmed();
if (!trimmed.isEmpty()) {
cachedCommands.append(trimmed);
}
}
return cachedCommands;
}
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(COMMAND_TIMEOUT_MS / 1000)
.arg(osInfo);
}
} // namespace QodeAssist::Tools

View File

@ -0,0 +1,58 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <llmcore/BaseTool.hpp>
#include <QObject>
namespace QodeAssist::Tools {
class ExecuteTerminalCommandTool : public LLMCore::BaseTool
{
Q_OBJECT
public:
explicit ExecuteTerminalCommandTool(QObject *parent = nullptr);
QString name() const override;
QString stringName() const override;
QString description() const override;
QJsonObject getDefinition(LLMCore::ToolSchemaFormat format) const override;
LLMCore::ToolPermissions requiredPermissions() const override;
QFuture<QString> 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;
// Constants for production safety
static constexpr int COMMAND_TIMEOUT_MS = 30000; // 30 seconds
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;
static constexpr int PROCESS_START_TIMEOUT_MS = 3000;
};
} // namespace QodeAssist::Tools

View File

@ -28,6 +28,7 @@
#include "BuildProjectTool.hpp"
#include "CreateNewFileTool.hpp"
#include "EditFileTool.hpp"
#include "ExecuteTerminalCommandTool.hpp"
#include "FindAndReadFileTool.hpp"
#include "GetIssuesListTool.hpp"
#include "ListProjectFilesTool.hpp"
@ -50,6 +51,7 @@ void ToolsFactory::registerTools()
registerTool(new CreateNewFileTool(this));
registerTool(new EditFileTool(this));
registerTool(new BuildProjectTool(this));
registerTool(new ExecuteTerminalCommandTool(this));
registerTool(new ProjectSearchTool(this));
registerTool(new FindAndReadFileTool(this));
@ -100,6 +102,11 @@ QJsonArray ToolsFactory::getToolsDefinitions(
continue;
}
if (it.value()->name() == "execute_terminal_command"
&& !settings.enableTerminalCommandTool()) {
continue;
}
const auto requiredPerms = it.value()->requiredPermissions();
if (filter != LLMCore::RunToolsFilter::ALL) {
@ -151,11 +158,11 @@ QJsonArray ToolsFactory::getToolsDefinitions(
}
}
// if (requiredPerms.testFlag(LLMCore::ToolPermission::NetworkAccess)) {
// if (!settings.allowNetworkAccess()) {
// hasPermission = false;
// }
// }
if (requiredPerms.testFlag(LLMCore::ToolPermission::NetworkAccess)) {
if (!settings.allowNetworkAccess()) {
hasPermission = false;
}
}
if (hasPermission) {
toolsArray.append(it.value()->getDefinition(format));

View File

@ -0,0 +1,125 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QString>
#include <QStringList>
#include <QTextBlock>
#include <QTextDocument>
#include <texteditor/texteditor.h>
#include <utils/textutils.h>
namespace QodeAssist {
class ContextExtractor
{
public:
static QString extractBefore(TextEditor::TextEditorWidget *editor,
const Utils::Text::Range &range,
int lineCount)
{
if (!editor || lineCount <= 0) {
return QString();
}
QTextDocument *doc = editor->document();
int startLine = range.begin.line;
int contextStartLine = qMax(1, startLine - lineCount);
QStringList contextLines;
for (int line = contextStartLine; line < startLine; ++line) {
QTextBlock block = doc->findBlockByNumber(line - 1);
if (block.isValid()) {
contextLines.append(block.text());
}
}
return contextLines.join('\n');
}
static QString extractAfter(TextEditor::TextEditorWidget *editor,
const Utils::Text::Range &range,
int lineCount)
{
if (!editor || lineCount <= 0) {
return QString();
}
QTextDocument *doc = editor->document();
int endLine = range.end.line;
int totalLines = doc->blockCount();
int contextEndLine = qMin(totalLines, endLine + lineCount);
QStringList contextLines;
for (int line = endLine + 1; line <= contextEndLine; ++line) {
QTextBlock block = doc->findBlockByNumber(line - 1);
if (block.isValid()) {
contextLines.append(block.text());
}
}
return contextLines.join('\n');
}
static QString extractLineContext(QTextDocument *doc, int position, bool before)
{
QTextCursor cursor(doc);
cursor.setPosition(position);
if (before) {
int posInBlock = cursor.positionInBlock();
cursor.movePosition(QTextCursor::StartOfBlock);
cursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, posInBlock);
return cursor.selectedText();
} else {
cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
return cursor.selectedText();
}
}
static QStringList extractSurroundingLines(QTextDocument *doc, int position, int linesBefore, int linesAfter)
{
QTextCursor cursor(doc);
cursor.setPosition(position);
QTextBlock currentBlock = cursor.block();
QStringList result;
QTextBlock blockBefore = currentBlock.previous();
QStringList beforeLines;
for (int i = 0; i < linesBefore && blockBefore.isValid(); ++i) {
beforeLines.prepend(blockBefore.text());
blockBefore = blockBefore.previous();
}
result.append(beforeLines);
QTextBlock blockAfter = currentBlock.next();
for (int i = 0; i < linesAfter && blockAfter.isValid(); ++i) {
result.append(blockAfter.text());
blockAfter = blockAfter.next();
}
return result;
}
};
} // namespace QodeAssist

View File

@ -0,0 +1,71 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QString>
#include <QCoreApplication>
#include <utils/differ.h>
namespace QodeAssist {
class DiffStatistics
{
Q_DECLARE_TR_FUNCTIONS(DiffStatistics)
public:
DiffStatistics() = default;
void calculate(const QList<Utils::Diff> &diffList)
{
m_linesAdded = 0;
m_linesRemoved = 0;
for (const auto &diff : diffList) {
if (diff.command == Utils::Diff::Insert) {
m_linesAdded += diff.text.count('\n') + (diff.text.isEmpty() ? 0 : 1);
} else if (diff.command == Utils::Diff::Delete) {
m_linesRemoved += diff.text.count('\n') + (diff.text.isEmpty() ? 0 : 1);
}
}
}
int linesAdded() const { return m_linesAdded; }
int linesRemoved() const { return m_linesRemoved; }
QString formatSummary() const
{
if (m_linesAdded > 0 && m_linesRemoved > 0) {
return tr("+%1 lines, -%2 lines").arg(m_linesAdded).arg(m_linesRemoved);
} else if (m_linesAdded > 0) {
return tr("+%1 lines").arg(m_linesAdded);
} else if (m_linesRemoved > 0) {
return tr("-%1 lines").arg(m_linesRemoved);
}
return tr("No changes");
}
private:
int m_linesAdded{0};
int m_linesRemoved{0};
};
} // namespace QodeAssist

View File

@ -155,6 +155,9 @@ void QuickRefactorDialog::setupUi()
connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
mainLayout->addWidget(buttonBox);
setTabOrder(m_commandsComboBox, m_textEdit);
setTabOrder(m_textEdit, buttonBox);
}
void QuickRefactorDialog::createActionButtons()

762
widgets/RefactorWidget.cpp Normal file
View File

@ -0,0 +1,762 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#include "RefactorWidget.hpp"
#include "DiffStatistics.hpp"
#include <texteditor/textdocument.h>
#include <texteditor/syntaxhighlighter.h>
#include <QCloseEvent>
#include <QEnterEvent>
#include <QEvent>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QLabel>
#include <QPainter>
#include <QRegion>
#include <QScreen>
#include <QScrollBar>
#include <QSharedPointer>
#include <QSplitter>
#include <QTextBlock>
#include <QVBoxLayout>
#include <coreplugin/icore.h>
#include <utils/differ.h>
#include <utils/theme/theme.h>
#include "settings/QuickRefactorSettings.hpp"
namespace QodeAssist {
CustomSplitterHandle::CustomSplitterHandle(Qt::Orientation orientation, QSplitter *parent)
: QSplitterHandle(orientation, parent)
{
if (orientation == Qt::Horizontal) {
setCursor(Qt::SplitHCursor);
} else {
setCursor(Qt::SplitVCursor);
}
setMouseTracking(true);
}
void CustomSplitterHandle::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
QColor bgColor = Utils::creatorColor(Utils::Theme::BackgroundColorHover);
bgColor.setAlpha(m_hovered ? 150 : 50);
painter.fillRect(rect(), bgColor);
QColor lineColor = Utils::creatorColor(Utils::Theme::SplitterColor);
lineColor.setAlpha(m_hovered ? 255 : 180);
const int lineWidth = m_hovered ? 3 : 2;
const int margin = 10;
painter.setPen(QPen(lineColor, lineWidth));
if (orientation() == Qt::Horizontal) {
int x = width() / 2;
painter.drawLine(x, margin, x, height() - margin);
painter.setBrush(lineColor);
int centerY = height() / 2;
const int dotSize = m_hovered ? 3 : 2;
const int dotSpacing = 8;
for (int i = -2; i <= 2; ++i) {
painter.drawEllipse(QPoint(x, centerY + i * dotSpacing), dotSize, dotSize);
}
} else {
int y = height() / 2;
painter.drawLine(margin, y, width() - margin, y);
}
}
void CustomSplitterHandle::enterEvent(QEnterEvent *event)
{
m_hovered = true;
update();
QSplitterHandle::enterEvent(event);
}
void CustomSplitterHandle::leaveEvent(QEvent *event)
{
m_hovered = false;
update();
QSplitterHandle::leaveEvent(event);
}
CustomSplitter::CustomSplitter(Qt::Orientation orientation, QWidget *parent)
: QSplitter(orientation, parent)
{
}
QSplitterHandle *CustomSplitter::createHandle()
{
return new CustomSplitterHandle(orientation(), this);
}
RefactorWidget::RefactorWidget(TextEditor::TextEditorWidget *sourceEditor, QWidget *parent)
: QWidget(parent)
, m_sourceEditor(sourceEditor)
, m_leftEditor(nullptr)
, m_rightEditor(nullptr)
, m_leftContainer(nullptr)
, m_splitter(nullptr)
, m_statsLabel(nullptr)
, m_applyButton(nullptr)
, m_declineButton(nullptr)
, m_editorWidth(800)
, m_syncingScroll(false)
, m_isClosing(false)
, m_linesAdded(0)
, m_linesRemoved(0)
{
setupUi();
applyEditorSettings();
setWindowFlags(Qt::Popup | Qt::FramelessWindowHint);
setAttribute(Qt::WA_DeleteOnClose);
setFocusPolicy(Qt::StrongFocus);
}
RefactorWidget::~RefactorWidget()
{
}
void RefactorWidget::setupUi()
{
auto *mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(6, 6, 6, 6);
mainLayout->setSpacing(4);
m_statsLabel = new QLabel(this);
m_statsLabel->setAlignment(Qt::AlignLeft);
mainLayout->addWidget(m_statsLabel, 0);
m_leftDocument = QSharedPointer<TextEditor::TextDocument>::create();
m_rightDocument = QSharedPointer<TextEditor::TextDocument>::create();
Qt::Orientation initialOrientation = Settings::quickRefactorSettings().widgetOrientation.value() == 1
? Qt::Vertical : Qt::Horizontal;
m_splitter = new CustomSplitter(initialOrientation, this);
m_splitter->setChildrenCollapsible(false);
m_splitter->setHandleWidth(12);
m_splitter->setStyleSheet("QSplitter::handle { background-color: transparent; }");
m_leftEditor = new TextEditor::TextEditorWidget();
m_leftEditor->setTextDocument(m_leftDocument);
m_leftEditor->setReadOnly(true);
m_leftEditor->setFrameStyle(QFrame::StyledPanel);
m_leftEditor->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_leftEditor->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
m_leftEditor->setMinimumWidth(150);
m_leftEditor->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
m_leftEditor->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard);
m_rightEditor = new TextEditor::TextEditorWidget();
m_rightEditor->setTextDocument(m_rightDocument);
m_rightEditor->setReadOnly(false);
m_rightEditor->setFrameStyle(QFrame::StyledPanel);
m_rightEditor->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
m_rightEditor->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
m_rightEditor->setMinimumWidth(150);
m_rightEditor->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
m_leftContainer = new QWidget();
m_leftContainer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
auto *leftLayout = new QVBoxLayout(m_leftContainer);
leftLayout->setSpacing(2);
leftLayout->setContentsMargins(0, 0, 0, 0);
auto *originalLabel = new QLabel(tr("◄ Original"), m_leftContainer);
leftLayout->addWidget(originalLabel, 0);
leftLayout->addWidget(m_leftEditor, 1);
auto *rightContainer = new QWidget();
rightContainer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
auto *rightLayout = new QVBoxLayout(rightContainer);
rightLayout->setSpacing(2);
rightLayout->setContentsMargins(0, 0, 0, 0);
auto *refactoredLabel = new QLabel(tr("Refactored ►"), rightContainer);
rightLayout->addWidget(refactoredLabel, 0);
rightLayout->addWidget(m_rightEditor, 1);
m_splitter->addWidget(m_leftContainer);
m_splitter->addWidget(rightContainer);
m_splitter->setStretchFactor(0, 1);
m_splitter->setStretchFactor(1, 1);
mainLayout->addWidget(m_splitter, 1);
connect(m_leftEditor->verticalScrollBar(), &QScrollBar::valueChanged,
this, &RefactorWidget::syncLeftScroll);
connect(m_rightEditor->verticalScrollBar(), &QScrollBar::valueChanged,
this, &RefactorWidget::syncRightScroll);
connect(m_leftEditor->horizontalScrollBar(), &QScrollBar::valueChanged,
this, &RefactorWidget::syncLeftHorizontalScroll);
connect(m_rightEditor->horizontalScrollBar(), &QScrollBar::valueChanged,
this, &RefactorWidget::syncRightHorizontalScroll);
connect(m_rightDocument->document(), &QTextDocument::contentsChanged,
this, &RefactorWidget::onRightEditorTextChanged);
auto *buttonLayout = new QHBoxLayout();
buttonLayout->setContentsMargins(0, 2, 0, 0);
buttonLayout->setSpacing(6);
#ifdef Q_OS_MACOS
m_applyButton = new QPushButton(tr("✓ Apply (⌘+Enter)"), this);
#else
m_applyButton = new QPushButton(tr("✓ Apply (Ctrl+Enter)"), this);
#endif
m_applyButton->setFocusPolicy(Qt::NoFocus);
m_applyButton->setCursor(Qt::PointingHandCursor);
m_applyButton->setMaximumHeight(24);
m_declineButton = new QPushButton(tr("✗ Decline (Esc)"), this);
m_declineButton->setFocusPolicy(Qt::NoFocus);
m_declineButton->setCursor(Qt::PointingHandCursor);
m_declineButton->setMaximumHeight(24);
buttonLayout->addStretch();
buttonLayout->addWidget(m_applyButton);
buttonLayout->addWidget(m_declineButton);
mainLayout->addLayout(buttonLayout, 0);
connect(m_applyButton, &QPushButton::clicked, this, &RefactorWidget::applyRefactoring);
connect(m_declineButton, &QPushButton::clicked, this, &RefactorWidget::declineRefactoring);
}
void RefactorWidget::setDiffContent(const QString &originalText, const QString &refactoredText)
{
setDiffContent(originalText, refactoredText, QString(), QString());
}
void RefactorWidget::setDiffContent(const QString &originalText, const QString &refactoredText,
const QString &contextBefore, const QString &contextAfter)
{
m_originalText = originalText;
m_refactoredText = refactoredText;
m_contextBefore = contextBefore;
m_contextAfter = contextAfter;
m_leftContainer->setVisible(true);
QString leftFullText;
QString rightFullText;
if (!contextBefore.isEmpty()) {
leftFullText = contextBefore + "\n";
rightFullText = contextBefore + "\n";
}
leftFullText += originalText;
rightFullText += refactoredText;
if (!contextAfter.isEmpty()) {
leftFullText += "\n" + contextAfter;
rightFullText += "\n" + contextAfter;
}
m_leftDocument->setPlainText(leftFullText);
m_rightDocument->setPlainText(rightFullText);
applySyntaxHighlighting();
if (!contextBefore.isEmpty() || !contextAfter.isEmpty()) {
dimContextLines(contextBefore, contextAfter);
}
Utils::Differ differ;
m_cachedDiffList = differ.diff(m_originalText, m_refactoredText);
highlightDifferences();
addLineMarkers();
calculateStats();
updateStatsLabel();
updateSizeToContent();
}
void RefactorWidget::highlightDifferences()
{
if (m_cachedDiffList.isEmpty()) {
return;
}
QList<Utils::Diff> leftDiffs;
QList<Utils::Diff> rightDiffs;
Utils::Differ::splitDiffList(m_cachedDiffList, &leftDiffs, &rightDiffs);
int contextBeforeOffset = m_contextBefore.isEmpty() ? 0 : (m_contextBefore.length() + 1);
QColor normalTextColor = Utils::creatorColor(Utils::Theme::TextColorNormal);
QTextCursor leftCursor(m_leftDocument->document());
QTextCharFormat removedFormat;
QColor removedBg = Utils::creatorColor(Utils::Theme::TextColorError);
removedBg.setAlpha(30);
removedFormat.setBackground(removedBg);
removedFormat.setForeground(normalTextColor);
int leftPos = 0;
for (const auto &diff : leftDiffs) {
if (diff.command == Utils::Diff::Delete) {
leftCursor.setPosition(contextBeforeOffset + leftPos);
leftCursor.setPosition(contextBeforeOffset + leftPos + diff.text.length(), QTextCursor::KeepAnchor);
leftCursor.setCharFormat(removedFormat);
}
if (diff.command != Utils::Diff::Insert) {
leftPos += diff.text.length();
}
}
QTextCursor rightCursor(m_rightDocument->document());
QTextCharFormat addedFormat;
QColor addedBg = Utils::creatorColor(Utils::Theme::IconsRunColor);
addedBg.setAlpha(60);
addedFormat.setBackground(addedBg);
addedFormat.setForeground(normalTextColor);
int rightPos = 0;
for (const auto &diff : rightDiffs) {
if (diff.command == Utils::Diff::Insert) {
rightCursor.setPosition(contextBeforeOffset + rightPos);
rightCursor.setPosition(contextBeforeOffset + rightPos + diff.text.length(), QTextCursor::KeepAnchor);
rightCursor.setCharFormat(addedFormat);
}
if (diff.command != Utils::Diff::Delete) {
rightPos += diff.text.length();
}
}
}
void RefactorWidget::dimContextLines(const QString &contextBefore, const QString &contextAfter)
{
QTextCharFormat dimFormat;
dimFormat.setForeground(Utils::creatorColor(Utils::Theme::TextColorDisabled));
auto dimLines = [&](QTextDocument *doc, int lineCount, bool fromStart) {
QTextCursor cursor(doc);
if (!fromStart) {
cursor.movePosition(QTextCursor::End);
}
for (int i = 0; i < lineCount; ++i) {
cursor.movePosition(QTextCursor::StartOfBlock);
cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
cursor.setCharFormat(dimFormat);
if (fromStart) {
if (!cursor.block().isValid() || !cursor.movePosition(QTextCursor::NextBlock)) {
break;
}
} else {
if (!cursor.movePosition(QTextCursor::PreviousBlock)) {
break;
}
}
}
};
if (!contextBefore.isEmpty()) {
int lines = contextBefore.count('\n') + (contextBefore.endsWith('\n') ? 0 : 1);
dimLines(m_leftDocument->document(), lines, true);
dimLines(m_rightDocument->document(), lines, true);
}
if (!contextAfter.isEmpty()) {
int lines = contextAfter.count('\n') + (contextAfter.endsWith('\n') ? 0 : 1);
dimLines(m_leftDocument->document(), lines, false);
dimLines(m_rightDocument->document(), lines, false);
}
}
QString RefactorWidget::getRefactoredText() const
{
return m_applyText;
}
void RefactorWidget::setRange(const Utils::Text::Range &range)
{
m_range = range;
}
void RefactorWidget::setEditorWidth(int width)
{
m_editorWidth = width;
updateSizeToContent();
}
void RefactorWidget::setApplyCallback(std::function<void(const QString &)> callback)
{
m_applyCallback = callback;
}
void RefactorWidget::setDeclineCallback(std::function<void()> callback)
{
m_declineCallback = callback;
}
void RefactorWidget::applyRefactoring()
{
if (m_isClosing) return;
m_isClosing = true;
if (m_applyCallback) {
m_applyCallback(m_applyText);
}
emit applied();
close();
}
void RefactorWidget::declineRefactoring()
{
if (m_isClosing) return;
m_isClosing = true;
if (m_declineCallback) {
m_declineCallback();
}
emit declined();
close();
}
void RefactorWidget::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
const QColor bgColor = Utils::creatorColor(Utils::Theme::BackgroundColorNormal);
const QColor borderColor = Utils::creatorColor(Utils::Theme::SplitterColor);
painter.fillRect(rect(), bgColor);
painter.setPen(QPen(borderColor, 2));
painter.drawRoundedRect(rect().adjusted(2, 2, -2, -2), 6, 6);
}
bool RefactorWidget::event(QEvent *event)
{
if (event->type() == QEvent::ShortcutOverride) {
auto *keyEvent = static_cast<QKeyEvent *>(event);
if (((keyEvent->key() == Qt::Key_Enter || keyEvent->key() == Qt::Key_Return) &&
keyEvent->modifiers() == Qt::ControlModifier) ||
keyEvent->key() == Qt::Key_Escape) {
event->accept();
return true;
}
}
if (event->type() == QEvent::KeyPress) {
auto *keyEvent = static_cast<QKeyEvent *>(event);
if ((keyEvent->key() == Qt::Key_Enter || keyEvent->key() == Qt::Key_Return) &&
keyEvent->modifiers() == Qt::ControlModifier) {
applyRefactoring();
return true;
}
if (keyEvent->key() == Qt::Key_Escape) {
declineRefactoring();
return true;
}
}
return QWidget::event(event);
}
void RefactorWidget::syncLeftScroll(int value)
{
if (m_syncingScroll) return;
m_syncingScroll = true;
m_rightEditor->verticalScrollBar()->setValue(value);
m_syncingScroll = false;
}
void RefactorWidget::syncRightScroll(int value)
{
if (m_syncingScroll) return;
m_syncingScroll = true;
m_leftEditor->verticalScrollBar()->setValue(value);
m_syncingScroll = false;
}
void RefactorWidget::syncLeftHorizontalScroll(int value)
{
if (m_syncingScroll) return;
m_syncingScroll = true;
m_rightEditor->horizontalScrollBar()->setValue(value);
m_syncingScroll = false;
}
void RefactorWidget::syncRightHorizontalScroll(int value)
{
if (m_syncingScroll) return;
m_syncingScroll = true;
m_leftEditor->horizontalScrollBar()->setValue(value);
m_syncingScroll = false;
}
void RefactorWidget::onRightEditorTextChanged()
{
QString fullText = m_rightDocument->plainText();
int startPos = m_contextBefore.isEmpty() ? 0 : m_contextBefore.length() + 1;
int endPos = m_contextAfter.isEmpty() ? fullText.length() : fullText.length() - m_contextAfter.length() - 1;
m_applyText = fullText.mid(startPos, endPos - startPos);
}
void RefactorWidget::closeEvent(QCloseEvent *event)
{
if (!m_isClosing) {
declineRefactoring();
}
event->accept();
}
void RefactorWidget::calculateStats()
{
DiffStatistics stats;
stats.calculate(m_cachedDiffList);
m_linesAdded = stats.linesAdded();
m_linesRemoved = stats.linesRemoved();
}
void RefactorWidget::updateStatsLabel()
{
DiffStatistics stats;
stats.calculate(m_cachedDiffList);
m_statsLabel->setText("📊 " + stats.formatSummary());
}
void RefactorWidget::applySyntaxHighlighting()
{
if (!m_sourceEditor) {
return;
}
auto *sourceDoc = m_sourceEditor->textDocument();
if (!sourceDoc || !sourceDoc->syntaxHighlighter()) {
return;
}
m_leftDocument->setMimeType(sourceDoc->mimeType());
m_rightDocument->setMimeType(sourceDoc->mimeType());
}
void RefactorWidget::addLineMarkers()
{
if (m_cachedDiffList.isEmpty()) {
return;
}
QList<Utils::Diff> leftDiffs;
QList<Utils::Diff> rightDiffs;
Utils::Differ::splitDiffList(m_cachedDiffList, &leftDiffs, &rightDiffs);
int contextBeforeOffset = m_contextBefore.isEmpty() ? 0 : (m_contextBefore.length() + 1);
QColor removedMarker = Utils::creatorColor(Utils::Theme::TextColorError);
QColor addedMarker = Utils::creatorColor(Utils::Theme::IconsRunColor);
QTextCursor leftCursor(m_leftDocument->document());
int leftPos = 0;
for (const auto &diff : leftDiffs) {
if (diff.command == Utils::Diff::Delete) {
leftCursor.setPosition(contextBeforeOffset + leftPos);
leftCursor.movePosition(QTextCursor::StartOfBlock);
leftCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
QTextBlockFormat blockFormat;
blockFormat.setBackground(QBrush(removedMarker.lighter(185)));
blockFormat.setLeftMargin(4);
blockFormat.setProperty(QTextFormat::FullWidthSelection, true);
leftCursor.setBlockFormat(blockFormat);
}
if (diff.command != Utils::Diff::Insert) {
leftPos += diff.text.length();
}
}
QTextCursor rightCursor(m_rightDocument->document());
int rightPos = 0;
for (const auto &diff : rightDiffs) {
if (diff.command == Utils::Diff::Insert) {
rightCursor.setPosition(contextBeforeOffset + rightPos);
rightCursor.movePosition(QTextCursor::StartOfBlock);
rightCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
QTextBlockFormat blockFormat;
blockFormat.setBackground(QBrush(addedMarker.lighter(195)));
blockFormat.setLeftMargin(4);
blockFormat.setProperty(QTextFormat::FullWidthSelection, true);
rightCursor.setBlockFormat(blockFormat);
}
if (diff.command != Utils::Diff::Delete) {
rightPos += diff.text.length();
}
}
}
void RefactorWidget::updateSizeToContent()
{
QFontMetrics fm(m_rightEditor->font());
int lineHeight = fm.height();
int leftMaxWidth = 0;
int leftLineCount = 0;
QTextBlock leftBlock = m_leftDocument->document()->begin();
while (leftBlock.isValid()) {
int lineWidth = fm.horizontalAdvance(leftBlock.text());
leftMaxWidth = qMax(leftMaxWidth, lineWidth);
leftLineCount++;
leftBlock = leftBlock.next();
}
int rightMaxWidth = 0;
int rightLineCount = 0;
QTextBlock rightBlock = m_rightDocument->document()->begin();
while (rightBlock.isValid()) {
int lineWidth = fm.horizontalAdvance(rightBlock.text());
rightMaxWidth = qMax(rightMaxWidth, lineWidth);
rightLineCount++;
rightBlock = rightBlock.next();
}
int maxLineWidth = qMax(leftMaxWidth, rightMaxWidth);
int maxLineCount = qMax(leftLineCount, rightLineCount);
const int editorPadding = 60;
const int scrollBarWidth = 20;
const int statsLabelHeight = 30;
const int buttonLayoutHeight = 35;
const int layoutMargins = 20;
const int labelHeight = 20;
int singleEditorWidth = maxLineWidth + editorPadding + scrollBarWidth;
int contentHeight = maxLineCount * lineHeight + statsLabelHeight + buttonLayoutHeight + layoutMargins + labelHeight;
bool horizontal = m_splitter->orientation() == Qt::Horizontal;
int contentWidth;
if (horizontal) {
contentWidth = singleEditorWidth * 2 + m_splitter->handleWidth() + 20;
} else {
contentWidth = singleEditorWidth + 20;
}
QScreen *screen = window()->screen();
if (!screen) {
screen = QGuiApplication::primaryScreen();
}
int screenWidth = screen ? screen->availableGeometry().width() : 1920;
int screenHeight = screen ? screen->availableGeometry().height() : 1080;
const int minWidth = Settings::quickRefactorSettings().widgetMinWidth();
const int maxWidth = qMin(Settings::quickRefactorSettings().widgetMaxWidth(),
qMin(m_editorWidth - 40, screenWidth - 100));
const int minHeight = Settings::quickRefactorSettings().widgetMinHeight();
const int maxHeight = qMin(Settings::quickRefactorSettings().widgetMaxHeight(), screenHeight - 100);
int targetWidth = qBound(minWidth, contentWidth, maxWidth);
int targetHeight = qBound(minHeight, contentHeight, maxHeight);
setFixedSize(targetWidth, targetHeight);
updateGeometry();
}
void RefactorWidget::applyEditorSettings()
{
if (!m_sourceEditor || !m_leftEditor || !m_rightEditor) {
return;
}
QFont editorFont = m_sourceEditor->font();
m_leftEditor->setFont(editorFont);
m_rightEditor->setFont(editorFont);
QString labelStyle = QString("color: %1; padding: 2px 4px;")
.arg(Utils::creatorColor(Utils::Theme::TextColorDisabled).name());
for (auto *label : findChildren<QLabel *>()) {
if (label != m_statsLabel) {
QFont labelFont = label->font();
labelFont.setPointSize(qMax(8, editorFont.pointSize() - 2));
label->setFont(labelFont);
label->setStyleSheet(labelStyle);
}
}
QFont statsFont = m_statsLabel->font();
statsFont.setBold(true);
statsFont.setPointSize(qMax(9, editorFont.pointSize() - 1));
m_statsLabel->setFont(statsFont);
m_statsLabel->setStyleSheet(QString(
"color: %1; padding: 4px 6px; background-color: %2; border-radius: 3px;")
.arg(Utils::creatorColor(Utils::Theme::TextColorNormal).name())
.arg(Utils::creatorColor(Utils::Theme::BackgroundColorHover).name()));
updateButtonStyles();
}
void RefactorWidget::updateButtonStyles()
{
if (!m_applyButton || !m_declineButton) {
return;
}
int baseFontSize = m_sourceEditor ? qMax(9, m_sourceEditor->font().pointSize() - 2) : 10;
auto createStyle = [&](const QColor &color, bool bold) {
return QString(
"QPushButton {"
" background-color: %1; color: %2; border: 1px solid %3;"
" border-radius: 3px; padding: 2px 8px; font-size: %4pt;%5"
"}"
"QPushButton:hover { background-color: %6; border: 1px solid %2; }"
"QPushButton:pressed { background-color: %7; }")
.arg(Utils::creatorColor(Utils::Theme::BackgroundColorNormal).name())
.arg(color.name())
.arg(Utils::creatorColor(Utils::Theme::SplitterColor).name())
.arg(baseFontSize)
.arg(bold ? QLatin1StringView(" font-weight: bold;") : QLatin1StringView(""))
.arg(Utils::creatorColor(Utils::Theme::BackgroundColorHover).name())
.arg(Utils::creatorColor(Utils::Theme::BackgroundColorSelected).name());
};
m_applyButton->setStyleSheet(createStyle(Utils::creatorColor(Utils::Theme::TextColorNormal), true));
m_declineButton->setStyleSheet(createStyle(Utils::creatorColor(Utils::Theme::TextColorError), false));
}
} // namespace QodeAssist

141
widgets/RefactorWidget.hpp Normal file
View File

@ -0,0 +1,141 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QWidget>
#include <QPushButton>
#include <QLabel>
#include <QVBoxLayout>
#include <QKeyEvent>
#include <QEnterEvent>
#include <QSharedPointer>
#include <QSplitter>
#include <QSplitterHandle>
#include <functional>
#include <texteditor/texteditor.h>
#include <utils/textutils.h>
#include <utils/differ.h>
namespace QodeAssist {
class CustomSplitterHandle : public QSplitterHandle
{
Q_OBJECT
public:
explicit CustomSplitterHandle(Qt::Orientation orientation, QSplitter *parent);
protected:
void paintEvent(QPaintEvent *event) override;
void enterEvent(QEnterEvent *event) override;
void leaveEvent(QEvent *event) override;
private:
bool m_hovered = false;
};
class CustomSplitter : public QSplitter
{
Q_OBJECT
public:
explicit CustomSplitter(Qt::Orientation orientation, QWidget *parent = nullptr);
protected:
QSplitterHandle *createHandle() override;
};
class RefactorWidget : public QWidget
{
Q_OBJECT
public:
explicit RefactorWidget(TextEditor::TextEditorWidget *sourceEditor, QWidget *parent = nullptr);
~RefactorWidget() override;
void setDiffContent(const QString &originalText, const QString &refactoredText);
void setDiffContent(const QString &originalText, const QString &refactoredText,
const QString &contextBefore, const QString &contextAfter);
void setApplyText(const QString &text) { m_applyText = text; }
void setRange(const Utils::Text::Range &range);
void setEditorWidth(int width);
QString getRefactoredText() const;
void setApplyCallback(std::function<void(const QString &)> callback);
void setDeclineCallback(std::function<void()> callback);
signals:
void applied();
void declined();
protected:
void paintEvent(QPaintEvent *event) override;
bool event(QEvent *event) override;
void closeEvent(QCloseEvent *event) override;
private slots:
void syncLeftScroll(int value);
void syncRightScroll(int value);
void syncLeftHorizontalScroll(int value);
void syncRightHorizontalScroll(int value);
void onRightEditorTextChanged();
private:
TextEditor::TextEditorWidget *m_sourceEditor;
TextEditor::TextEditorWidget *m_leftEditor;
TextEditor::TextEditorWidget *m_rightEditor;
QSharedPointer<TextEditor::TextDocument> m_leftDocument;
QSharedPointer<TextEditor::TextDocument> m_rightDocument;
QWidget *m_leftContainer;
QSplitter *m_splitter;
QLabel *m_statsLabel;
QPushButton *m_applyButton;
QPushButton *m_declineButton;
QString m_originalText;
QString m_refactoredText;
QString m_applyText;
QString m_contextBefore;
QString m_contextAfter;
Utils::Text::Range m_range;
int m_editorWidth;
bool m_syncingScroll;
bool m_isClosing;
int m_linesAdded;
int m_linesRemoved;
QList<Utils::Diff> m_cachedDiffList;
std::function<void(const QString &)> m_applyCallback;
std::function<void()> m_declineCallback;
void setupUi();
void applyRefactoring();
void declineRefactoring();
void updateSizeToContent();
void highlightDifferences();
void dimContextLines(const QString &contextBefore, const QString &contextAfter);
void calculateStats();
void updateStatsLabel();
void applySyntaxHighlighting();
void addLineMarkers();
void applyEditorSettings();
void updateButtonStyles();
};
} // namespace QodeAssist

View File

@ -0,0 +1,163 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#include "RefactorWidgetHandler.hpp"
#include "RefactorWidget.hpp"
#include "ContextExtractor.hpp"
#include <QScrollBar>
#include <QTextBlock>
namespace QodeAssist {
RefactorWidgetHandler::RefactorWidgetHandler(QObject *parent)
: QObject(parent)
{
}
RefactorWidgetHandler::~RefactorWidgetHandler()
{
hideRefactorWidget();
}
void RefactorWidgetHandler::showRefactorWidget(
TextEditor::TextEditorWidget *editor,
const QString &originalText,
const QString &refactoredText,
const Utils::Text::Range &range)
{
QString contextBefore = ContextExtractor::extractBefore(editor, range, 3);
QString contextAfter = ContextExtractor::extractAfter(editor, range, 3);
showRefactorWidget(editor, originalText, refactoredText, range, contextBefore, contextAfter);
}
void RefactorWidgetHandler::showRefactorWidget(
TextEditor::TextEditorWidget *editor,
const QString &originalText,
const QString &refactoredText,
const Utils::Text::Range &range,
const QString &contextBefore,
const QString &contextAfter)
{
if (!editor) {
return;
}
hideRefactorWidget();
m_editor = editor;
m_refactorWidget = new RefactorWidget(editor);
m_refactorWidget->setDiffContent(originalText, refactoredText, contextBefore, contextAfter);
m_refactorWidget->setApplyText(refactoredText);
m_refactorWidget->setRange(range);
m_refactorWidget->setEditorWidth(getEditorWidth());
if (m_applyCallback) {
m_refactorWidget->setApplyCallback(m_applyCallback);
}
if (m_declineCallback) {
m_refactorWidget->setDeclineCallback(m_declineCallback);
}
updateWidgetPosition();
m_refactorWidget->show();
m_refactorWidget->raise();
}
void RefactorWidgetHandler::hideRefactorWidget()
{
if (!m_refactorWidget.isNull()) {
m_refactorWidget->close();
m_refactorWidget = nullptr;
}
m_editor = nullptr;
}
void RefactorWidgetHandler::setApplyCallback(std::function<void(const QString &)> callback)
{
m_applyCallback = callback;
}
void RefactorWidgetHandler::setDeclineCallback(std::function<void()> callback)
{
m_declineCallback = callback;
}
void RefactorWidgetHandler::setTextToApply(const QString &text)
{
if (!m_refactorWidget.isNull()) {
m_refactorWidget->setApplyText(text);
}
}
void RefactorWidgetHandler::updateWidgetPosition()
{
if (m_refactorWidget.isNull() || m_editor.isNull()) {
return;
}
QPoint position = calculateWidgetPosition();
m_refactorWidget->move(position);
}
QPoint RefactorWidgetHandler::calculateWidgetPosition()
{
if (m_editor.isNull()) {
return QPoint(0, 0);
}
QTextCursor cursor = m_editor->textCursor();
QRect cursorRect = m_editor->cursorRect(cursor);
QPoint globalPos = m_editor->mapToGlobal(cursorRect.bottomLeft());
globalPos.setY(globalPos.y() + 10);
if (m_refactorWidget) {
QRect widgetRect(globalPos, m_refactorWidget->size());
QRect screenRect = m_editor->screen()->availableGeometry();
if (widgetRect.right() > screenRect.right()) {
globalPos.setX(screenRect.right() - m_refactorWidget->width() - 10);
}
if (widgetRect.bottom() > screenRect.bottom()) {
globalPos.setY(m_editor->mapToGlobal(cursorRect.topLeft()).y()
- m_refactorWidget->height() - 10);
}
if (globalPos.x() < screenRect.left()) {
globalPos.setX(screenRect.left() + 10);
}
if (globalPos.y() < screenRect.top()) {
globalPos.setY(screenRect.top() + 10);
}
}
return globalPos;
}
int RefactorWidgetHandler::getEditorWidth() const
{
return m_editor.isNull() ? 800 : m_editor->viewport()->width();
}
} // namespace QodeAssist

View File

@ -0,0 +1,75 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QPointer>
#include <functional>
#include <texteditor/texteditor.h>
#include <utils/textutils.h>
namespace QodeAssist {
class RefactorWidget;
class RefactorWidgetHandler : public QObject
{
Q_OBJECT
public:
explicit RefactorWidgetHandler(QObject *parent = nullptr);
~RefactorWidgetHandler() override;
void showRefactorWidget(
TextEditor::TextEditorWidget *editor,
const QString &originalText,
const QString &refactoredText,
const Utils::Text::Range &range);
void showRefactorWidget(
TextEditor::TextEditorWidget *editor,
const QString &originalText,
const QString &refactoredText,
const Utils::Text::Range &range,
const QString &contextBefore,
const QString &contextAfter);
void hideRefactorWidget();
bool isWidgetVisible() const { return !m_refactorWidget.isNull(); }
void setApplyCallback(std::function<void(const QString &)> callback);
void setDeclineCallback(std::function<void()> callback);
void setTextToApply(const QString &text);
private:
QPointer<TextEditor::TextEditorWidget> m_editor;
QPointer<RefactorWidget> m_refactorWidget;
std::function<void(const QString &)> m_applyCallback;
std::function<void()> m_declineCallback;
void updateWidgetPosition();
QPoint calculateWidgetPosition();
int getEditorWidth() const;
};
} // namespace QodeAssist