mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-06-12 17:29:13 -04:00
feat: Add skills feature for tool and chat calling (#351)
This commit is contained in:
@@ -35,6 +35,7 @@ add_definitions(
|
||||
)
|
||||
|
||||
add_subdirectory(sources/external/llmqore)
|
||||
add_subdirectory(sources/skills)
|
||||
add_subdirectory(pluginllmcore)
|
||||
add_subdirectory(settings)
|
||||
add_subdirectory(logger)
|
||||
@@ -64,6 +65,7 @@ add_qtc_plugin(QodeAssist
|
||||
QtCreator::CPlusPlus
|
||||
LLMQore
|
||||
PluginLLMCore
|
||||
Skills
|
||||
QodeAssistChatViewplugin
|
||||
SOURCES
|
||||
.github/workflows/build_cmake.yml
|
||||
@@ -151,6 +153,7 @@ add_qtc_plugin(QodeAssist
|
||||
tools/FileSearchUtils.hpp tools/FileSearchUtils.cpp
|
||||
tools/TodoTool.hpp tools/TodoTool.cpp
|
||||
tools/ReadOriginalHistoryTool.hpp tools/ReadOriginalHistoryTool.cpp
|
||||
tools/SkillTool.hpp tools/SkillTool.cpp
|
||||
mcp/McpServerManager.hpp mcp/McpServerManager.cpp
|
||||
mcp/McpServerConnection.hpp mcp/McpServerConnection.cpp
|
||||
mcp/McpClientsManager.hpp mcp/McpClientsManager.cpp
|
||||
|
||||
@@ -23,6 +23,7 @@ qt_add_qml_module(QodeAssistChatView
|
||||
qml/controls/FileMentionPopup.qml
|
||||
qml/controls/FileEditsActionBar.qml
|
||||
qml/controls/ContextViewer.qml
|
||||
qml/controls/SkillCommandPopup.qml
|
||||
qml/controls/Toast.qml
|
||||
qml/controls/TopBar.qml
|
||||
qml/controls/SplitDropZone.qml
|
||||
@@ -92,6 +93,7 @@ target_link_libraries(QodeAssistChatView
|
||||
QodeAssistUIControlsplugin
|
||||
QodeAssistLogger
|
||||
LLMQore
|
||||
Skills
|
||||
)
|
||||
|
||||
target_include_directories(QodeAssistChatView
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
#include "SessionFileRegistry.hpp"
|
||||
#include "context/ContextManager.hpp"
|
||||
#include "pluginllmcore/RulesLoader.hpp"
|
||||
#include "ProjectSettings.hpp"
|
||||
#include "SkillsSettings.hpp"
|
||||
#include "sources/skills/SkillsManager.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
@@ -313,6 +316,52 @@ SessionFileRegistry *ChatRootView::sessionFileRegistry() const
|
||||
return m_sessionFileRegistry;
|
||||
}
|
||||
|
||||
Skills::SkillsManager *ChatRootView::skillsManager() const
|
||||
{
|
||||
if (!m_skillsManagerResolved) {
|
||||
m_skillsManagerResolved = true;
|
||||
if (auto context = qmlContext(this)) {
|
||||
m_skillsManager = qobject_cast<Skills::SkillsManager *>(
|
||||
context->contextProperty("skillsManager").value<QObject *>());
|
||||
}
|
||||
}
|
||||
return m_skillsManager;
|
||||
}
|
||||
|
||||
QVariantList ChatRootView::searchSkills(const QString &query) const
|
||||
{
|
||||
QVariantList results;
|
||||
auto *manager = skillsManager();
|
||||
if (!manager || !Settings::skillsSettings().enableSkills())
|
||||
return results;
|
||||
|
||||
auto *project = PluginLLMCore::RulesLoader::getActiveProject();
|
||||
QStringList projectSkillDirs;
|
||||
if (project) {
|
||||
Settings::ProjectSettings projectSettings(project);
|
||||
projectSkillDirs = Settings::SkillsSettings::splitLines(
|
||||
projectSettings.projectSkillDirs());
|
||||
}
|
||||
manager->configure(
|
||||
project ? project->projectDirectory().toFSPathString() : QString(),
|
||||
Settings::SkillsSettings::splitPaths(Settings::skillsSettings().globalSkillRoots()),
|
||||
projectSkillDirs);
|
||||
|
||||
const QString needle = query.trimmed().toLower();
|
||||
for (const Skills::AgentSkill &skill : manager->skills()) {
|
||||
if (!skill.enabled)
|
||||
continue;
|
||||
if (!needle.isEmpty() && !skill.name.toLower().contains(needle)
|
||||
&& !skill.description.toLower().contains(needle)) {
|
||||
continue;
|
||||
}
|
||||
results.append(QVariantMap{
|
||||
{QStringLiteral("name"), skill.name},
|
||||
{QStringLiteral("description"), skill.description}});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
ChatModel *ChatRootView::chatModel() const
|
||||
{
|
||||
return m_chatModel;
|
||||
@@ -387,6 +436,7 @@ void ChatRootView::dispatchSend(
|
||||
|
||||
m_tokenCounter->recordSent();
|
||||
|
||||
m_clientInterface->setSkillsManager(skillsManager());
|
||||
m_clientInterface->sendMessage(message, attachments, linkedFiles, useToolsArg, useThinkingArg);
|
||||
|
||||
m_fileManager->clearIntermediateStorage();
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
#include "pluginllmcore/PromptProviderChat.hpp"
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatCompressor;
|
||||
@@ -135,6 +139,8 @@ public:
|
||||
Q_INVOKABLE QString getRuleContent(int index);
|
||||
Q_INVOKABLE void refreshRules();
|
||||
|
||||
Q_INVOKABLE QVariantList searchSkills(const QString &query) const;
|
||||
|
||||
bool useTools() const;
|
||||
void setUseTools(bool enabled);
|
||||
bool useThinking() const;
|
||||
@@ -244,6 +250,7 @@ private:
|
||||
bool hasImageAttachments(const QStringList &attachments) const;
|
||||
|
||||
SessionFileRegistry *sessionFileRegistry() const;
|
||||
Skills::SkillsManager *skillsManager() const;
|
||||
|
||||
ChatModel *m_chatModel;
|
||||
PluginLLMCore::PromptProviderChat m_promptProvider;
|
||||
@@ -279,6 +286,8 @@ private:
|
||||
ChatHistoryStore *m_historyStore;
|
||||
mutable QPointer<SessionFileRegistry> m_sessionFileRegistry;
|
||||
mutable bool m_sessionFileRegistryResolved = false;
|
||||
mutable QPointer<Skills::SkillsManager> m_skillsManager;
|
||||
mutable bool m_skillsManagerResolved = false;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "ChatRootView.hpp"
|
||||
#include "QodeAssistConstants.hpp"
|
||||
#include "SessionFileRegistry.hpp"
|
||||
#include "sources/skills/SkillsManager.hpp"
|
||||
|
||||
namespace {
|
||||
constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::WindowSystemMenuHint
|
||||
@@ -26,7 +27,10 @@ constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::Win
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatView::ChatView(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry)
|
||||
ChatView::ChatView(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager)
|
||||
: QQuickView{engine, nullptr}
|
||||
, m_isPin(false)
|
||||
{
|
||||
@@ -36,6 +40,7 @@ ChatView::ChatView(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry)
|
||||
auto context = new QQmlContext{engine, this};
|
||||
context->setContextProperty("_chatview", this);
|
||||
context->setContextProperty("sessionFileRegistry", sessionFileRegistry);
|
||||
context->setContextProperty("skillsManager", skillsManager);
|
||||
|
||||
auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this};
|
||||
auto rootItem = component->create(context);
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
#include <QQuickView>
|
||||
#include <QShortcut>
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class SessionFileRegistry;
|
||||
@@ -19,7 +23,10 @@ class ChatView : public QQuickView
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(bool isPin READ isPin WRITE setIsPin NOTIFY isPinChanged FINAL)
|
||||
public:
|
||||
ChatView(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry);
|
||||
ChatView(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager);
|
||||
|
||||
bool isPin() const;
|
||||
void setIsPin(bool newIsPin);
|
||||
|
||||
@@ -13,16 +13,22 @@
|
||||
|
||||
#include "QodeAssistConstants.hpp"
|
||||
#include "SessionFileRegistry.hpp"
|
||||
#include "sources/skills/SkillsManager.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatWidget::ChatWidget(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry, QWidget *parent)
|
||||
ChatWidget::ChatWidget(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager,
|
||||
QWidget *parent)
|
||||
: QQuickWidget{engine, parent}
|
||||
{
|
||||
/// @note setup quick view content
|
||||
{
|
||||
auto context = new QQmlContext{engine, this};
|
||||
context->setContextProperty("sessionFileRegistry", sessionFileRegistry);
|
||||
context->setContextProperty("skillsManager", skillsManager);
|
||||
auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this};
|
||||
auto rootItem = component->create(context);
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
|
||||
#include <QtQuickWidgets/QtQuickWidgets>
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class SessionFileRegistry;
|
||||
@@ -17,6 +21,7 @@ public:
|
||||
explicit ChatWidget(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager,
|
||||
QWidget *parent = nullptr);
|
||||
~ChatWidget() = default;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QMimeDatabase>
|
||||
#include <QRegularExpression>
|
||||
#include <QUuid>
|
||||
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
@@ -35,21 +36,29 @@
|
||||
#include "ChatSerializer.hpp"
|
||||
#include "GeneralSettings.hpp"
|
||||
#include "Logger.hpp"
|
||||
#include "ProjectSettings.hpp"
|
||||
#include "ProvidersManager.hpp"
|
||||
#include "SkillsSettings.hpp"
|
||||
#include "ToolsSettings.hpp"
|
||||
#include <RulesLoader.hpp>
|
||||
#include <context/ChangesManager.h>
|
||||
#include <sources/skills/SkillsManager.hpp>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ClientInterface::ClientInterface(
|
||||
ChatModel *chatModel, PluginLLMCore::IPromptProvider *promptProvider, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_chatModel(chatModel)
|
||||
, m_promptProvider(promptProvider)
|
||||
, m_chatModel(chatModel)
|
||||
, m_contextManager(new Context::ContextManager(this))
|
||||
{}
|
||||
|
||||
void ClientInterface::setSkillsManager(Skills::SkillsManager *skillsManager)
|
||||
{
|
||||
m_skillsManager = skillsManager;
|
||||
}
|
||||
|
||||
ClientInterface::~ClientInterface()
|
||||
{
|
||||
cancelRequest();
|
||||
@@ -186,6 +195,44 @@ void ClientInterface::sendMessage(
|
||||
systemPrompt += QString("\n# No active project in IDE");
|
||||
}
|
||||
|
||||
if (m_skillsManager && Settings::skillsSettings().enableSkills()) {
|
||||
QStringList projectSkillDirs;
|
||||
if (project) {
|
||||
Settings::ProjectSettings projectSettings(project);
|
||||
projectSkillDirs = Settings::SkillsSettings::splitLines(
|
||||
projectSettings.projectSkillDirs());
|
||||
}
|
||||
m_skillsManager->configure(
|
||||
project ? project->projectDirectory().toFSPathString() : QString(),
|
||||
Settings::SkillsSettings::splitPaths(
|
||||
Settings::skillsSettings().globalSkillRoots()),
|
||||
projectSkillDirs);
|
||||
|
||||
const QString alwaysOnSkills = m_skillsManager->alwaysOnBodies();
|
||||
if (!alwaysOnSkills.isEmpty())
|
||||
systemPrompt += QString("\n\n") + alwaysOnSkills;
|
||||
|
||||
const QString skillsCatalog = m_skillsManager->catalogText();
|
||||
if (!skillsCatalog.isEmpty())
|
||||
systemPrompt += QString("\n\n") + skillsCatalog;
|
||||
|
||||
static const QRegularExpression skillCommand(
|
||||
QStringLiteral("(?:^|\\s)/([a-z0-9][a-z0-9-]*)"));
|
||||
QStringList invokedSkillNames;
|
||||
auto skillMatch = skillCommand.globalMatch(message);
|
||||
while (skillMatch.hasNext()) {
|
||||
const QString skillName = skillMatch.next().captured(1);
|
||||
if (invokedSkillNames.contains(skillName))
|
||||
continue;
|
||||
const auto invokedSkill = m_skillsManager->findByName(skillName);
|
||||
if (invokedSkill && !invokedSkill->body.isEmpty()) {
|
||||
invokedSkillNames << skillName;
|
||||
systemPrompt += QString("\n\n# Invoked Skill: %1\n\n%2")
|
||||
.arg(invokedSkill->name, invokedSkill->body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!linkedFiles.isEmpty()) {
|
||||
systemPrompt = getSystemPromptWithLinkedFiles(systemPrompt, linkedFiles);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
#include <LLMQore/BaseClient.hpp>
|
||||
#include <context/ContextManager.hpp>
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ClientInterface : public QObject
|
||||
@@ -25,6 +29,8 @@ public:
|
||||
ChatModel *chatModel, PluginLLMCore::IPromptProvider *promptProvider, QObject *parent = nullptr);
|
||||
~ClientInterface();
|
||||
|
||||
void setSkillsManager(Skills::SkillsManager *skillsManager);
|
||||
|
||||
void sendMessage(
|
||||
const QString &message,
|
||||
const QList<QString> &attachments = {},
|
||||
@@ -84,6 +90,7 @@ private:
|
||||
PluginLLMCore::IPromptProvider *m_promptProvider = nullptr;
|
||||
ChatModel *m_chatModel;
|
||||
Context::ContextManager *m_contextManager;
|
||||
Skills::SkillsManager *m_skillsManager = nullptr;
|
||||
QString m_chatFilePath;
|
||||
|
||||
QHash<QString, RequestContext> m_activeRequests;
|
||||
|
||||
@@ -401,15 +401,31 @@ ChatRootView {
|
||||
root.calculateMessageTokensCount(messageInput.text)
|
||||
var cursorPos = messageInput.cursorPosition
|
||||
var textBefore = messageInput.text.substring(0, cursorPos)
|
||||
|
||||
var atIndex = textBefore.lastIndexOf('@')
|
||||
if (atIndex >= 0) {
|
||||
var query = textBefore.substring(atIndex + 1)
|
||||
if (query.indexOf(' ') === -1 && query.indexOf('\n') === -1) {
|
||||
fileMentionPopup.updateSearch(query)
|
||||
skillCommandPopup.dismiss()
|
||||
return
|
||||
}
|
||||
}
|
||||
fileMentionPopup.dismiss()
|
||||
|
||||
const slashIndex = textBefore.lastIndexOf('/')
|
||||
if (slashIndex >= 0) {
|
||||
const beforeSlash = slashIndex === 0
|
||||
? ' '
|
||||
: textBefore.charAt(slashIndex - 1)
|
||||
const skillQuery = textBefore.substring(slashIndex + 1)
|
||||
if ((beforeSlash === ' ' || beforeSlash === '\n')
|
||||
&& /^[a-z0-9-]*$/.test(skillQuery)) {
|
||||
skillCommandPopup.updateSearch(skillQuery)
|
||||
return
|
||||
}
|
||||
}
|
||||
skillCommandPopup.dismiss()
|
||||
}
|
||||
|
||||
Keys.onPressed: function(event) {
|
||||
@@ -427,6 +443,20 @@ ChatRootView {
|
||||
fileMentionPopup.dismiss()
|
||||
event.accepted = true
|
||||
}
|
||||
} else if (skillCommandPopup.visible) {
|
||||
if (event.key === Qt.Key_Down) {
|
||||
skillCommandPopup.moveDown()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Up) {
|
||||
skillCommandPopup.moveUp()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||
root.applySkillSelection()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Escape) {
|
||||
skillCommandPopup.dismiss()
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,6 +591,23 @@ ChatRootView {
|
||||
}
|
||||
}
|
||||
|
||||
function applySkillSelection() {
|
||||
const name = skillCommandPopup.currentName()
|
||||
if (name === "")
|
||||
return
|
||||
const cursorPos = messageInput.cursorPosition
|
||||
const textBefore = messageInput.text.substring(0, cursorPos)
|
||||
const slashIndex = textBefore.lastIndexOf('/')
|
||||
if (slashIndex < 0)
|
||||
return
|
||||
const before = messageInput.text.substring(0, slashIndex)
|
||||
const after = messageInput.text.substring(cursorPos)
|
||||
const token = '/' + name + ' '
|
||||
messageInput.text = before + token + after
|
||||
messageInput.cursorPosition = before.length + token.length
|
||||
skillCommandPopup.dismiss()
|
||||
}
|
||||
|
||||
function sendChatMessage() {
|
||||
root.sendMessage(fileMentionPopup.expandMentions(messageInput.text))
|
||||
messageInput.text = ""
|
||||
@@ -660,6 +707,20 @@ ChatRootView {
|
||||
}
|
||||
}
|
||||
|
||||
SkillCommandPopup {
|
||||
id: skillCommandPopup
|
||||
|
||||
z: 999
|
||||
width: Math.min(480, root.width - 20)
|
||||
|
||||
x: Math.max(5, Math.min(view.x + 5, root.width - width - 5))
|
||||
y: view.y - height - 4
|
||||
|
||||
skillProvider: root
|
||||
|
||||
onSelectionRequested: root.applySkillSelection()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
focusInput()
|
||||
}
|
||||
|
||||
125
ChatView/qml/controls/SkillCommandPopup.qml
Normal file
125
ChatView/qml/controls/SkillCommandPopup.qml
Normal file
@@ -0,0 +1,125 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
// Object exposing Q_INVOKABLE QVariantList searchSkills(query).
|
||||
property var skillProvider: null
|
||||
property var searchResults: []
|
||||
property int currentIndex: 0
|
||||
|
||||
signal selectionRequested()
|
||||
|
||||
visible: searchResults.length > 0
|
||||
height: Math.min(searchResults.length * 40, 40 * 6) + 2
|
||||
|
||||
color: palette.window
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 4
|
||||
|
||||
function updateSearch(query) {
|
||||
searchResults = skillProvider ? skillProvider.searchSkills(query) : []
|
||||
currentIndex = 0
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
searchResults = []
|
||||
currentIndex = 0
|
||||
}
|
||||
|
||||
function moveUp() {
|
||||
if (currentIndex > 0)
|
||||
currentIndex--
|
||||
}
|
||||
|
||||
function moveDown() {
|
||||
if (currentIndex < searchResults.length - 1)
|
||||
currentIndex++
|
||||
}
|
||||
|
||||
function currentName() {
|
||||
if (currentIndex >= 0 && currentIndex < searchResults.length)
|
||||
return searchResults[currentIndex].name
|
||||
return ""
|
||||
}
|
||||
|
||||
onCurrentIndexChanged: listView.positionViewAtIndex(currentIndex, ListView.Contain)
|
||||
|
||||
ListView {
|
||||
id: listView
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: 1
|
||||
model: root.searchResults
|
||||
currentIndex: root.currentIndex
|
||||
clip: true
|
||||
|
||||
ScrollBar.vertical: ScrollBar {
|
||||
policy: ScrollBar.AsNeeded
|
||||
}
|
||||
|
||||
delegate: Rectangle {
|
||||
id: delegateItem
|
||||
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
width: listView.width
|
||||
height: 40
|
||||
color: index === root.currentIndex
|
||||
? palette.highlight
|
||||
: (hoverArea.containsMouse
|
||||
? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.25)
|
||||
: "transparent")
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
anchors.topMargin: 4
|
||||
anchors.bottomMargin: 4
|
||||
spacing: 1
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "/" + delegateItem.modelData.name
|
||||
color: delegateItem.index === root.currentIndex
|
||||
? palette.highlightedText
|
||||
: palette.text
|
||||
font.bold: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: delegateItem.modelData.description
|
||||
color: delegateItem.index === root.currentIndex
|
||||
? Qt.rgba(palette.highlightedText.r,
|
||||
palette.highlightedText.g,
|
||||
palette.highlightedText.b, 0.7)
|
||||
: palette.mid
|
||||
font.pixelSize: 11
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: hoverArea
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
root.currentIndex = delegateItem.index
|
||||
root.selectionRequested()
|
||||
}
|
||||
onEntered: root.currentIndex = delegateItem.index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
README.md
37
README.md
@@ -35,6 +35,7 @@ QodeAssist enhances Qt Creator with AI-powered coding assistance:
|
||||
- **Chat Assistant** — side panel, bottom panel, or detached window; history with auto-save, token monitoring, extended thinking
|
||||
- **Quick Refactoring** — inline AI-assisted edits directly in the editor with a searchable custom-instructions library
|
||||
- **Agent Tools** — read, search, create and edit files; build the project; run terminal commands; access linter/compiler issues; manage TODOs
|
||||
- **Agent Skills** — reusable folders of specialized instructions loaded on demand; discovered from `.qodeassist/skills/` and `.claude/skills/`, invoked automatically, with `/skill`, or always-on
|
||||
- **MCP Server** — expose QodeAssist's project-aware tools to external MCP clients (Claude Code, VS Code, Claude Desktop via bridge)
|
||||
- **MCP Client Hub** — connect QodeAssist to external MCP servers and use their tools in Chat and Quick Refactor (authenticated MCP servers are not supported yet)
|
||||
- **File Context** — attach, link, or auto-sync open editor files for richer prompts
|
||||
@@ -253,6 +254,41 @@ Chat and Quick Refactor can call tools to inspect and modify your project. Each
|
||||
| `execute_terminal_command` | Run a shell command (with confirmation) |
|
||||
| `todo_tool` | Track multi-step task progress during a conversation |
|
||||
|
||||
### Skills
|
||||
|
||||
**Agent Skills** package specialized instructions and workflows into reusable folders the AI loads on demand. QodeAssist implements the open [Agent Skills](https://agentskills.io) format, so skills authored for Claude Code, Cursor, or other agents work as-is.
|
||||
|
||||
A skill is a folder containing a `SKILL.md` file — YAML frontmatter (`name`, `description`) plus Markdown instructions:
|
||||
|
||||
```
|
||||
my-skill/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-skill
|
||||
description: What the skill does and when to use it.
|
||||
---
|
||||
|
||||
# My Skill
|
||||
|
||||
Step-by-step instructions for the task...
|
||||
```
|
||||
|
||||
**Where skills are discovered:**
|
||||
- **Project skills** — project-relative subdirectories (default `.qodeassist/skills/` and `.claude/skills/`), configured in `Projects → QodeAssist → Skills`. Project skills win over global ones on a name collision.
|
||||
- **Global skills** — absolute directories shared across all projects (default includes `~/.claude/skills/`), configured in `Tools → Options → QodeAssist → Skills`.
|
||||
|
||||
Both settings pages show the list of currently discovered skills.
|
||||
|
||||
**How skills are used in Chat:**
|
||||
- **Automatically** — each skill's name and description is added to the system prompt; when a request matches, the model loads the full instructions via the `load_skill` tool (requires a tool-calling model).
|
||||
- **Explicitly** — type `/` in the chat input and pick a skill from the popup; its instructions are injected into that one message. Works with any model.
|
||||
- **Always-on** — a skill whose frontmatter has `metadata: always-on: "true"` is injected into every chat request automatically.
|
||||
|
||||
Enable or disable the whole feature in `Tools → Options → QodeAssist → Skills`.
|
||||
|
||||
### MCP Server
|
||||
|
||||
QodeAssist can run an **MCP (Model Context Protocol) server** on `localhost`, exposing the tools above to external clients — so you can use QodeAssist's project awareness from Claude Code CLI, VS Code, Cursor, Claude Desktop, or any other MCP-capable client.
|
||||
@@ -454,6 +490,7 @@ For additional support, join our [Discord Community](https://discord.gg/BGMkUsXU
|
||||
- [x] Quick refactoring with custom-instructions library
|
||||
- [x] Diff sharing with models
|
||||
- [x] Tools / function calling (file I/O, build, terminal, diagnostics)
|
||||
- [x] Agent Skills (project + global directories, `/skill` commands, always-on, `load_skill` tool)
|
||||
- [x] Project-specific rules (`.qodeassist/rules/`)
|
||||
- [x] MCP (Model Context Protocol) — QodeAssist as a server
|
||||
- [x] MCP — QodeAssist as a client (consume external MCP tools; authenticated MCP servers not yet supported)
|
||||
|
||||
@@ -17,11 +17,15 @@
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatEditor::ChatEditor(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry)
|
||||
ChatEditor::ChatEditor(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager)
|
||||
: m_engine(engine)
|
||||
, m_sessionFileRegistry(sessionFileRegistry)
|
||||
, m_skillsManager(skillsManager)
|
||||
, m_document(new ChatDocument(this))
|
||||
, m_chatWidget(new ChatWidget(engine, sessionFileRegistry))
|
||||
, m_chatWidget(new ChatWidget(engine, sessionFileRegistry, skillsManager))
|
||||
{
|
||||
setWidget(m_chatWidget);
|
||||
setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT));
|
||||
@@ -67,7 +71,7 @@ QWidget *ChatEditor::toolBar()
|
||||
|
||||
Core::IEditor *ChatEditor::duplicate()
|
||||
{
|
||||
return new ChatEditor(m_engine, m_sessionFileRegistry);
|
||||
return new ChatEditor(m_engine, m_sessionFileRegistry, m_skillsManager);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
|
||||
class QQmlEngine;
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatDocument;
|
||||
@@ -20,7 +24,10 @@ class ChatEditor : public Core::IEditor
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
ChatEditor(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry);
|
||||
ChatEditor(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager);
|
||||
~ChatEditor() override;
|
||||
|
||||
Core::IDocument *document() const override;
|
||||
@@ -32,6 +39,7 @@ public:
|
||||
private:
|
||||
QQmlEngine *m_engine;
|
||||
SessionFileRegistry *m_sessionFileRegistry;
|
||||
Skills::SkillsManager *m_skillsManager;
|
||||
ChatDocument *m_document;
|
||||
ChatWidget *m_chatWidget;
|
||||
};
|
||||
|
||||
@@ -9,12 +9,16 @@
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatEditorFactory::ChatEditorFactory(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry)
|
||||
ChatEditorFactory::ChatEditorFactory(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager)
|
||||
{
|
||||
setId(Constants::QODE_ASSIST_CHAT_EDITOR_ID);
|
||||
setDisplayName(Tr::tr("QodeAssist Chat"));
|
||||
setEditorCreator(
|
||||
[engine, sessionFileRegistry] { return new ChatEditor(engine, sessionFileRegistry); });
|
||||
setEditorCreator([engine, sessionFileRegistry, skillsManager] {
|
||||
return new ChatEditor(engine, sessionFileRegistry, skillsManager);
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
|
||||
class QQmlEngine;
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class SessionFileRegistry;
|
||||
@@ -14,7 +18,10 @@ class SessionFileRegistry;
|
||||
class ChatEditorFactory : public Core::IEditorFactory
|
||||
{
|
||||
public:
|
||||
ChatEditorFactory(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry);
|
||||
ChatEditorFactory(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager);
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatOutputPane::ChatOutputPane(
|
||||
QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry, QObject *parent)
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager,
|
||||
QObject *parent)
|
||||
: Core::IOutputPane(parent)
|
||||
, m_chatWidget{new ChatWidget{engine, sessionFileRegistry}}
|
||||
, m_chatWidget{new ChatWidget{engine, sessionFileRegistry, skillsManager}}
|
||||
{
|
||||
setId("QodeAssistChat");
|
||||
setDisplayName(Tr::tr("QodeAssist Chat"));
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
#include "ChatView/ChatWidget.hpp"
|
||||
#include <coreplugin/ioutputpane.h>
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class SessionFileRegistry;
|
||||
@@ -18,6 +22,7 @@ public:
|
||||
explicit ChatOutputPane(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager,
|
||||
QObject *parent = nullptr);
|
||||
~ChatOutputPane() override;
|
||||
|
||||
|
||||
@@ -6,12 +6,17 @@
|
||||
#include "ChatView/ChatWidget.hpp"
|
||||
#include "ChatView/SessionFileRegistry.hpp"
|
||||
#include "QodeAssistConstants.hpp"
|
||||
#include "sources/skills/SkillsManager.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
NavigationPanel::NavigationPanel(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry)
|
||||
NavigationPanel::NavigationPanel(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager)
|
||||
: m_engine{engine}
|
||||
, m_sessionFileRegistry{sessionFileRegistry}
|
||||
, m_skillsManager{skillsManager}
|
||||
{
|
||||
setDisplayName(tr("QodeAssist Chat"));
|
||||
setPriority(500);
|
||||
@@ -23,7 +28,7 @@ NavigationPanel::~NavigationPanel() {}
|
||||
|
||||
Core::NavigationView NavigationPanel::createWidget()
|
||||
{
|
||||
return {.widget = new ChatWidget{m_engine, m_sessionFileRegistry}};
|
||||
return {.widget = new ChatWidget{m_engine, m_sessionFileRegistry, m_skillsManager}};
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
|
||||
class QQmlEngine;
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class SessionFileRegistry;
|
||||
@@ -17,7 +21,10 @@ class NavigationPanel : public Core::INavigationWidgetFactory
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit NavigationPanel(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry);
|
||||
explicit NavigationPanel(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager);
|
||||
~NavigationPanel();
|
||||
|
||||
Core::NavigationView createWidget() override;
|
||||
@@ -25,6 +32,7 @@ public:
|
||||
private:
|
||||
QPointer<QQmlEngine> m_engine;
|
||||
QPointer<SessionFileRegistry> m_sessionFileRegistry;
|
||||
QPointer<Skills::SkillsManager> m_skillsManager;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
#include "logger/RequestPerformanceLogger.hpp"
|
||||
#include "mcp/McpClientsManager.hpp"
|
||||
#include "mcp/McpServerManager.hpp"
|
||||
#include "sources/skills/SkillsManager.hpp"
|
||||
#include "tools/ToolsRegistration.hpp"
|
||||
#include "providers/Providers.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
@@ -162,14 +164,28 @@ public:
|
||||
|
||||
m_engine = new QQmlEngine{this};
|
||||
m_sessionFileRegistry = new Chat::SessionFileRegistry{this};
|
||||
m_skillsManager = new Skills::SkillsManager{this};
|
||||
|
||||
{
|
||||
auto &providers = PluginLLMCore::ProvidersManager::instance();
|
||||
for (const QString &providerName : providers.providersNames()) {
|
||||
if (auto *provider = providers.getProviderByName(providerName)) {
|
||||
if (auto *toolsManager = provider->toolsManager())
|
||||
Tools::registerSkillTool(toolsManager, m_skillsManager);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Settings::chatAssistantSettings().enableChatInBottomToolBar()) {
|
||||
m_chatOutputPane = new Chat::ChatOutputPane{m_engine, m_sessionFileRegistry};
|
||||
m_chatOutputPane = new Chat::ChatOutputPane{
|
||||
m_engine, m_sessionFileRegistry, m_skillsManager};
|
||||
}
|
||||
if (Settings::chatAssistantSettings().enableChatInNavigationPanel()) {
|
||||
m_navigationPanel = new Chat::NavigationPanel{m_engine, m_sessionFileRegistry};
|
||||
m_navigationPanel = new Chat::NavigationPanel{
|
||||
m_engine, m_sessionFileRegistry, m_skillsManager};
|
||||
}
|
||||
m_chatEditorFactory = new Chat::ChatEditorFactory{m_engine, m_sessionFileRegistry};
|
||||
m_chatEditorFactory = new Chat::ChatEditorFactory{
|
||||
m_engine, m_sessionFileRegistry, m_skillsManager};
|
||||
|
||||
Settings::setupProjectPanel();
|
||||
ConfigurationManager::instance().init();
|
||||
@@ -324,7 +340,7 @@ private:
|
||||
void openChatInWindow()
|
||||
{
|
||||
if (!m_chatView)
|
||||
m_chatView.reset(new Chat::ChatView{m_engine, m_sessionFileRegistry});
|
||||
m_chatView.reset(new Chat::ChatView{m_engine, m_sessionFileRegistry, m_skillsManager});
|
||||
|
||||
if (!m_chatView->isVisible())
|
||||
m_chatView->show();
|
||||
@@ -339,7 +355,8 @@ private:
|
||||
void setChatInBottomPaneEnabled(bool enabled)
|
||||
{
|
||||
if (enabled && !m_chatOutputPane)
|
||||
m_chatOutputPane = new Chat::ChatOutputPane{m_engine, m_sessionFileRegistry};
|
||||
m_chatOutputPane = new Chat::ChatOutputPane{
|
||||
m_engine, m_sessionFileRegistry, m_skillsManager};
|
||||
else if (!enabled && m_chatOutputPane)
|
||||
delete m_chatOutputPane;
|
||||
|
||||
@@ -350,7 +367,8 @@ private:
|
||||
void setChatInSidebarEnabled(bool enabled)
|
||||
{
|
||||
if (enabled && !m_navigationPanel)
|
||||
m_navigationPanel = new Chat::NavigationPanel{m_engine, m_sessionFileRegistry};
|
||||
m_navigationPanel = new Chat::NavigationPanel{
|
||||
m_engine, m_sessionFileRegistry, m_skillsManager};
|
||||
else if (!enabled && m_navigationPanel)
|
||||
delete m_navigationPanel;
|
||||
|
||||
@@ -430,6 +448,7 @@ private:
|
||||
QScopedPointer<Chat::ChatView> m_chatView;
|
||||
QPointer<Mcp::McpServerManager> m_mcpServerManager;
|
||||
QPointer<QQmlEngine> m_engine;
|
||||
QPointer<Skills::SkillsManager> m_skillsManager;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Internal
|
||||
|
||||
@@ -9,6 +9,7 @@ add_library(QodeAssistSettings STATIC
|
||||
ChatAssistantSettings.hpp ChatAssistantSettings.cpp
|
||||
QuickRefactorSettings.hpp QuickRefactorSettings.cpp
|
||||
ToolsSettings.hpp ToolsSettings.cpp
|
||||
SkillsSettings.hpp SkillsSettings.cpp
|
||||
McpSettings.hpp McpSettings.cpp
|
||||
SettingsDialog.hpp SettingsDialog.cpp
|
||||
ProjectSettings.hpp ProjectSettings.cpp
|
||||
@@ -30,5 +31,6 @@ target_link_libraries(QodeAssistSettings
|
||||
QtCreator::Core
|
||||
QtCreator::Utils
|
||||
QodeAssistLogger
|
||||
Skills
|
||||
)
|
||||
target_include_directories(QodeAssistSettings PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
@@ -32,6 +32,15 @@ ProjectSettings::ProjectSettings(ProjectExplorer::Project *project)
|
||||
|
||||
chatHistoryPath.setDefaultValue(projectChatHistoryPath);
|
||||
|
||||
projectSkillDirs.setSettingsKey(Constants::SK_PROJECT_SKILL_DIRS);
|
||||
projectSkillDirs.setLabelText(Tr::tr("Skill directories:"));
|
||||
projectSkillDirs.setDisplayStyle(Utils::StringAspect::TextEditDisplay);
|
||||
projectSkillDirs.setToolTip(
|
||||
Tr::tr("Project-relative subdirectories scanned for Agent Skills, one per line. "
|
||||
"Resolved against the project root. These take priority over the global "
|
||||
"skill directories when a skill name appears in both."));
|
||||
projectSkillDirs.setDefaultValue(".qodeassist/skills\n.claude/skills");
|
||||
|
||||
Utils::Store map = Utils::storeFromVariant(
|
||||
project->namedSettings(Constants::QODE_ASSIST_PROJECT_SETTINGS_ID));
|
||||
fromMap(map);
|
||||
@@ -39,6 +48,7 @@ ProjectSettings::ProjectSettings(ProjectExplorer::Project *project)
|
||||
enableQodeAssist.addOnChanged(this, [this, project] { save(project); });
|
||||
useGlobalSettings.addOnChanged(this, [this, project] { save(project); });
|
||||
chatHistoryPath.addOnChanged(this, [this, project] { save(project); });
|
||||
projectSkillDirs.addOnChanged(this, [this, project] { save(project); });
|
||||
}
|
||||
|
||||
void ProjectSettings::setUseGlobalSettings(bool useGlobal)
|
||||
|
||||
@@ -23,6 +23,7 @@ public:
|
||||
Utils::BoolAspect enableQodeAssist{this};
|
||||
Utils::BoolAspect useGlobalSettings{this};
|
||||
Utils::FilePathAspect chatHistoryPath{this};
|
||||
Utils::StringAspect projectSkillDirs{this};
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
|
||||
@@ -8,9 +8,15 @@
|
||||
#include <projectexplorer/projectsettingswidget.h>
|
||||
#include <utils/layoutbuilder.h>
|
||||
|
||||
#include <QLabel>
|
||||
#include <QListWidget>
|
||||
|
||||
#include "ProjectSettings.hpp"
|
||||
#include "SettingsConstants.hpp"
|
||||
#include "SettingsTr.hpp"
|
||||
#include "SkillsSettings.hpp"
|
||||
#include "sources/skills/SkillsLoader.hpp"
|
||||
#include "sources/skills/SkillsManager.hpp"
|
||||
|
||||
using namespace ProjectExplorer;
|
||||
|
||||
@@ -41,17 +47,59 @@ static ProjectSettingsWidget *createProjectPanel(Project *project)
|
||||
&ProjectSettings::setUseGlobalSettings);
|
||||
|
||||
widget->setUseGlobalSettings(settings->useGlobalSettings());
|
||||
widget->setEnabled(!settings->useGlobalSettings());
|
||||
|
||||
QObject::connect(
|
||||
widget, &ProjectSettingsWidget::useGlobalSettingsChanged, widget, [widget](bool useGlobal) {
|
||||
widget->setEnabled(!useGlobal);
|
||||
});
|
||||
|
||||
auto generalWidget = new QWidget;
|
||||
Column{
|
||||
settings->enableQodeAssist,
|
||||
Space{8},
|
||||
settings->chatHistoryPath,
|
||||
}
|
||||
.attachTo(generalWidget);
|
||||
|
||||
generalWidget->setEnabled(!settings->useGlobalSettings());
|
||||
QObject::connect(
|
||||
widget,
|
||||
&ProjectSettingsWidget::useGlobalSettingsChanged,
|
||||
generalWidget,
|
||||
[generalWidget](bool useGlobal) { generalWidget->setEnabled(!useGlobal); });
|
||||
|
||||
auto skillsList = new QListWidget;
|
||||
skillsList->setSelectionMode(QAbstractItemView::NoSelection);
|
||||
skillsList->setMaximumHeight(160);
|
||||
|
||||
auto refreshSkills = [skillsList, project, settings] {
|
||||
skillsList->clear();
|
||||
|
||||
// Project-only roots: the global page shows global skills separately.
|
||||
const QStringList roots = Skills::SkillsManager::resolveRoots(
|
||||
project->projectDirectory().toFSPathString(),
|
||||
{},
|
||||
SkillsSettings::splitLines(settings->projectSkillDirs()));
|
||||
|
||||
const QVector<Skills::AgentSkill> skills = Skills::SkillsLoader::scan(roots);
|
||||
for (const Skills::AgentSkill &skill : skills) {
|
||||
auto *item = new QListWidgetItem(
|
||||
QStringLiteral("%1 — %2").arg(skill.name, skill.description), skillsList);
|
||||
item->setToolTip(skill.skillDir);
|
||||
}
|
||||
if (skills.isEmpty())
|
||||
new QListWidgetItem(Tr::tr("No skills discovered."), skillsList);
|
||||
};
|
||||
refreshSkills();
|
||||
QObject::connect(
|
||||
&settings->projectSkillDirs, &Utils::BaseAspect::changed, skillsList, refreshSkills);
|
||||
|
||||
Column{
|
||||
generalWidget,
|
||||
Space{8},
|
||||
Group{
|
||||
title(Tr::tr("Skills")),
|
||||
Column{
|
||||
settings->projectSkillDirs,
|
||||
new QLabel(Tr::tr("Discovered project skills:")),
|
||||
skillsList,
|
||||
},
|
||||
},
|
||||
}
|
||||
.attachTo(widget);
|
||||
|
||||
|
||||
@@ -102,12 +102,18 @@ const char CA_ENABLE_TERMINAL_COMMAND_TOOL[] = "QodeAssist.caEnableTerminalComma
|
||||
const char CA_ENABLE_TODO_TOOL[] = "QodeAssist.caEnableTodoToolV2";
|
||||
const char CA_ENABLE_READ_ORIGINAL_HISTORY_TOOL[]
|
||||
= "QodeAssist.caEnableReadOriginalHistoryTool";
|
||||
const char CA_ENABLE_SKILL_TOOL[] = "QodeAssist.caEnableSkillTool";
|
||||
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 CA_TERMINAL_COMMAND_TIMEOUT[] = "QodeAssist.caTerminalCommandTimeout";
|
||||
|
||||
// Skills settings
|
||||
const char SK_ENABLE_SKILLS[] = "QodeAssist.skEnableSkills";
|
||||
const char SK_GLOBAL_SKILL_ROOTS[] = "QodeAssist.skGlobalSkillRoots";
|
||||
const char SK_PROJECT_SKILL_DIRS[] = "QodeAssist.skProjectSkillDirs";
|
||||
|
||||
// MCP server settings
|
||||
const char MCP_ENABLE_SERVER[] = "QodeAssist.mcpEnableServer";
|
||||
const char MCP_SERVER_PORT[] = "QodeAssist.mcpServerPort";
|
||||
@@ -124,6 +130,7 @@ const char QODE_ASSIST_QUICK_REFACTOR_SETTINGS_PAGE_ID[]
|
||||
= "QodeAssist.4QuickRefactorSettingsPageId";
|
||||
const char QODE_ASSIST_TOOLS_SETTINGS_PAGE_ID[] = "QodeAssist.5ToolsSettingsPageId";
|
||||
const char QODE_ASSIST_MCP_SETTINGS_PAGE_ID[] = "QodeAssist.6McpSettingsPageId";
|
||||
const char QODE_ASSIST_SKILLS_SETTINGS_PAGE_ID[] = "QodeAssist.8SkillsSettingsPageId";
|
||||
|
||||
const char QODE_ASSIST_GENERAL_OPTIONS_CATEGORY[] = "QodeAssist.Category";
|
||||
const char QODE_ASSIST_GENERAL_OPTIONS_DISPLAY_CATEGORY[] = "QodeAssist";
|
||||
|
||||
135
settings/SkillsSettings.cpp
Normal file
135
settings/SkillsSettings.cpp
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "SkillsSettings.hpp"
|
||||
|
||||
#include <coreplugin/dialogs/ioptionspage.h>
|
||||
#include <coreplugin/icore.h>
|
||||
#include <utils/layoutbuilder.h>
|
||||
|
||||
#include <QDir>
|
||||
#include <QLabel>
|
||||
#include <QListWidget>
|
||||
|
||||
#include "SettingsConstants.hpp"
|
||||
#include "SettingsTr.hpp"
|
||||
#include "sources/skills/SkillsLoader.hpp"
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
SkillsSettings &skillsSettings()
|
||||
{
|
||||
static SkillsSettings settings;
|
||||
return settings;
|
||||
}
|
||||
|
||||
QStringList SkillsSettings::splitLines(const QString &value)
|
||||
{
|
||||
QStringList lines;
|
||||
const auto parts = value.split('\n', Qt::SkipEmptyParts);
|
||||
for (const QString &part : parts) {
|
||||
const QString trimmed = part.trimmed();
|
||||
if (!trimmed.isEmpty())
|
||||
lines << trimmed;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
QStringList SkillsSettings::splitPaths(const QString &value)
|
||||
{
|
||||
QStringList paths;
|
||||
for (QString path : splitLines(value)) {
|
||||
if (path == QLatin1String("~"))
|
||||
path = QDir::homePath();
|
||||
else if (path.startsWith(QLatin1String("~/")))
|
||||
path = QDir::homePath() + path.mid(1);
|
||||
paths << QDir::cleanPath(path);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
SkillsSettings::SkillsSettings()
|
||||
{
|
||||
setAutoApply(false);
|
||||
|
||||
setDisplayName(Tr::tr("Skills"));
|
||||
|
||||
enableSkills.setSettingsKey(Constants::SK_ENABLE_SKILLS);
|
||||
enableSkills.setLabelText(Tr::tr("Enable skills"));
|
||||
enableSkills.setToolTip(
|
||||
Tr::tr("Discover Agent Skills from the configured skill directories and expose them "
|
||||
"to the chat assistant. Each skill is a folder containing a SKILL.md file."));
|
||||
enableSkills.setDefaultValue(true);
|
||||
|
||||
const QString defaultGlobalRoots
|
||||
= Core::ICore::userResourcePath().toFSPathString() + "/qodeassist/skills\n"
|
||||
+ QDir::homePath() + "/.claude/skills";
|
||||
|
||||
globalSkillRoots.setSettingsKey(Constants::SK_GLOBAL_SKILL_ROOTS);
|
||||
globalSkillRoots.setLabelText(Tr::tr("Global skill directories:"));
|
||||
globalSkillRoots.setDisplayStyle(Utils::StringAspect::TextEditDisplay);
|
||||
globalSkillRoots.setToolTip(
|
||||
Tr::tr("Absolute paths scanned for skills, one per line. Each path is a directory "
|
||||
"whose subfolders contain SKILL.md files. A leading ~ expands to your home "
|
||||
"directory. Lets QodeAssist pick up skills shared with other agents "
|
||||
"(e.g. ~/.claude/skills)."));
|
||||
globalSkillRoots.setDefaultValue(defaultGlobalRoots);
|
||||
|
||||
readSettings();
|
||||
|
||||
setLayouter([this]() {
|
||||
using namespace Layouting;
|
||||
|
||||
auto skillsList = new QListWidget;
|
||||
skillsList->setSelectionMode(QAbstractItemView::NoSelection);
|
||||
skillsList->setMaximumHeight(160);
|
||||
|
||||
auto refreshSkills = [skillsList, this] {
|
||||
skillsList->clear();
|
||||
const QVector<Skills::AgentSkill> skills
|
||||
= Skills::SkillsLoader::scan(splitPaths(globalSkillRoots()));
|
||||
for (const Skills::AgentSkill &skill : skills) {
|
||||
auto *item = new QListWidgetItem(
|
||||
QStringLiteral("%1 — %2").arg(skill.name, skill.description), skillsList);
|
||||
item->setToolTip(skill.skillDir);
|
||||
}
|
||||
if (skills.isEmpty())
|
||||
new QListWidgetItem(Tr::tr("No skills discovered."), skillsList);
|
||||
};
|
||||
refreshSkills();
|
||||
connect(&globalSkillRoots, &Utils::BaseAspect::changed, skillsList, refreshSkills);
|
||||
|
||||
return Column{
|
||||
Group{
|
||||
title(Tr::tr("Skills")),
|
||||
Column{
|
||||
Row{enableSkills, Stretch{1}},
|
||||
},
|
||||
},
|
||||
Group{
|
||||
title(Tr::tr("Skill Directories")),
|
||||
Column{
|
||||
globalSkillRoots,
|
||||
new QLabel(Tr::tr("Discovered global skills:")),
|
||||
skillsList,
|
||||
},
|
||||
},
|
||||
Stretch{1}};
|
||||
});
|
||||
}
|
||||
|
||||
class SkillsSettingsPage : public Core::IOptionsPage
|
||||
{
|
||||
public:
|
||||
SkillsSettingsPage()
|
||||
{
|
||||
setId(Constants::QODE_ASSIST_SKILLS_SETTINGS_PAGE_ID);
|
||||
setDisplayName(Tr::tr("Skills"));
|
||||
setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY);
|
||||
setSettingsProvider([] { return &skillsSettings(); });
|
||||
}
|
||||
};
|
||||
|
||||
const SkillsSettingsPage skillsSettingsPage;
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
25
settings/SkillsSettings.hpp
Normal file
25
settings/SkillsSettings.hpp
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <utils/aspects.h>
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
class SkillsSettings : public Utils::AspectContainer
|
||||
{
|
||||
public:
|
||||
SkillsSettings();
|
||||
|
||||
Utils::BoolAspect enableSkills{this};
|
||||
|
||||
Utils::StringAspect globalSkillRoots{this};
|
||||
|
||||
static QStringList splitLines(const QString &value);
|
||||
static QStringList splitPaths(const QString &value);
|
||||
};
|
||||
|
||||
SkillsSettings &skillsSettings();
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
@@ -119,6 +119,14 @@ ToolsSettings::ToolsSettings()
|
||||
"summary currently in context. Has no effect if the chat was never compressed."));
|
||||
enableReadOriginalHistoryTool.setDefaultValue(true);
|
||||
|
||||
enableSkillTool.setSettingsKey(Constants::CA_ENABLE_SKILL_TOOL);
|
||||
enableSkillTool.setLabelText(Tr::tr("Load Skill"));
|
||||
enableSkillTool.setToolTip(
|
||||
Tr::tr("Lets the AI load the full instructions of a skill on demand. The Available "
|
||||
"Skills catalog in the system prompt lists each skill; this tool pulls a "
|
||||
"skill's complete instructions into context when needed."));
|
||||
enableSkillTool.setDefaultValue(true);
|
||||
|
||||
allowedTerminalCommandsLinux.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_LINUX);
|
||||
allowedTerminalCommandsLinux.setLabelText(Tr::tr("Allowed Commands (Linux)"));
|
||||
allowedTerminalCommandsLinux.setToolTip(
|
||||
@@ -186,7 +194,8 @@ ToolsSettings::ToolsSettings()
|
||||
enableGetIssuesListTool,
|
||||
enableTerminalCommandTool,
|
||||
enableTodoTool,
|
||||
enableReadOriginalHistoryTool}},
|
||||
enableReadOriginalHistoryTool,
|
||||
enableSkillTool}},
|
||||
Space{8},
|
||||
Group{
|
||||
title(Tr::tr("Tool Settings")),
|
||||
@@ -237,6 +246,7 @@ void ToolsSettings::resetSettingsToDefaults()
|
||||
resetAspect(enableTerminalCommandTool);
|
||||
resetAspect(enableTodoTool);
|
||||
resetAspect(enableReadOriginalHistoryTool);
|
||||
resetAspect(enableSkillTool);
|
||||
resetAspect(allowedTerminalCommandsLinux);
|
||||
resetAspect(allowedTerminalCommandsMacOS);
|
||||
resetAspect(allowedTerminalCommandsWindows);
|
||||
|
||||
@@ -31,6 +31,7 @@ public:
|
||||
Utils::BoolAspect enableTerminalCommandTool{this};
|
||||
Utils::BoolAspect enableTodoTool{this};
|
||||
Utils::BoolAspect enableReadOriginalHistoryTool{this};
|
||||
Utils::BoolAspect enableSkillTool{this};
|
||||
|
||||
Utils::StringAspect allowedTerminalCommandsLinux{this};
|
||||
Utils::StringAspect allowedTerminalCommandsMacOS{this};
|
||||
|
||||
29
sources/skills/AgentSkill.hpp
Normal file
29
sources/skills/AgentSkill.hpp
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QHash>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
|
||||
struct AgentSkill
|
||||
{
|
||||
QString name;
|
||||
QString description;
|
||||
QString body; // Markdown body after the frontmatter
|
||||
QString skillDir; // absolute path to the skill folder
|
||||
QString rootPath; // the scan root this skill was found in
|
||||
QString license;
|
||||
QString compatibility;
|
||||
QStringList allowedTools;
|
||||
QHash<QString, QString> metadata;
|
||||
bool enabled = true;
|
||||
bool alwaysOn = false;
|
||||
|
||||
bool isValid() const { return !name.isEmpty(); }
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Skills
|
||||
12
sources/skills/CMakeLists.txt
Normal file
12
sources/skills/CMakeLists.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
add_library(Skills STATIC
|
||||
AgentSkill.hpp
|
||||
SkillsLoader.hpp SkillsLoader.cpp
|
||||
SkillsManager.hpp SkillsManager.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(Skills
|
||||
PUBLIC
|
||||
Qt::Core
|
||||
)
|
||||
|
||||
target_include_directories(Skills PUBLIC ${CMAKE_SOURCE_DIR})
|
||||
269
sources/skills/SkillsLoader.cpp
Normal file
269
sources/skills/SkillsLoader.cpp
Normal file
@@ -0,0 +1,269 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "SkillsLoader.hpp"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QSet>
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
|
||||
namespace {
|
||||
|
||||
QString unquote(QString value)
|
||||
{
|
||||
value = value.trimmed();
|
||||
if (value.size() >= 2
|
||||
&& ((value.startsWith('"') && value.endsWith('"'))
|
||||
|| (value.startsWith('\'') && value.endsWith('\'')))) {
|
||||
value = value.mid(1, value.size() - 2);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
int indentOf(const QString &line)
|
||||
{
|
||||
int i = 0;
|
||||
while (i < line.size() && line[i] == ' ')
|
||||
++i;
|
||||
return i;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int SkillsLoader::maxBodyChars()
|
||||
{
|
||||
return 64 * 1024;
|
||||
}
|
||||
|
||||
bool SkillsLoader::parseFrontmatter(
|
||||
const QString &rawText, AgentSkill &skill, QString &body, QString &error)
|
||||
{
|
||||
// Normalize line endings so CRLF/CR files parse identically to LF.
|
||||
QString text = rawText;
|
||||
text.replace(QLatin1String("\r\n"), QLatin1String("\n"));
|
||||
text.replace('\r', '\n');
|
||||
|
||||
const QStringList lines = text.split('\n');
|
||||
if (lines.isEmpty() || lines.first().trimmed() != QLatin1String("---")) {
|
||||
error = QStringLiteral("missing YAML frontmatter");
|
||||
return false;
|
||||
}
|
||||
|
||||
int closing = -1;
|
||||
for (int i = 1; i < lines.size(); ++i) {
|
||||
if (lines[i].trimmed() == QLatin1String("---")) {
|
||||
closing = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (closing < 0) {
|
||||
error = QStringLiteral("unterminated frontmatter");
|
||||
return false;
|
||||
}
|
||||
|
||||
body = lines.mid(closing + 1).join('\n').trimmed();
|
||||
|
||||
QHash<QString, QString> fields;
|
||||
int i = 1;
|
||||
while (i < closing) {
|
||||
const QString line = lines[i];
|
||||
const QString trimmed = line.trimmed();
|
||||
if (trimmed.isEmpty() || trimmed.startsWith('#') || indentOf(line) != 0) {
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
const int colon = line.indexOf(':');
|
||||
if (colon < 0) {
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
const QString key = line.left(colon).trimmed();
|
||||
QString value = line.mid(colon + 1).trimmed();
|
||||
++i;
|
||||
|
||||
if (key == QLatin1String("metadata") && value.isEmpty()) {
|
||||
while (i < closing && (lines[i].trimmed().isEmpty() || indentOf(lines[i]) > 0)) {
|
||||
const QString entry = lines[i].trimmed();
|
||||
++i;
|
||||
if (entry.isEmpty() || entry.startsWith('#'))
|
||||
continue;
|
||||
const int entryColon = entry.indexOf(':');
|
||||
if (entryColon < 0)
|
||||
continue;
|
||||
skill.metadata.insert(
|
||||
entry.left(entryColon).trimmed(), unquote(entry.mid(entryColon + 1)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.startsWith('>') || value.startsWith('|')) {
|
||||
const bool literal = value.startsWith('|');
|
||||
QStringList block; // raw lines, indentation preserved
|
||||
while (i < closing && (lines[i].trimmed().isEmpty() || indentOf(lines[i]) > 0)) {
|
||||
block.append(lines[i]);
|
||||
++i;
|
||||
}
|
||||
while (!block.isEmpty() && block.last().trimmed().isEmpty())
|
||||
block.removeLast();
|
||||
|
||||
if (literal) {
|
||||
// Strip the common leading indentation, keep the rest verbatim.
|
||||
int common = -1;
|
||||
for (const QString &blockLine : block) {
|
||||
if (blockLine.trimmed().isEmpty())
|
||||
continue;
|
||||
const int indent = indentOf(blockLine);
|
||||
if (common < 0 || indent < common)
|
||||
common = indent;
|
||||
}
|
||||
if (common < 0)
|
||||
common = 0;
|
||||
QStringList stripped;
|
||||
for (const QString &blockLine : block)
|
||||
stripped.append(blockLine.mid(qMin(common, blockLine.size())));
|
||||
value = stripped.join('\n');
|
||||
} else {
|
||||
// Folded scalar: join non-blank lines with single spaces.
|
||||
QStringList folded;
|
||||
for (const QString &blockLine : block) {
|
||||
const QString trimmedLine = blockLine.trimmed();
|
||||
if (!trimmedLine.isEmpty())
|
||||
folded.append(trimmedLine);
|
||||
}
|
||||
value = folded.join(' ');
|
||||
}
|
||||
fields.insert(key, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
fields.insert(key, unquote(value));
|
||||
}
|
||||
|
||||
skill.name = fields.value(QStringLiteral("name"));
|
||||
skill.description = fields.value(QStringLiteral("description"));
|
||||
skill.license = fields.value(QStringLiteral("license"));
|
||||
skill.compatibility = fields.value(QStringLiteral("compatibility"));
|
||||
const QString tools = fields.value(QStringLiteral("allowed-tools"));
|
||||
if (!tools.isEmpty())
|
||||
skill.allowedTools = tools.split(' ', Qt::SkipEmptyParts);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SkillsLoader::validateName(const QString &name, const QString &dirName, QString &error)
|
||||
{
|
||||
if (name.isEmpty()) {
|
||||
error = QStringLiteral("missing 'name'");
|
||||
return false;
|
||||
}
|
||||
if (name.size() > 64) {
|
||||
error = QStringLiteral("'name' exceeds 64 characters");
|
||||
return false;
|
||||
}
|
||||
for (const QChar c : name) {
|
||||
const bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-';
|
||||
if (!ok) {
|
||||
error = QStringLiteral(
|
||||
"'name' may only contain lowercase letters, digits and hyphens");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (name.startsWith('-') || name.endsWith('-')) {
|
||||
error = QStringLiteral("'name' must not start or end with a hyphen");
|
||||
return false;
|
||||
}
|
||||
if (name.contains(QLatin1String("--"))) {
|
||||
error = QStringLiteral("'name' must not contain consecutive hyphens");
|
||||
return false;
|
||||
}
|
||||
// The directory name may differ in case on case-insensitive filesystems
|
||||
// (macOS, Windows); the spec only requires the names to match.
|
||||
if (name.compare(dirName, Qt::CaseInsensitive) != 0) {
|
||||
error = QStringLiteral("'name' (%1) must match the skill directory name (%2)")
|
||||
.arg(name, dirName);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
SkillsLoader::ParseResult SkillsLoader::parseSkillFile(
|
||||
const QString &skillDir, const QString &rootPath)
|
||||
{
|
||||
ParseResult result;
|
||||
|
||||
const QString skillMdPath = QDir(skillDir).absoluteFilePath(QStringLiteral("SKILL.md"));
|
||||
QFile file(skillMdPath);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
result.error = QStringLiteral("cannot open SKILL.md");
|
||||
return result;
|
||||
}
|
||||
const QString text = QString::fromUtf8(file.readAll());
|
||||
file.close();
|
||||
|
||||
AgentSkill skill;
|
||||
QString body;
|
||||
if (!parseFrontmatter(text, skill, body, result.error))
|
||||
return result;
|
||||
|
||||
const QString dirName = QDir(skillDir).dirName();
|
||||
if (!validateName(skill.name, dirName, result.error))
|
||||
return result;
|
||||
|
||||
if (skill.description.isEmpty()) {
|
||||
result.error = QStringLiteral("missing 'description'");
|
||||
return result;
|
||||
}
|
||||
if (skill.description.size() > 1024) {
|
||||
result.error = QStringLiteral("'description' exceeds 1024 characters");
|
||||
return result;
|
||||
}
|
||||
|
||||
skill.alwaysOn = skill.metadata.value(QStringLiteral("always-on"))
|
||||
.compare(QLatin1String("true"), Qt::CaseInsensitive)
|
||||
== 0;
|
||||
if (body.size() > maxBodyChars()) {
|
||||
body.truncate(maxBodyChars());
|
||||
body += QStringLiteral("\n\n[skill body truncated]");
|
||||
}
|
||||
skill.body = body;
|
||||
skill.skillDir = QDir(skillDir).absolutePath();
|
||||
skill.rootPath = rootPath;
|
||||
result.skill = skill;
|
||||
result.valid = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
QVector<AgentSkill> SkillsLoader::scan(const QStringList &rootPaths)
|
||||
{
|
||||
QVector<AgentSkill> skills;
|
||||
QSet<QString> seenNames;
|
||||
|
||||
for (const QString &root : rootPaths) {
|
||||
QDir rootDir(root);
|
||||
if (!rootDir.exists())
|
||||
continue;
|
||||
|
||||
const QStringList entries = rootDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
|
||||
for (const QString &entry : entries) {
|
||||
const QString skillDir = rootDir.absoluteFilePath(entry);
|
||||
if (!QFile::exists(QDir(skillDir).absoluteFilePath(QStringLiteral("SKILL.md"))))
|
||||
continue;
|
||||
|
||||
const ParseResult result = parseSkillFile(skillDir, root);
|
||||
if (!result.valid) {
|
||||
qWarning().noquote()
|
||||
<< "QodeAssist Skills: skipping" << skillDir << "-" << result.error;
|
||||
continue;
|
||||
}
|
||||
if (seenNames.contains(result.skill.name))
|
||||
continue; // earlier root wins
|
||||
seenNames.insert(result.skill.name);
|
||||
skills.append(result.skill);
|
||||
}
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Skills
|
||||
36
sources/skills/SkillsLoader.hpp
Normal file
36
sources/skills/SkillsLoader.hpp
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
|
||||
#include "AgentSkill.hpp"
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
|
||||
class SkillsLoader
|
||||
{
|
||||
public:
|
||||
struct ParseResult
|
||||
{
|
||||
AgentSkill skill;
|
||||
bool valid = false;
|
||||
QString error;
|
||||
};
|
||||
|
||||
static QVector<AgentSkill> scan(const QStringList &rootPaths);
|
||||
|
||||
static ParseResult parseSkillFile(const QString &skillDir, const QString &rootPath);
|
||||
|
||||
static int maxBodyChars();
|
||||
|
||||
private:
|
||||
static bool parseFrontmatter(
|
||||
const QString &text, AgentSkill &skill, QString &body, QString &error);
|
||||
static bool validateName(const QString &name, const QString &dirName, QString &error);
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Skills
|
||||
129
sources/skills/SkillsManager.cpp
Normal file
129
sources/skills/SkillsManager.cpp
Normal file
@@ -0,0 +1,129 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "SkillsManager.hpp"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileSystemWatcher>
|
||||
|
||||
#include "SkillsLoader.hpp"
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
|
||||
SkillsManager::SkillsManager(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_watcher(new QFileSystemWatcher(this))
|
||||
{
|
||||
connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, [this] { reload(); });
|
||||
}
|
||||
|
||||
void SkillsManager::configure(
|
||||
const QString &projectPath,
|
||||
const QStringList &globalRoots,
|
||||
const QStringList &projectSubdirs)
|
||||
{
|
||||
if (m_projectPath == projectPath && m_globalRoots == globalRoots
|
||||
&& m_projectSubdirs == projectSubdirs) {
|
||||
return;
|
||||
}
|
||||
m_projectPath = projectPath;
|
||||
m_globalRoots = globalRoots;
|
||||
m_projectSubdirs = projectSubdirs;
|
||||
reload();
|
||||
}
|
||||
|
||||
QStringList SkillsManager::resolveRoots(
|
||||
const QString &projectPath,
|
||||
const QStringList &globalRoots,
|
||||
const QStringList &projectSubdirs)
|
||||
{
|
||||
// Project-relative roots first so they win on a name collision.
|
||||
QStringList roots;
|
||||
if (!projectPath.isEmpty()) {
|
||||
const QDir projectDir(projectPath);
|
||||
const QString projectRoot = QDir::cleanPath(projectDir.absolutePath());
|
||||
for (const QString &subdir : projectSubdirs) {
|
||||
const QString resolved = QDir::cleanPath(projectDir.absoluteFilePath(subdir));
|
||||
// Drop subdirs that escape the project root (e.g. "../../etc").
|
||||
if (resolved == projectRoot
|
||||
|| resolved.startsWith(projectRoot + QLatin1Char('/'))) {
|
||||
roots << resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const QString &root : globalRoots)
|
||||
roots << QDir::cleanPath(root);
|
||||
return roots;
|
||||
}
|
||||
|
||||
void SkillsManager::reload()
|
||||
{
|
||||
const QStringList roots = resolveRoots(m_projectPath, m_globalRoots, m_projectSubdirs);
|
||||
m_skills = SkillsLoader::scan(roots);
|
||||
updateWatcher(roots);
|
||||
emit skillsChanged();
|
||||
}
|
||||
|
||||
void SkillsManager::updateWatcher(const QStringList &roots)
|
||||
{
|
||||
const QStringList watched = m_watcher->directories();
|
||||
if (!watched.isEmpty())
|
||||
m_watcher->removePaths(watched);
|
||||
|
||||
QStringList toWatch;
|
||||
for (const QString &root : roots) {
|
||||
if (QDir(root).exists())
|
||||
toWatch << root;
|
||||
}
|
||||
for (const AgentSkill &skill : m_skills)
|
||||
toWatch << skill.skillDir;
|
||||
|
||||
if (!toWatch.isEmpty())
|
||||
m_watcher->addPaths(toWatch);
|
||||
}
|
||||
|
||||
QVector<AgentSkill> SkillsManager::skills() const
|
||||
{
|
||||
return m_skills;
|
||||
}
|
||||
|
||||
std::optional<AgentSkill> SkillsManager::findByName(const QString &name) const
|
||||
{
|
||||
for (const AgentSkill &skill : m_skills) {
|
||||
if (skill.name == name)
|
||||
return skill;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
QString SkillsManager::catalogText() const
|
||||
{
|
||||
QStringList entries;
|
||||
for (const AgentSkill &skill : m_skills) {
|
||||
if (!skill.enabled || skill.alwaysOn)
|
||||
continue;
|
||||
entries << QStringLiteral("- %1: %2").arg(skill.name, skill.description);
|
||||
}
|
||||
if (entries.isEmpty())
|
||||
return {};
|
||||
|
||||
return QStringLiteral("# Available Skills\n"
|
||||
"Specialized skills are available for the tasks below. When a "
|
||||
"request matches a skill, call the load_skill tool with that "
|
||||
"skill's name to load its full instructions, then follow them.\n\n")
|
||||
+ entries.join('\n');
|
||||
}
|
||||
|
||||
QString SkillsManager::alwaysOnBodies() const
|
||||
{
|
||||
QStringList bodies;
|
||||
for (const AgentSkill &skill : m_skills) {
|
||||
if (!skill.enabled || !skill.alwaysOn)
|
||||
continue;
|
||||
if (!skill.body.isEmpty())
|
||||
bodies << skill.body;
|
||||
}
|
||||
return bodies.join(QStringLiteral("\n\n"));
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Skills
|
||||
59
sources/skills/SkillsManager.hpp
Normal file
59
sources/skills/SkillsManager.hpp
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
|
||||
#include "AgentSkill.hpp"
|
||||
|
||||
class QFileSystemWatcher;
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
|
||||
class SkillsManager : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SkillsManager(QObject *parent = nullptr);
|
||||
|
||||
void configure(
|
||||
const QString &projectPath,
|
||||
const QStringList &globalRoots,
|
||||
const QStringList &projectSubdirs);
|
||||
|
||||
void reload();
|
||||
|
||||
QVector<AgentSkill> skills() const;
|
||||
|
||||
std::optional<AgentSkill> findByName(const QString &name) const;
|
||||
|
||||
static QStringList resolveRoots(
|
||||
const QString &projectPath,
|
||||
const QStringList &globalRoots,
|
||||
const QStringList &projectSubdirs);
|
||||
|
||||
QString catalogText() const;
|
||||
|
||||
QString alwaysOnBodies() const;
|
||||
|
||||
signals:
|
||||
void skillsChanged();
|
||||
|
||||
private:
|
||||
void updateWatcher(const QStringList &roots);
|
||||
|
||||
QString m_projectPath;
|
||||
QStringList m_globalRoots;
|
||||
QStringList m_projectSubdirs;
|
||||
QVector<AgentSkill> m_skills;
|
||||
QFileSystemWatcher *m_watcher = nullptr;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Skills
|
||||
84
tools/SkillTool.cpp
Normal file
84
tools/SkillTool.cpp
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "SkillTool.hpp"
|
||||
|
||||
#include <LLMQore/ToolExceptions.hpp>
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QtConcurrent>
|
||||
|
||||
#include "sources/skills/AgentSkill.hpp"
|
||||
#include "sources/skills/SkillsManager.hpp"
|
||||
|
||||
namespace QodeAssist::Tools {
|
||||
|
||||
SkillTool::SkillTool(Skills::SkillsManager *skillsManager, QObject *parent)
|
||||
: BaseTool(parent)
|
||||
, m_skillsManager(skillsManager)
|
||||
{}
|
||||
|
||||
QString SkillTool::id() const
|
||||
{
|
||||
return "load_skill";
|
||||
}
|
||||
|
||||
QString SkillTool::displayName() const
|
||||
{
|
||||
return "Loading skill";
|
||||
}
|
||||
|
||||
QString SkillTool::description() const
|
||||
{
|
||||
return "Load the full instructions of a skill by name. The Available Skills catalog in "
|
||||
"the system prompt lists each skill's name and a short description. When a request "
|
||||
"matches a skill, call this tool with that skill's name to load its complete "
|
||||
"instructions, then follow them.";
|
||||
}
|
||||
|
||||
QJsonObject SkillTool::parametersSchema() const
|
||||
{
|
||||
QJsonObject properties;
|
||||
properties["name"] = QJsonObject{
|
||||
{"type", "string"},
|
||||
{"description",
|
||||
"Exact name of the skill to load, as shown in the Available Skills catalog."}};
|
||||
|
||||
QJsonObject definition;
|
||||
definition["type"] = "object";
|
||||
definition["properties"] = properties;
|
||||
definition["required"] = QJsonArray{"name"};
|
||||
return definition;
|
||||
}
|
||||
|
||||
QFuture<LLMQore::ToolResult> SkillTool::executeAsync(const QJsonObject &input)
|
||||
{
|
||||
const QString name = input["name"].toString().trimmed();
|
||||
|
||||
const std::optional<Skills::AgentSkill> found
|
||||
= m_skillsManager ? m_skillsManager->findByName(name) : std::nullopt;
|
||||
|
||||
return QtConcurrent::run([name, found]() -> LLMQore::ToolResult {
|
||||
if (name.isEmpty()) {
|
||||
throw LLMQore::ToolInvalidArgument(
|
||||
"'name' parameter is required and cannot be empty");
|
||||
}
|
||||
if (!found) {
|
||||
throw LLMQore::ToolRuntimeError(
|
||||
QString("Unknown skill: '%1'. Use a skill name from the Available Skills "
|
||||
"catalog in the system prompt.")
|
||||
.arg(name));
|
||||
}
|
||||
if (found->body.isEmpty()) {
|
||||
throw LLMQore::ToolRuntimeError(
|
||||
QString("Skill '%1' has no instructions.").arg(found->name));
|
||||
}
|
||||
|
||||
return LLMQore::ToolResult::text(
|
||||
QString("Skill: %1\n\n%2").arg(found->name, found->body));
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Tools
|
||||
35
tools/SkillTool.hpp
Normal file
35
tools/SkillTool.hpp
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <LLMQore/BaseTool.hpp>
|
||||
#include <QFuture>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Tools {
|
||||
|
||||
class SkillTool : public ::LLMQore::BaseTool
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SkillTool(Skills::SkillsManager *skillsManager, QObject *parent = nullptr);
|
||||
|
||||
QString id() const override;
|
||||
QString displayName() const override;
|
||||
QString description() const override;
|
||||
QJsonObject parametersSchema() const override;
|
||||
QFuture<LLMQore::ToolResult> executeAsync(const QJsonObject &input) override;
|
||||
|
||||
private:
|
||||
QPointer<Skills::SkillsManager> m_skillsManager;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Tools
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "ProjectSearchTool.hpp"
|
||||
#include "ReadFileTool.hpp"
|
||||
#include "ReadOriginalHistoryTool.hpp"
|
||||
#include "SkillTool.hpp"
|
||||
#include "TodoTool.hpp"
|
||||
|
||||
namespace QodeAssist::Tools {
|
||||
@@ -66,4 +67,26 @@ void registerQodeAssistTools(::LLMQore::ToolsManager *manager)
|
||||
manager, s.enableReadOriginalHistoryTool, "read_original_history");
|
||||
}
|
||||
|
||||
void registerSkillTool(
|
||||
::LLMQore::ToolsManager *manager, Skills::SkillsManager *skillsManager)
|
||||
{
|
||||
Utils::BoolAspect &aspect = Settings::toolsSettings().enableSkillTool;
|
||||
const QString toolId = QStringLiteral("load_skill");
|
||||
|
||||
auto sync = [manager, toolId, &aspect, skillsManager]() {
|
||||
const bool wanted = aspect.volatileValue();
|
||||
const bool present = manager->tool(toolId) != nullptr;
|
||||
if (wanted && !present) {
|
||||
manager->addTool(new SkillTool(skillsManager, manager));
|
||||
} else if (!wanted && present) {
|
||||
manager->removeTool(toolId);
|
||||
}
|
||||
};
|
||||
|
||||
sync();
|
||||
|
||||
QObject::connect(&aspect, &Utils::BoolAspect::volatileValueChanged, manager, sync);
|
||||
QObject::connect(&aspect, &Utils::BaseAspect::changed, manager, sync);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Tools
|
||||
|
||||
@@ -7,8 +7,15 @@ namespace LLMQore {
|
||||
class ToolsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Tools {
|
||||
|
||||
void registerQodeAssistTools(::LLMQore::ToolsManager *manager);
|
||||
|
||||
void registerSkillTool(
|
||||
::LLMQore::ToolsManager *manager, Skills::SkillsManager *skillsManager);
|
||||
|
||||
} // namespace QodeAssist::Tools
|
||||
|
||||
Reference in New Issue
Block a user