Compare commits

...

4 Commits

Author SHA1 Message Date
Petr Mironychev
ca3baa7597 chore: Update plugin to 0.9.16 2026-05-21 14:59:52 +02:00
Petr Mironychev
b33a1c2d43 fix: Add handling final argument for OpenAI responses tool calling 2026-05-21 14:19:16 +02:00
Petr Mironychev
c4e34bb3d9 chore: Update plugin to 0.9.15 version 2026-05-21 10:49:34 +02:00
Petr Mironychev
b9e0b5a00c fix: Change getting focus to chat in editor and name of title 2026-05-21 10:48:11 +02:00
21 changed files with 297 additions and 91 deletions

View File

@@ -44,9 +44,11 @@ qt_add_qml_module(QodeAssistChatView
icons/window-unlock.svg icons/window-unlock.svg
icons/chat-icon.svg icons/chat-icon.svg
icons/chat-pause-icon.svg icons/chat-pause-icon.svg
icons/new-chat-icon.svg
icons/rules-icon.svg icons/rules-icon.svg
icons/context-icon.svg icons/context-icon.svg
icons/open-in-editor.svg icons/open-in-editor.svg
icons/open-in-window.svg
icons/apply-changes-button.svg icons/apply-changes-button.svg
icons/undo-changes-button.svg icons/undo-changes-button.svg
icons/reject-changes-button.svg icons/reject-changes-button.svg

View File

@@ -112,6 +112,18 @@ ChatRootView::ChatRootView(QQuickItem *parent)
setRecentFilePath(QString{}); setRecentFilePath(QString{});
m_fileEditController->clearCurrentRequestId(); m_fileEditController->clearCurrentRequestId();
}); });
auto maybeEmitTitle = [this] {
const QString newTitle = computeChatTitle();
if (newTitle == m_cachedChatTitle)
return;
m_cachedChatTitle = newTitle;
emit chatTitleChanged();
};
connect(m_chatModel, &ChatModel::modelReseted, this, maybeEmitTitle);
connect(m_chatModel, &QAbstractItemModel::modelReset, this, maybeEmitTitle);
connect(m_chatModel, &QAbstractItemModel::rowsInserted, this, maybeEmitTitle);
connect(m_chatModel, &QAbstractItemModel::rowsRemoved, this, maybeEmitTitle);
connect(m_chatModel, &QAbstractItemModel::dataChanged, this, maybeEmitTitle);
connect(this, &ChatRootView::attachmentFilesChanged, this, [this]() { connect(this, &ChatRootView::attachmentFilesChanged, this, [this]() {
m_tokenCounter->setAttachments(m_attachmentFiles); m_tokenCounter->setAttachments(m_attachmentFiles);
}); });
@@ -792,6 +804,51 @@ void ChatRootView::triggerOpenChatCommand(Utils::Id commandId)
} }
} }
bool ChatRootView::isInEditor() const
{
return m_isInEditor;
}
void ChatRootView::setInEditor(bool value)
{
if (m_isInEditor == value)
return;
m_isInEditor = value;
emit isInEditorChanged();
}
void ChatRootView::requestNewChat()
{
triggerOpenChatCommand(Constants::QODE_ASSIST_NEW_CHAT_ACTION);
}
QString ChatRootView::chatTitle() const
{
if (m_cachedChatTitle.isEmpty())
m_cachedChatTitle = computeChatTitle();
return m_cachedChatTitle;
}
QString ChatRootView::computeChatTitle() const
{
if (!m_chatModel)
return {};
const auto history = m_chatModel->getChatHistory();
for (const auto &msg : history) {
if (msg.role != ChatModel::User)
continue;
const QString content = msg.content.trimmed();
if (content.isEmpty())
continue;
const QString firstLine = content.section(QChar('\n'), 0, 0).trimmed();
constexpr int maxLen = 60;
if (firstLine.length() > maxLen)
return firstLine.left(maxLen - 1) + QChar(0x2026);
return firstLine;
}
return {};
}
void ChatRootView::handOffSession() void ChatRootView::handOffSession()
{ {
if (m_chatModel->rowCount() > 0) { if (m_chatModel->rowCount() > 0) {
@@ -824,7 +881,7 @@ void ChatRootView::consumePendingChatFile()
void ChatRootView::relocateToSplit() void ChatRootView::relocateToSplit()
{ {
handOffSession(); handOffSession();
triggerOpenChatCommand(Constants::QODE_ASSIST_SHOW_CHAT_ACTION); triggerOpenChatCommand(Constants::QODE_ASSIST_NEW_CHAT_ACTION);
clearMessages(); clearMessages();
clearAttachmentFiles(); clearAttachmentFiles();
emit closeHostRequested(); emit closeHostRequested();

View File

@@ -63,6 +63,8 @@ class ChatRootView : public QQuickItem
Q_PROPERTY(QString currentAgentRoleDescription READ currentAgentRoleDescription NOTIFY currentAgentRoleChanged FINAL) Q_PROPERTY(QString currentAgentRoleDescription READ currentAgentRoleDescription NOTIFY currentAgentRoleChanged FINAL)
Q_PROPERTY(QString currentAgentRoleSystemPrompt READ currentAgentRoleSystemPrompt NOTIFY currentAgentRoleChanged FINAL) Q_PROPERTY(QString currentAgentRoleSystemPrompt READ currentAgentRoleSystemPrompt NOTIFY currentAgentRoleChanged FINAL)
Q_PROPERTY(bool isCompressing READ isCompressing NOTIFY isCompressingChanged FINAL) Q_PROPERTY(bool isCompressing READ isCompressing NOTIFY isCompressingChanged FINAL)
Q_PROPERTY(bool isInEditor READ isInEditor NOTIFY isInEditorChanged FINAL)
Q_PROPERTY(QString chatTitle READ chatTitle NOTIFY chatTitleChanged FINAL)
QML_ELEMENT QML_ELEMENT
@@ -183,6 +185,13 @@ public:
bool isCompressing() const; bool isCompressing() const;
bool isInEditor() const;
void setInEditor(bool value);
QString chatTitle() const;
Q_INVOKABLE void requestNewChat();
public slots: public slots:
void sendMessage(const QString &message); void sendMessage(const QString &message);
void copyToClipboard(const QString &text); void copyToClipboard(const QString &text);
@@ -228,11 +237,15 @@ signals:
void compressionCompleted(const QString &compressedChatPath); void compressionCompleted(const QString &compressedChatPath);
void compressionFailed(const QString &error); void compressionFailed(const QString &error);
void isInEditorChanged();
void chatTitleChanged();
void openFilesChanged(); void openFilesChanged();
void closeHostRequested(); void closeHostRequested();
private: private:
QString computeChatTitle() const;
void triggerOpenChatCommand(Utils::Id commandId); void triggerOpenChatCommand(Utils::Id commandId);
void handOffSession(); void handOffSession();
bool deferSendForAutoCompress( bool deferSendForAutoCompress(
@@ -271,6 +284,8 @@ private:
}; };
PendingSend m_pendingSend; PendingSend m_pendingSend;
bool m_isSyncOpenFiles; bool m_isSyncOpenFiles;
bool m_isInEditor = false;
mutable QString m_cachedChatTitle;
QList<Core::IEditor *> m_currentEditors; QList<Core::IEditor *> m_currentEditors;
bool m_isRequestInProgress; bool m_isRequestInProgress;
QString m_lastErrorMessage; QString m_lastErrorMessage;

View File

@@ -21,6 +21,7 @@ ChatWidget::ChatWidget(
QQmlEngine *engine, QQmlEngine *engine,
SessionFileRegistry *sessionFileRegistry, SessionFileRegistry *sessionFileRegistry,
Skills::SkillsManager *skillsManager, Skills::SkillsManager *skillsManager,
bool registerOwnContext,
QWidget *parent) QWidget *parent)
: QQuickWidget{engine, parent} : QQuickWidget{engine, parent}
{ {
@@ -37,11 +38,20 @@ ChatWidget::ChatWidget(
setResizeMode(QQuickWidget::SizeRootObjectToView); setResizeMode(QQuickWidget::SizeRootObjectToView);
setFocusPolicy(Qt::StrongFocus); setFocusPolicy(Qt::StrongFocus);
if (registerOwnContext) {
auto ideContext = new Core::IContext{this}; auto ideContext = new Core::IContext{this};
ideContext->setWidget(this); ideContext->setWidget(this);
ideContext->setContext(Core::Context{Constants::QODE_ASSIST_CHAT_CONTEXT}); ideContext->setContext(Core::Context{Constants::QODE_ASSIST_CHAT_CONTEXT});
Core::ICore::addContextObject(ideContext); Core::ICore::addContextObject(ideContext);
} }
}
void ChatWidget::focusInEvent(QFocusEvent *event)
{
QQuickWidget::focusInEvent(event);
if (rootObject())
QMetaObject::invokeMethod(rootObject(), "focusInput");
}
void ChatWidget::clear() void ChatWidget::clear()
{ {

View File

@@ -22,6 +22,7 @@ public:
QQmlEngine *engine, QQmlEngine *engine,
SessionFileRegistry *sessionFileRegistry, SessionFileRegistry *sessionFileRegistry,
Skills::SkillsManager *skillsManager, Skills::SkillsManager *skillsManager,
bool registerOwnContext = true,
QWidget *parent = nullptr); QWidget *parent = nullptr);
~ChatWidget() = default; ~ChatWidget() = default;
@@ -38,6 +39,9 @@ public:
signals: signals:
void clearPressed(); void clearPressed();
protected:
void focusInEvent(QFocusEvent *event) override;
}; };
} // namespace QodeAssist::Chat } // namespace QodeAssist::Chat

View File

@@ -174,13 +174,20 @@ void ClientInterface::sendMessage(
auto project = PluginLLMCore::RulesLoader::getActiveProject(); auto project = PluginLLMCore::RulesLoader::getActiveProject();
if (project) { if (project) {
systemPrompt += QString("\n# Active project name: %1").arg(project->displayName()); systemPrompt += QString("\n# Active project: %1").arg(project->displayName());
systemPrompt += QString("\n# Active Project path: %1") systemPrompt += QString(
"\n# Project source root: %1"
"\n# All new source files, headers, QML and CMake edits MUST be "
"created or modified under this directory. Use absolute paths "
"rooted here, or project-relative paths.")
.arg(project->projectDirectory().toUrlishString()); .arg(project->projectDirectory().toUrlishString());
if (auto target = project->activeTarget()) { if (auto target = project->activeTarget()) {
if (auto buildConfig = target->activeBuildConfiguration()) { if (auto buildConfig = target->activeBuildConfiguration()) {
systemPrompt += QString("\n# Active Build directory: %1") systemPrompt
+= QString(
"\n# Build output directory (compiler artifacts only — do NOT "
"create or edit source files here): %1")
.arg(buildConfig->buildDirectory().toUrlishString()); .arg(buildConfig->buildDirectory().toUrlishString());
} }
} }

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.6 0H2.4C1.08 0 0 1.08 0 2.4V16.8C0 18.12 1.08 19.2 2.4 19.2H7.2V22.8C7.2 23.46 7.74 24 8.4 24H9C9.3 24 9.6 23.88 9.84 23.652L14.28 19.2H21.6C22.92 19.2 24 18.12 24 16.8V2.4C24 1.08 22.92 0 21.6 0ZM21.6 16.8H13.44L8.76 21.48L8.4 21.6V16.8H2.4V2.4H21.6V16.8Z" fill="black"/>
<rect x="11" y="5" width="2" height="9" rx="0.5" fill="black"/>
<rect x="7.5" y="8.5" width="9" height="2" rx="0.5" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 526 B

View File

@@ -1,17 +1,6 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="44" height="44" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_74_52)"> <g transform="translate(10 8) skewX(-15)" stroke="black" stroke-width="2" stroke-linejoin="round">
<mask id="mask0_74_52" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="44" height="44"> <rect x="10" y="0" width="22" height="15" rx="3" ry="3" fill="black"/>
<path d="M44 0H0V44H44V0Z" fill="white"/> <rect x="0" y="12" width="22" height="15" rx="3" ry="3" fill="none"/>
</mask>
<g mask="url(#mask0_74_52)">
<path d="M18 31C25.1797 31 31 25.1797 31 18C31 10.8203 25.1797 5 18 5C10.8203 5 5 10.8203 5 18C5 25.1797 10.8203 31 18 31Z" stroke="black" stroke-width="3.5"/>
<path d="M27 27L38 38" stroke="black" stroke-width="3.5" stroke-linecap="round"/>
<path d="M16.375 23L18.2841 11.3636H20.1023L18.1932 23H16.375ZM11.1648 20.1136L11.4659 18.2955H20.5568L20.2557 20.1136H11.1648ZM12.2841 23L14.1932 11.3636H16.0114L14.1023 23H12.2841ZM11.8295 16.0682L12.1364 14.25H21.2273L20.9205 16.0682H11.8295Z" fill="black"/>
</g> </g>
</g>
<defs>
<clipPath id="clip0_74_52">
<rect width="44" height="44" fill="white"/>
</clipPath>
</defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 348 B

View File

@@ -0,0 +1,6 @@
<svg width="44" height="44" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(10 8) skewX(-15)" stroke="black" stroke-width="2" stroke-linejoin="round">
<rect x="10" y="0" width="22" height="15" rx="3" ry="3" fill="none"/>
<rect x="0" y="12" width="22" height="15" rx="3" ry="3" fill="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 348 B

View File

@@ -87,21 +87,25 @@ ChatRootView {
Layout.preferredWidth: parent.width Layout.preferredWidth: parent.width
Layout.preferredHeight: childrenRect.height + 10 Layout.preferredHeight: childrenRect.height + 10
isInEditor: root.isInEditor
saveButton.onClicked: root.showSaveDialog() saveButton.onClicked: root.showSaveDialog()
loadButton.onClicked: root.showLoadDialog() loadButton.onClicked: root.showLoadDialog()
clearButton.onClicked: root.clearChat() clearButton.onClicked: root.clearChat()
newChatButton.onClicked: root.requestNewChat()
tokensBadge { tokensBadge {
readonly property int sessionCached: root.chatModel.sessionCachedPromptTokens readonly property int sessionPrompt: root.chatModel.sessionPromptTokens || 0
readonly property int sessionCompletion: root.chatModel.sessionCompletionTokens || 0
readonly property int sessionCached: root.chatModel.sessionCachedPromptTokens || 0
text: sessionCached > 0 text: sessionCached > 0
? qsTr("next ~%1 · session ↑%2 ↓%3 ↻%4") ? qsTr("next ~%1 · session ↑%2 ↓%3 ↻%4")
.arg(root.inputTokensCount) .arg(root.inputTokensCount)
.arg(root.chatModel.sessionPromptTokens) .arg(sessionPrompt)
.arg(root.chatModel.sessionCompletionTokens) .arg(sessionCompletion)
.arg(sessionCached) .arg(sessionCached)
: qsTr("next ~%1 · session ↑%2 ↓%3") : qsTr("next ~%1 · session ↑%2 ↓%3")
.arg(root.inputTokensCount) .arg(root.inputTokensCount)
.arg(root.chatModel.sessionPromptTokens) .arg(sessionPrompt)
.arg(root.chatModel.sessionCompletionTokens) .arg(sessionCompletion)
ToolTip.text: sessionCached > 0 ToolTip.text: sessionCached > 0
? qsTr("next request (estimate) · session prompt ↑ / completion ↓ / cached ↻ (provider cache hits)") ? qsTr("next request (estimate) · session prompt ↑ / completion ↓ / cached ↻ (provider cache hits)")
: qsTr("next request (estimate) · session prompt ↑ / completion ↓") : qsTr("next request (estimate) · session prompt ↑ / completion ↓")
@@ -117,8 +121,11 @@ ChatRootView {
onCheckedChanged: _chatview.isPin = topBar.pinButton.checked onCheckedChanged: _chatview.isPin = topBar.pinButton.checked
} }
relocateButton { relocateButton {
icon.source: (typeof _chatview !== 'undefined')
? "qrc:/qt/qml/ChatView/icons/open-in-editor.svg"
: "qrc:/qt/qml/ChatView/icons/open-in-window.svg"
ToolTip.text: (typeof _chatview !== 'undefined') ToolTip.text: (typeof _chatview !== 'undefined')
? qsTr("Move this chat to an editor split") ? qsTr("Move this chat to an editor tab")
: qsTr("Move this chat to a separate window") : qsTr("Move this chat to a separate window")
onClicked: { onClicked: {
if (typeof _chatview !== 'undefined') if (typeof _chatview !== 'undefined')

View File

@@ -10,9 +10,12 @@ import UIControls
Rectangle { Rectangle {
id: root id: root
property bool isInEditor: false
property alias saveButton: saveButtonId property alias saveButton: saveButtonId
property alias loadButton: loadButtonId property alias loadButton: loadButtonId
property alias clearButton: clearButtonId property alias clearButton: clearButtonId
property alias newChatButton: newChatButtonId
property alias tokensBadge: tokensBadgeId property alias tokensBadge: tokensBadgeId
property alias recentPath: recentPathId property alias recentPath: recentPathId
property alias openChatHistory: openChatHistoryId property alias openChatHistory: openChatHistoryId
@@ -77,6 +80,43 @@ Rectangle {
ToolTip.delay: 250 ToolTip.delay: 250
} }
QoASeparator {
anchors.verticalCenter: parent.verticalCenter
}
QoAButton {
id: clearButtonId
icon {
source: "qrc:/qt/qml/ChatView/icons/clean-icon-dark.svg"
height: 15
width: 8
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Clean chat")
}
QoASeparator {
anchors.verticalCenter: parent.verticalCenter
}
QoAButton {
id: newChatButtonId
visible: root.isInEditor
icon {
source: "qrc:/qt/qml/ChatView/icons/new-chat-icon.svg"
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
height: 15
width: 15
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Open new chat in a new tab")
}
QoAComboBox { QoAComboBox {
id: configSelectorId id: configSelectorId
@@ -276,21 +316,6 @@ Rectangle {
ToolTip.delay: 250 ToolTip.delay: 250
ToolTip.text: qsTr("Current amount tokens in chat and LLM limit threshold") ToolTip.text: qsTr("Current amount tokens in chat and LLM limit threshold")
} }
QoASeparator {}
QoAButton {
id: clearButtonId
icon {
source: "qrc:/qt/qml/ChatView/icons/clean-icon-dark.svg"
height: 15
width: 8
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Clean chat")
}
} }
} }
} }

View File

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

View File

@@ -16,6 +16,7 @@ const char QODE_ASSIST_CHAT_EDITOR_ID[] = "QodeAssist.ChatEditor";
const char QODE_ASSIST_SHOW_CHAT_ACTION[] = "QodeAssist.ShowChatView"; const char QODE_ASSIST_SHOW_CHAT_ACTION[] = "QodeAssist.ShowChatView";
const char QODE_ASSIST_OPEN_CHAT_WINDOW_ACTION[] = "QodeAssist.OpenChatWindow"; const char QODE_ASSIST_OPEN_CHAT_WINDOW_ACTION[] = "QodeAssist.OpenChatWindow";
const char QODE_ASSIST_NEW_CHAT_ACTION[] = "QodeAssist.NewChat";
const char QODE_ASSIST_CHAT_SEND_MESSAGE[] = "QodeAssist.Chat.SendMessage"; const char QODE_ASSIST_CHAT_SEND_MESSAGE[] = "QodeAssist.Chat.SendMessage";
const char QODE_ASSIST_CHAT_CLEAR_SESSION[] = "QodeAssist.Chat.ClearSession"; const char QODE_ASSIST_CHAT_CLEAR_SESSION[] = "QodeAssist.Chat.ClearSession";

View File

@@ -3,17 +3,13 @@
#include "ChatEditor.hpp" #include "ChatEditor.hpp"
#include <coreplugin/actionmanager/actionmanager.h>
#include <coreplugin/actionmanager/command.h>
#include <coreplugin/coreconstants.h>
#include <coreplugin/editormanager/editormanager.h> #include <coreplugin/editormanager/editormanager.h>
#include <QAction>
#include "ChatDocument.hpp" #include "ChatDocument.hpp"
#include "ChatView/ChatRootView.hpp" #include "ChatView/ChatRootView.hpp"
#include "ChatView/ChatWidget.hpp" #include "ChatView/ChatWidget.hpp"
#include "QodeAssistConstants.hpp" #include "QodeAssistConstants.hpp"
#include "QodeAssisttr.h"
namespace QodeAssist::Chat { namespace QodeAssist::Chat {
@@ -25,26 +21,28 @@ ChatEditor::ChatEditor(
, m_sessionFileRegistry(sessionFileRegistry) , m_sessionFileRegistry(sessionFileRegistry)
, m_skillsManager(skillsManager) , m_skillsManager(skillsManager)
, m_document(new ChatDocument(this)) , m_document(new ChatDocument(this))
, m_chatWidget(new ChatWidget(engine, sessionFileRegistry, skillsManager)) , m_chatWidget(new ChatWidget(engine, sessionFileRegistry, skillsManager, false))
{ {
setWidget(m_chatWidget); setWidget(m_chatWidget);
setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT)); setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT));
setDuplicateSupported(true); setDuplicateSupported(false);
if (auto rootView = qobject_cast<ChatRootView *>(m_chatWidget->rootObject())) { if (auto rootView = qobject_cast<ChatRootView *>(m_chatWidget->rootObject())) {
rootView->setInEditor(true);
connect( connect(
rootView, rootView,
&ChatRootView::closeHostRequested, &ChatRootView::closeHostRequested,
this, this,
[this] { [this] { Core::EditorManager::closeEditors({this}); },
Core::EditorManager::closeEditors({this});
if (auto command
= Core::ActionManager::command(Core::Constants::REMOVE_CURRENT_SPLIT)) {
if (auto action = command->action(); action && action->isEnabled())
action->trigger();
}
},
Qt::QueuedConnection); Qt::QueuedConnection);
auto syncTitle = [this, rootView] {
const QString title = rootView->chatTitle();
m_document->setPreferredDisplayName(
title.isEmpty() ? Tr::tr("QodeAssist Chat") : QStringLiteral("QodeAssist - ") + title);
};
connect(rootView, &ChatRootView::chatTitleChanged, this, syncTitle);
syncTitle();
} }
} }
@@ -71,7 +69,7 @@ QWidget *ChatEditor::toolBar()
Core::IEditor *ChatEditor::duplicate() Core::IEditor *ChatEditor::duplicate()
{ {
return new ChatEditor(m_engine, m_sessionFileRegistry, m_skillsManager); return nullptr;
} }
} // namespace QodeAssist::Chat } // namespace QodeAssist::Chat

View File

@@ -17,8 +17,6 @@ class ChatDocument;
class ChatWidget; class ChatWidget;
class SessionFileRegistry; class SessionFileRegistry;
// Editor-area host for the chat. Each editor (including a split duplicate) owns its own
// ChatWidget and therefore its own independent chat session.
class ChatEditor : public Core::IEditor class ChatEditor : public Core::IEditor
{ {
Q_OBJECT Q_OBJECT

View File

@@ -10,6 +10,8 @@
#include <coreplugin/actionmanager/actionmanager.h> #include <coreplugin/actionmanager/actionmanager.h>
#include <coreplugin/actionmanager/command.h> #include <coreplugin/actionmanager/command.h>
#include <coreplugin/coreconstants.h> #include <coreplugin/coreconstants.h>
#include <coreplugin/editormanager/documentmodel.h>
#include <coreplugin/editormanager/editormanager.h>
#include <coreplugin/icontext.h> #include <coreplugin/icontext.h>
#include <coreplugin/icore.h> #include <coreplugin/icore.h>
#include <coreplugin/messagemanager.h> #include <coreplugin/messagemanager.h>
@@ -228,10 +230,10 @@ public:
ActionBuilder showChatViewAction(this, Constants::QODE_ASSIST_SHOW_CHAT_ACTION); ActionBuilder showChatViewAction(this, Constants::QODE_ASSIST_SHOW_CHAT_ACTION);
const QKeySequence showChatViewShortcut = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_W); const QKeySequence showChatViewShortcut = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_W);
showChatViewAction.setDefaultKeySequence(showChatViewShortcut); showChatViewAction.setDefaultKeySequence(showChatViewShortcut);
showChatViewAction.setToolTip(Tr::tr("Open QodeAssist Chat in an editor split")); showChatViewAction.setToolTip(Tr::tr("Open QodeAssist Chat as an editor tab"));
showChatViewAction.setText(Tr::tr("Show QodeAssist Chat")); showChatViewAction.setText(Tr::tr("Show QodeAssist Chat"));
showChatViewAction.setIcon(QCODEASSIST_CHAT_ICON.icon()); showChatViewAction.setIcon(QCODEASSIST_CHAT_ICON.icon());
showChatViewAction.addOnTriggered(this, [this] { openChatInSplit(); }); showChatViewAction.addOnTriggered(this, [this] { openChatInEditor(); });
m_statusWidget->setChatButtonAction(showChatViewAction.contextAction()); m_statusWidget->setChatButtonAction(showChatViewAction.contextAction());
m_chatButtonMenu = new QMenu(m_statusWidget); m_chatButtonMenu = new QMenu(m_statusWidget);
@@ -260,6 +262,12 @@ public:
openChatWindowAction.setIcon(QCODEASSIST_CHAT_ICON.icon()); openChatWindowAction.setIcon(QCODEASSIST_CHAT_ICON.icon());
openChatWindowAction.addOnTriggered(this, [this] { openChatInWindow(); }); openChatWindowAction.addOnTriggered(this, [this] { openChatInWindow(); });
ActionBuilder newChatAction(this, Constants::QODE_ASSIST_NEW_CHAT_ACTION);
newChatAction.setText(Tr::tr("New QodeAssist Chat"));
newChatAction.setToolTip(Tr::tr("Open a fresh chat in a new editor tab"));
newChatAction.setIcon(QCODEASSIST_CHAT_ICON.icon());
newChatAction.addOnTriggered(this, [this] { openNewChatInEditor(); });
ActionBuilder sendMessageAction(this, Constants::QODE_ASSIST_CHAT_SEND_MESSAGE); ActionBuilder sendMessageAction(this, Constants::QODE_ASSIST_CHAT_SEND_MESSAGE);
sendMessageAction.setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT)); sendMessageAction.setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT));
sendMessageAction.setText(Tr::tr("Send QodeAssist Chat Message")); sendMessageAction.setText(Tr::tr("Send QodeAssist Chat Message"));
@@ -323,13 +331,14 @@ public:
} }
private: private:
void openChatInSplit() void openChatInEditor()
{ {
if (auto splitCommand if (auto existing = findExistingChatEditor()) {
= Core::ActionManager::command(Core::Constants::SPLIT_SIDE_BY_SIDE)) { Core::EditorManager::activateEditor(existing);
if (auto splitAction = splitCommand->action()) existing->consumePendingChatFile();
splitAction->trigger(); return;
} }
QString title = Tr::tr("QodeAssist Chat"); QString title = Tr::tr("QodeAssist Chat");
Core::IEditor *editor = Core::EditorManager::openEditorWithContents( Core::IEditor *editor = Core::EditorManager::openEditorWithContents(
Constants::QODE_ASSIST_CHAT_EDITOR_ID, &title, {}, QUuid::createUuid().toString()); Constants::QODE_ASSIST_CHAT_EDITOR_ID, &title, {}, QUuid::createUuid().toString());
@@ -337,6 +346,34 @@ private:
chatEditor->consumePendingChatFile(); chatEditor->consumePendingChatFile();
} }
void openNewChatInEditor()
{
QString title = Tr::tr("QodeAssist Chat");
Core::IEditor *editor = Core::EditorManager::openEditorWithContents(
Constants::QODE_ASSIST_CHAT_EDITOR_ID, &title, {}, QUuid::createUuid().toString());
// For the "New Chat" button pending is empty (no-op). For relocate-to-editor it
// carries the handed-off chat file and gets loaded into the freshly opened tab.
if (auto chatEditor = qobject_cast<Chat::ChatEditor *>(editor))
chatEditor->consumePendingChatFile();
}
Chat::ChatEditor *findExistingChatEditor() const
{
const auto entries = Core::DocumentModel::entries();
for (auto *entry : entries) {
if (!entry || !entry->document)
continue;
if (entry->document->id() != Constants::QODE_ASSIST_CHAT_EDITOR_ID)
continue;
const auto editors = Core::DocumentModel::editorsForDocument(entry->document);
for (auto *editor : editors) {
if (auto chatEditor = qobject_cast<Chat::ChatEditor *>(editor))
return chatEditor;
}
}
return nullptr;
}
void openChatInWindow() void openChatInWindow()
{ {
if (!m_chatView) if (!m_chatView)
@@ -400,11 +437,11 @@ private:
m_chatButtonMenu->addSeparator(); m_chatButtonMenu->addSeparator();
if (m_chatView && m_chatView->isVisible()) { if (m_chatView && m_chatView->isVisible()) {
QAction *splitAction = m_chatButtonMenu->addAction(Tr::tr("Open Chat in Split")); QAction *editorAction = m_chatButtonMenu->addAction(Tr::tr("Open Chat in Editor"));
connect(splitAction, &QAction::triggered, this, [this] { connect(editorAction, &QAction::triggered, this, [this] {
if (m_chatView) if (m_chatView)
m_chatView->close(); m_chatView->close();
openChatInSplit(); openChatInEditor();
}); });
} else { } else {
QAction *windowAction QAction *windowAction

View File

@@ -133,7 +133,9 @@ ToolsSettings::ToolsSettings()
Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on Linux. " Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on Linux. "
"Example: git, ls, cat, grep, find, cmake")); "Example: git, ls, cat, grep, find, cmake"));
allowedTerminalCommandsLinux.setDisplayStyle(Utils::StringAspect::LineEditDisplay); allowedTerminalCommandsLinux.setDisplayStyle(Utils::StringAspect::LineEditDisplay);
allowedTerminalCommandsLinux.setDefaultValue("git, ls, cat, grep, find"); allowedTerminalCommandsLinux.setDefaultValue(
"git, ls, cat, grep, find, pwd, echo, head, tail, wc, which, file, stat, tree, uname, "
"date, whoami, hostname");
allowedTerminalCommandsMacOS.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_MACOS); allowedTerminalCommandsMacOS.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_MACOS);
allowedTerminalCommandsMacOS.setLabelText(Tr::tr("Allowed Commands (macOS)")); allowedTerminalCommandsMacOS.setLabelText(Tr::tr("Allowed Commands (macOS)"));
@@ -141,7 +143,9 @@ ToolsSettings::ToolsSettings()
Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on macOS. " Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on macOS. "
"Example: git, ls, cat, grep, find, cmake")); "Example: git, ls, cat, grep, find, cmake"));
allowedTerminalCommandsMacOS.setDisplayStyle(Utils::StringAspect::LineEditDisplay); allowedTerminalCommandsMacOS.setDisplayStyle(Utils::StringAspect::LineEditDisplay);
allowedTerminalCommandsMacOS.setDefaultValue("git, ls, cat, grep, find"); allowedTerminalCommandsMacOS.setDefaultValue(
"git, ls, cat, grep, find, pwd, echo, head, tail, wc, which, file, stat, tree, uname, "
"date, whoami, hostname");
allowedTerminalCommandsWindows.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_WINDOWS); allowedTerminalCommandsWindows.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_WINDOWS);
allowedTerminalCommandsWindows.setLabelText(Tr::tr("Allowed Commands (Windows)")); allowedTerminalCommandsWindows.setLabelText(Tr::tr("Allowed Commands (Windows)"));
@@ -149,7 +153,8 @@ ToolsSettings::ToolsSettings()
Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on Windows. " Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on Windows. "
"Example: git, dir, type, findstr, where, cmake")); "Example: git, dir, type, findstr, where, cmake"));
allowedTerminalCommandsWindows.setDisplayStyle(Utils::StringAspect::LineEditDisplay); allowedTerminalCommandsWindows.setDisplayStyle(Utils::StringAspect::LineEditDisplay);
allowedTerminalCommandsWindows.setDefaultValue("git, dir, type, findstr, where"); allowedTerminalCommandsWindows.setDefaultValue(
"git, dir, type, findstr, where, echo, whoami, hostname, ver, tree, fc");
terminalCommandTimeout.setSettingsKey(Constants::CA_TERMINAL_COMMAND_TIMEOUT); terminalCommandTimeout.setSettingsKey(Constants::CA_TERMINAL_COMMAND_TIMEOUT);
terminalCommandTimeout.setLabelText(Tr::tr("Command Timeout (seconds)")); terminalCommandTimeout.setLabelText(Tr::tr("Command Timeout (seconds)"));

View File

@@ -77,10 +77,20 @@ QFuture<LLMQore::ToolResult> CreateNewFileTool::executeAsync(const QJsonObject &
if (!isInProject) { if (!isInProject) {
const auto &settings = Settings::toolsSettings(); const auto &settings = Settings::toolsSettings();
if (!settings.allowAccessOutsideProject()) { if (!settings.allowAccessOutsideProject()) {
const QString projectRoot = Context::ProjectUtils::getProjectRoot();
const QString hint = projectRoot.isEmpty()
? QStringLiteral(
"No project is currently open. Open a project in Qt Creator or "
"enable 'Allow file access outside project' in QodeAssist settings.")
: QString(
"Retry with a path under the active project root: '%1'. The build "
"directory is for compiler output only and cannot accept new source "
"files. If you really need to write outside the project, enable "
"'Allow file access outside project' in QodeAssist settings.")
.arg(projectRoot);
throw LLMQore::ToolRuntimeError( throw LLMQore::ToolRuntimeError(
QString("Error: File path '%1' is not within the current project. " QString("Error: File path '%1' is not within the current project. %2")
"Enable 'Allow file access outside project' in settings to create files outside project scope.") .arg(absolutePath, hint));
.arg(absolutePath));
} }
LOG_MESSAGE(QString("Creating file outside project scope: %1").arg(absolutePath)); LOG_MESSAGE(QString("Creating file outside project scope: %1").arg(absolutePath));
} }

View File

@@ -143,10 +143,20 @@ QFuture<LLMQore::ToolResult> EditFileTool::executeAsync(const QJsonObject &input
if (!isInProject) { if (!isInProject) {
const auto &settings = Settings::toolsSettings(); const auto &settings = Settings::toolsSettings();
if (!settings.allowAccessOutsideProject()) { if (!settings.allowAccessOutsideProject()) {
const QString projectRoot = Context::ProjectUtils::getProjectRoot();
const QString hint = projectRoot.isEmpty()
? QStringLiteral(
"No project is currently open. Open a project in Qt Creator or "
"enable 'Allow file access outside project' in QodeAssist settings.")
: QString(
"Retry with a path under the active project root: '%1'. The build "
"directory is for compiler output only — source files must live under "
"the project root. If you really need to edit outside the project, "
"enable 'Allow file access outside project' in QodeAssist settings.")
.arg(projectRoot);
throw LLMQore::ToolRuntimeError( throw LLMQore::ToolRuntimeError(
QString("File path '%1' is not within the current project. " QString("File path '%1' is not within the current project. %2")
"Enable 'Allow file access outside project' in settings to edit files outside the project.") .arg(filePath, hint));
.arg(filePath));
} }
LOG_MESSAGE(QString("Editing file outside project scope: %1").arg(filePath)); LOG_MESSAGE(QString("Editing file outside project scope: %1").arg(filePath));
} }

View File

@@ -13,6 +13,7 @@
#include <QProcess> #include <QProcess>
#include <QPromise> #include <QPromise>
#include <QRegularExpression> #include <QRegularExpression>
#include <QRegularExpression>
#include <QSharedPointer> #include <QSharedPointer>
#include <QTimer> #include <QTimer>
@@ -45,18 +46,26 @@ QJsonObject ExecuteTerminalCommandTool::parametersSchema() const
QJsonObject definition; QJsonObject definition;
definition["type"] = "object"; definition["type"] = "object";
const QString commandDesc = getCommandDescription(); const QStringList allowed = getAllowedCommands();
const QString allowedList = allowed.isEmpty() ? "none" : allowed.join(", ");
QJsonObject properties; QJsonObject properties;
properties["command"] = QJsonObject{ properties["command"] = QJsonObject{
{"type", "string"}, {"type", "string"},
{"description", commandDesc}}; {"description",
QString("Name of the executable to run, WITHOUT any arguments or flags. "
"Must be exactly one of the allowed commands: %1. "
"Put every flag and argument in the separate `args` field. "
"Correct: command=\"ls\", args=\"-R\". "
"Incorrect: command=\"ls -R\" (the whole line in one field will be rejected).")
.arg(allowedList)}};
properties["args"] = QJsonObject{ properties["args"] = QJsonObject{
{"type", "string"}, {"type", "string"},
{"description", {"description",
"Optional arguments for the command. Arguments with spaces should be properly quoted. " "Optional arguments and flags for the command, as a single string. Do NOT repeat the "
"Example: '--file \"path with spaces.txt\" --verbose'"}}; "command name here. Arguments with spaces should be quoted. "
"Example: args=\"--file \\\"path with spaces.txt\\\" --verbose\"."}};
definition["properties"] = properties; definition["properties"] = properties;
definition["required"] = QJsonArray{"command"}; definition["required"] = QJsonArray{"command"};
@@ -68,14 +77,25 @@ QFuture<LLMQore::ToolResult> ExecuteTerminalCommandTool::executeAsync(const QJso
{ {
using LLMQore::ToolResult; using LLMQore::ToolResult;
const QString command = input.value("command").toString().trimmed(); QString command = input.value("command").toString().trimmed();
const QString args = input.value("args").toString().trimmed(); QString args = input.value("args").toString().trimmed();
if (command.isEmpty()) { if (command.isEmpty()) {
LOG_MESSAGE("ExecuteTerminalCommandTool: Command is empty"); LOG_MESSAGE("ExecuteTerminalCommandTool: Command is empty");
return QtFuture::makeReadyFuture(ToolResult::error("Error: Command parameter is required.")); return QtFuture::makeReadyFuture(ToolResult::error("Error: Command parameter is required."));
} }
// Tolerate models that pack the whole command line into `command`. As long as `args` is
// empty we can safely split on the first whitespace — the allowlist check still validates
// the actual executable name.
if (args.isEmpty()) {
const int firstSpace = command.indexOf(QRegularExpression("\\s"));
if (firstSpace > 0) {
args = command.mid(firstSpace + 1).trimmed();
command = command.left(firstSpace);
}
}
if (command.length() > MAX_COMMAND_LENGTH) { if (command.length() > MAX_COMMAND_LENGTH) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command too long (%1 chars)") LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command too long (%1 chars)")
.arg(command.length())); .arg(command.length()));