From fb887967ed4373232d4e374a799b80ed97e1f684 Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Tue, 26 May 2026 16:44:45 +0200 Subject: [PATCH] feat: Add agents pipelines --- CMakeLists.txt | 2 + qodeassist.cpp | 7 + settings/SettingsConstants.hpp | 3 + settings/SettingsTr.hpp | 12 + sources/settings/AgentPipelinesPage.cpp | 273 ++++++++++ sources/settings/AgentPipelinesPage.hpp | 39 ++ sources/settings/AgentRosterWidget.cpp | 636 ++++++++++++++++++++++ sources/settings/AgentRosterWidget.hpp | 70 +++ sources/settings/AgentSelectionDialog.cpp | 471 ++++++++++++++++ sources/settings/AgentSelectionDialog.hpp | 182 +++++++ sources/settings/AgentSlotWidget.cpp | 361 ++++++++++++ sources/settings/AgentSlotWidget.hpp | 77 +++ sources/settings/CMakeLists.txt | 38 ++ sources/settings/PipelinesConfig.cpp | 280 ++++++++++ sources/settings/PipelinesConfig.hpp | 47 ++ 15 files changed, 2498 insertions(+) create mode 100644 sources/settings/AgentPipelinesPage.cpp create mode 100644 sources/settings/AgentPipelinesPage.hpp create mode 100644 sources/settings/AgentRosterWidget.cpp create mode 100644 sources/settings/AgentRosterWidget.hpp create mode 100644 sources/settings/AgentSelectionDialog.cpp create mode 100644 sources/settings/AgentSelectionDialog.hpp create mode 100644 sources/settings/AgentSlotWidget.cpp create mode 100644 sources/settings/AgentSlotWidget.hpp create mode 100644 sources/settings/CMakeLists.txt create mode 100644 sources/settings/PipelinesConfig.cpp create mode 100644 sources/settings/PipelinesConfig.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index df7660b..4ee62fc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -174,6 +174,8 @@ add_qtc_plugin(QodeAssist if(QODEASSIST_EXPERIMENTAL) target_compile_definitions(QodeAssist PRIVATE QODEASSIST_EXPERIMENTAL) + add_subdirectory(sources/settings) + target_link_libraries(QodeAssist PRIVATE QodeAssistAgentPipelines) endif() get_target_property(QtCreatorCorePath QtCreator::Core LOCATION) diff --git a/qodeassist.cpp b/qodeassist.cpp index 5665246..3f1a6ae 100644 --- a/qodeassist.cpp +++ b/qodeassist.cpp @@ -56,6 +56,7 @@ #ifdef QODEASSIST_EXPERIMENTAL #include "settings/AgentsSettingsPage.hpp" #include "settings/ProvidersSettingsPage.hpp" +#include "sources/settings/AgentPipelinesPage.hpp" #endif #include "settings/QuickRefactorSettings.hpp" #include "settings/SettingsConstants.hpp" @@ -222,6 +223,10 @@ public: m_agentsPageNavigator = new Settings::AgentsPageNavigator(this); m_agentsOptionsPage = Settings::createAgentsSettingsPage( m_agentFactory, m_agentsPageNavigator); + + m_agentPipelinesPageNavigator = new Settings::AgentPipelinesPageNavigator(this); + m_agentPipelinesOptionsPage = Settings::createAgentPipelinesSettingsPage( + m_agentFactory, m_agentPipelinesPageNavigator, m_agentsPageNavigator); #endif m_mcpServerManager = new Mcp::McpServerManager(this); @@ -527,6 +532,8 @@ private: QPointer m_agentFactory; QPointer m_agentsPageNavigator; std::unique_ptr m_agentsOptionsPage; + QPointer m_agentPipelinesPageNavigator; + std::unique_ptr m_agentPipelinesOptionsPage; #endif }; diff --git a/settings/SettingsConstants.hpp b/settings/SettingsConstants.hpp index 6ceb0ed..363fba8 100644 --- a/settings/SettingsConstants.hpp +++ b/settings/SettingsConstants.hpp @@ -141,6 +141,9 @@ const char QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID[] = "QodeAssist.7ProviderSettin // Agents Settings Page ID const char QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID[] = "QodeAssist.8AgentsSettingsPageId"; +// Agent Pipelines (experimental) settings +const char QODE_ASSIST_AGENT_PIPELINES_PAGE_ID[] = "QodeAssist.9AgentPipelinesPageId"; + // Provider API Keys const char OPEN_ROUTER_API_KEY[] = "QodeAssist.openRouterApiKey"; const char OPEN_ROUTER_API_KEY_HISTORY[] = "QodeAssist.openRouterApiKeyHistory"; diff --git a/settings/SettingsTr.hpp b/settings/SettingsTr.hpp index 82b270e..72f33fd 100644 --- a/settings/SettingsTr.hpp +++ b/settings/SettingsTr.hpp @@ -33,6 +33,18 @@ inline const char *CUSTOM_ENDPOINT = QT_TRANSLATE_NOOP("QtC::QodeAssist", "Custo inline const char *CODE_COMPLETION = QT_TRANSLATE_NOOP("QtC::QodeAssist", "Code Completion"); inline const char *CHAT_ASSISTANT = QT_TRANSLATE_NOOP("QtC::QodeAssist", "Chat Assistant"); inline const char *QUICK_REFACTOR = QT_TRANSLATE_NOOP("QtC::QodeAssist", "Quick Refactor"); +inline const char *CHAT_COMPRESSION = QT_TRANSLATE_NOOP("QtC::QodeAssist", "Chat Compression"); +inline const char *AGENT_PIPELINES = QT_TRANSLATE_NOOP("QtC::QodeAssist", "Agent Pipelines"); +inline const char *SLOT_HINT_CODE_COMPLETION = QT_TRANSLATE_NOOP( + "QtC::QodeAssist", + "Inline completions while you type. Matchers run on every request."); +inline const char *SLOT_HINT_CHAT_ASSISTANT = QT_TRANSLATE_NOOP( + "QtC::QodeAssist", "Conversational assistant in the QodeAssist panel."); +inline const char *SLOT_HINT_CHAT_COMPRESSION = QT_TRANSLATE_NOOP( + "QtC::QodeAssist", + "Used when a chat conversation needs to be summarised to stay within context."); +inline const char *SLOT_HINT_QUICK_REFACTOR = QT_TRANSLATE_NOOP( + "QtC::QodeAssist", "Inline editor-driven refactors via the Quick Refactor action."); inline const char *RESET_SETTINGS = QT_TRANSLATE_NOOP("QtC::QodeAssist", "Reset Settings"); inline const char *CONFIRMATION = QT_TRANSLATE_NOOP( "QtC::QodeAssist", "Are you sure you want to reset all settings to default values?"); diff --git a/sources/settings/AgentPipelinesPage.cpp b/sources/settings/AgentPipelinesPage.cpp new file mode 100644 index 0000000..af75d6e --- /dev/null +++ b/sources/settings/AgentPipelinesPage.cpp @@ -0,0 +1,273 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "AgentPipelinesPage.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../Version.hpp" +#include "AgentRosterWidget.hpp" +#include "AgentsSettingsPage.hpp" +#include "Logger.hpp" +#include "PipelinesConfig.hpp" +#include "SettingsConstants.hpp" +#include "SettingsTr.hpp" + +#include + +namespace QodeAssist::Settings { + +AgentPipelinesPageNavigator::AgentPipelinesPageNavigator(QObject *parent) + : QObject(parent) +{} + +namespace { + +constexpr int kSaveDebounceMs = 300; + +struct SlotMeta +{ + const char *title; + const char *hint; +}; + +const SlotMeta kSlotMeta[] = { + {TrConstants::CODE_COMPLETION, TrConstants::SLOT_HINT_CODE_COMPLETION}, + {TrConstants::CHAT_ASSISTANT, TrConstants::SLOT_HINT_CHAT_ASSISTANT}, + {TrConstants::CHAT_COMPRESSION, TrConstants::SLOT_HINT_CHAT_COMPRESSION}, + {TrConstants::QUICK_REFACTOR, TrConstants::SLOT_HINT_QUICK_REFACTOR}, +}; + +class AgentPipelinesPageWidget : public Core::IOptionsPageWidget +{ + Q_OBJECT + Q_DISABLE_COPY_MOVE(AgentPipelinesPageWidget) +public: + AgentPipelinesPageWidget( + const QPointer &agentFactory, + const QPointer &navigator, + const QPointer &agentsNavigator) + : m_agentFactory(agentFactory) + , m_navigator(navigator) + , m_agentsNavigator(agentsNavigator) + { + m_titleLabel = new QLabel(Tr::tr(TrConstants::AGENT_PIPELINES), this); + QFont tf = m_titleLabel->font(); + tf.setBold(true); + tf.setPixelSize(13); + m_titleLabel->setFont(tf); + + m_resetBtn = new QPushButton(Tr::tr(TrConstants::RESET_TO_DEFAULTS), this); + + auto *headerRow = new QHBoxLayout; + headerRow->setContentsMargins(0, 0, 0, 0); + headerRow->setSpacing(8); + headerRow->addWidget(m_titleLabel); + headerRow->addStretch(1); + headerRow->addWidget(m_resetBtn); + + auto *headerSep = new QFrame(this); + headerSep->setFrameShape(QFrame::HLine); + headerSep->setFrameShadow(QFrame::Sunken); + + m_rosters[0] = new AgentRosterWidget(this); + m_rosters[1] = new AgentRosterWidget(this); + m_rosters[2] = new AgentRosterWidget(this); + m_rosters[3] = new AgentRosterWidget(this); + + for (int i = 0; i < kRosterCount; ++i) + m_rosters[i]->setSlot(Tr::tr(kSlotMeta[i].title), Tr::tr(kSlotMeta[i].hint), {}); + + auto *content = new QWidget(this); + auto *contentLay = new QVBoxLayout(content); + contentLay->setContentsMargins(0, 0, 0, 0); + contentLay->setSpacing(12); + for (int i = 0; i < kRosterCount; ++i) + contentLay->addWidget(m_rosters[i]); + contentLay->addStretch(1); + + auto *scroll = new QScrollArea(this); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + scroll->setWidget(content); + + auto *root = new QVBoxLayout(this); + root->setContentsMargins(8, 8, 8, 8); + root->setSpacing(6); + root->addLayout(headerRow); + root->addWidget(headerSep); + root->addWidget(scroll, 1); + + m_saveDebounce = new QTimer(this); + m_saveDebounce->setSingleShot(true); + m_saveDebounce->setInterval(kSaveDebounceMs); + connect(m_saveDebounce, &QTimer::timeout, this, [this]() { persistRosters(); }); + + loadFromSettings(); + + connect(m_resetBtn, &QPushButton::clicked, this, &AgentPipelinesPageWidget::onReset); + + for (int i = 0; i < kRosterCount; ++i) { + connect(m_rosters[i], &AgentRosterWidget::editAgentRequested, this, + &AgentPipelinesPageWidget::onEditAgent); + connect(m_rosters[i], &AgentRosterWidget::rosterChanged, this, + [this](const QStringList &) { m_saveDebounce->start(); }); + } + } + + ~AgentPipelinesPageWidget() override + { + if (m_saveDebounce && m_saveDebounce->isActive()) { + m_saveDebounce->stop(); + persistRosters(); + } + } + + void apply() final + { + if (m_saveDebounce && m_saveDebounce->isActive()) + m_saveDebounce->stop(); + persistRosters(); + } + +private: + static constexpr int kRosterCount = 4; + + void persistRosters() + { + PipelineRosters rosters; + rosters.codeCompletion = m_rosters[0]->roster(); + rosters.chatAssistant = m_rosters[1]->roster(); + rosters.chatCompression = m_rosters[2]->roster(); + rosters.quickRefactor = m_rosters[3]->roster(); + QString err; + if (!PipelinesConfig::save(rosters, &err)) { + LOG_MESSAGE(QStringLiteral("[Pipelines] save failed (%1): %2") + .arg(PipelinesConfig::filePath(), err)); + if (!m_saveErrorShown) { + m_saveErrorShown = true; + QMessageBox::warning( + Core::ICore::dialogParent(), + Tr::tr(TrConstants::AGENT_PIPELINES), + tr("Failed to save pipelines.toml:\n%1\n\n" + "Further save failures will only be logged.") + .arg(err)); + } + } else { + m_saveErrorShown = false; + } + } + + void onReset() + { + const auto reply = QMessageBox::question( + Core::ICore::dialogParent(), + Tr::tr(TrConstants::RESET_SETTINGS), + Tr::tr(TrConstants::CONFIRMATION), + QMessageBox::Yes | QMessageBox::No); + + if (reply != QMessageBox::Yes) + return; + + QString err; + if (!PipelinesConfig::save(PipelineRosters::defaults(), &err)) + LOG_MESSAGE(QStringLiteral("[Pipelines] failed to reset rosters: %1").arg(err)); + + m_saveErrorShown = false; + loadFromSettings(); + } + + void onEditAgent(const QString &name) + { + if (m_agentsNavigator) + m_agentsNavigator->requestSelectAgent(name); + if (m_navigator) + emit m_navigator->editAgentRequested(name); + +#if QODEASSIST_QT_CREATOR_VERSION >= QT_VERSION_CHECK(18, 0, 83) + Core::ICore::showSettings(Constants::QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID); +#else + Core::ICore::showOptionsDialog(Constants::QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID); +#endif + } + + void loadFromSettings() + { + const PipelinesLoadResult lr = PipelinesConfig::load(); + if (lr.status == PipelinesLoadStatus::ParseError + || lr.status == PipelinesLoadStatus::SchemaError) { + QMessageBox::warning( + Core::ICore::dialogParent(), + Tr::tr(TrConstants::AGENT_PIPELINES), + tr("pipelines.toml has issues — using defaults for affected entries:\n%1\n\n" + "Click OK to continue. Changes you make here will overwrite the file.") + .arg(lr.message)); + } + + AgentFactory *factory = m_agentFactory.data(); + m_rosters[0]->setRoster(lr.rosters.codeCompletion, factory); + m_rosters[1]->setRoster(lr.rosters.chatAssistant, factory); + m_rosters[2]->setRoster(lr.rosters.chatCompression, factory); + m_rosters[3]->setRoster(lr.rosters.quickRefactor, factory); + } + + QPointer m_agentFactory; + QPointer m_navigator; + QPointer m_agentsNavigator; + + QLabel *m_titleLabel = nullptr; + QPushButton *m_resetBtn = nullptr; + + AgentRosterWidget *m_rosters[kRosterCount] = {}; + + QTimer *m_saveDebounce = nullptr; + bool m_saveErrorShown = false; +}; + +class AgentPipelinesOptionsPage final : public Core::IOptionsPage +{ +public: + AgentPipelinesOptionsPage( + AgentFactory *agentFactory, + AgentPipelinesPageNavigator *navigator, + AgentsPageNavigator *agentsNavigator) + { + setId(Constants::QODE_ASSIST_AGENT_PIPELINES_PAGE_ID); + setDisplayName(Tr::tr(TrConstants::AGENT_PIPELINES)); + setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY); + const QPointer factoryPtr(agentFactory); + const QPointer navPtr(navigator); + const QPointer agentsNavPtr(agentsNavigator); + setWidgetCreator([factoryPtr, navPtr, agentsNavPtr] { + return new AgentPipelinesPageWidget(factoryPtr, navPtr, agentsNavPtr); + }); + } +}; + +} // namespace + +std::unique_ptr createAgentPipelinesSettingsPage( + AgentFactory *agentFactory, + AgentPipelinesPageNavigator *navigator, + AgentsPageNavigator *agentsNavigator) +{ + return std::make_unique( + agentFactory, navigator, agentsNavigator); +} + +} // namespace QodeAssist::Settings + +#include "AgentPipelinesPage.moc" diff --git a/sources/settings/AgentPipelinesPage.hpp b/sources/settings/AgentPipelinesPage.hpp new file mode 100644 index 0000000..e43d186 --- /dev/null +++ b/sources/settings/AgentPipelinesPage.hpp @@ -0,0 +1,39 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include + +namespace Core { +class IOptionsPage; +} + +namespace QodeAssist { +class AgentFactory; +} + +namespace QodeAssist::Settings { + +class AgentsPageNavigator; + +class AgentPipelinesPageNavigator : public QObject +{ + Q_OBJECT + Q_DISABLE_COPY_MOVE(AgentPipelinesPageNavigator) +public: + explicit AgentPipelinesPageNavigator(QObject *parent = nullptr); + +signals: + void editAgentRequested(const QString &agentName); +}; + +std::unique_ptr createAgentPipelinesSettingsPage( + AgentFactory *agentFactory, + AgentPipelinesPageNavigator *navigator, + AgentsPageNavigator *agentsNavigator); + +} // namespace QodeAssist::Settings diff --git a/sources/settings/AgentRosterWidget.cpp b/sources/settings/AgentRosterWidget.cpp new file mode 100644 index 0000000..3cbdb5a --- /dev/null +++ b/sources/settings/AgentRosterWidget.cpp @@ -0,0 +1,636 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "AgentRosterWidget.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include "AgentSelectionDialog.hpp" +#include "SettingsTr.hpp" + +namespace QodeAssist::Settings { + +namespace { + +enum class PillKind { + Template, + On, // capability on (thinking/tools) + Off, // capability off ("plain") + User, // user-defined agent + Active, // ✓ active + Match, // matched-this-row chip background + Tag, // free-form discoverability tag from AgentConfig::tags + Neutral, +}; + +struct Theme +{ + bool dark = false; + + QColor pageBg; + QColor cardBg; + QColor cardBorder; + QColor groupBorder; + QColor rowSeparator; + QColor rowMatchBg; + QColor listHeader; + + QColor text; + QColor textSoft; + QColor textMute; + QColor textFaint; + + QColor matchChipBg; + QColor matchChipBorder; + QColor matchChipText; + + QColor codeBg; + QColor codeBorder; + + struct Pill + { + QColor bg, fg, border; + }; + Pill pill(PillKind k) const; +}; + +Theme::Pill Theme::pill(PillKind k) const +{ + if (dark) { + switch (k) { + case PillKind::Template: return {{0x2c, 0x3f, 0x5a}, {0xcf, 0xe1, 0xf7}, {0x4a, 0x62, 0x86}}; + case PillKind::On: return {{0x2f, 0x45, 0x30}, {0xbc, 0xe0, 0xbd}, {0x4a, 0x6c, 0x4b}}; + case PillKind::Off: return {{0x3a, 0x3a, 0x3a}, {0x8a, 0x8a, 0x8a}, {0x4a, 0x4a, 0x4a}}; + case PillKind::User: return {{0x4a, 0x3f, 0x24}, {0xe6, 0xcd, 0x92}, {0x6a, 0x5a, 0x30}}; + case PillKind::Active: return {{0x3a, 0x6a, 0x28}, {0xff, 0xff, 0xff}, {0x4a, 0x80, 0x38}}; + case PillKind::Match: return {{0x27, 0x38, 0x4a}, {0xbd, 0xd7, 0xee}, {0x3f, 0x5a, 0x78}}; + case PillKind::Tag: return {{0x2e, 0x2e, 0x3a}, {0xb9, 0xb9, 0xcf}, {0x46, 0x46, 0x5a}}; + case PillKind::Neutral: return {{0x3a, 0x3a, 0x3a}, {0xcf, 0xcf, 0xcf}, {0x4a, 0x4a, 0x4a}}; + } + } else { + switch (k) { + case PillKind::Template: return {{0xdb, 0xe7, 0xf6}, {0x1f, 0x3f, 0x73}, {0xa8, 0xc1, 0xe0}}; + case PillKind::On: return {{0xdb, 0xe9, 0xd3}, {0x2c, 0x5a, 0x1c}, {0xa3, 0xbc, 0x97}}; + case PillKind::Off: return {{0xec, 0xec, 0xec}, {0x7a, 0x7a, 0x7a}, {0xc8, 0xc8, 0xc8}}; + case PillKind::User: return {{0xf0, 0xe4, 0xcf}, {0x75, 0x54, 0x1a}, {0xcd, 0xb9, 0x8a}}; + case PillKind::Active: return {{0x2c, 0x5a, 0x1c}, {0xff, 0xff, 0xff}, {0x2c, 0x5a, 0x1c}}; + case PillKind::Match: return {{0xd6, 0xe8, 0xf7}, {0x1a, 0x4a, 0x7a}, {0x8a, 0xb1, 0xd5}}; + case PillKind::Tag: return {{0xe7, 0xe7, 0xf2}, {0x46, 0x46, 0x6e}, {0xc1, 0xc1, 0xd5}}; + case PillKind::Neutral: return {{0xe3, 0xe3, 0xe3}, {0x3a, 0x3a, 0x3a}, {0xbc, 0xbc, 0xbc}}; + } + } + return {{0, 0, 0}, {0, 0, 0}, {0, 0, 0}}; +} + +Theme themeFor(const QWidget *w) +{ + const QPalette pal = w ? w->palette() : QApplication::palette(); + const bool dark = pal.color(QPalette::Window).lightness() < 128; + Theme t; + t.dark = dark; + if (dark) { + t.pageBg = {0x2b, 0x2b, 0x2b}; + t.cardBg = {0x2f, 0x2f, 0x2f}; + t.cardBorder = {0x4a, 0x4a, 0x4a}; + t.groupBorder = {0x4a, 0x4a, 0x4a}; + t.rowSeparator = {0x3a, 0x3a, 0x3a}; + t.rowMatchBg = {0x2d, 0x3d, 0x24}; + t.listHeader = {0x25, 0x25, 0x25}; + t.text = {0xe6, 0xe6, 0xe6}; + t.textSoft = {0xc2, 0xc2, 0xc2}; + t.textMute = {0x9a, 0x9a, 0x9a}; + t.textFaint = {0x7a, 0x7a, 0x7a}; + t.matchChipBg = {0x26, 0x26, 0x26}; + t.matchChipBorder = {0x3a, 0x3a, 0x3a}; + t.matchChipText = {0xc2, 0xc2, 0xc2}; + t.codeBg = {0x26, 0x26, 0x26}; + t.codeBorder = {0x3a, 0x3a, 0x3a}; + } else { + t.pageBg = {0xed, 0xed, 0xed}; + t.cardBg = {0xf8, 0xf8, 0xf8}; + t.cardBorder = {0xbd, 0xbd, 0xbd}; + t.groupBorder = {0xb8, 0xb8, 0xb8}; + t.rowSeparator = {0xe0, 0xe0, 0xe0}; + t.rowMatchBg = {0xe8, 0xf3, 0xdf}; + t.listHeader = {0xe8, 0xe8, 0xe8}; + t.text = {0x1c, 0x1c, 0x1c}; + t.textSoft = {0x3a, 0x3a, 0x3a}; + t.textMute = {0x5a, 0x5a, 0x5a}; + t.textFaint = {0x8a, 0x8a, 0x8a}; + t.matchChipBg = {0xea, 0xea, 0xea}; + t.matchChipBorder = {0xcf, 0xcf, 0xcf}; + t.matchChipText = {0x3a, 0x3a, 0x3a}; + t.codeBg = {0xea, 0xea, 0xea}; + t.codeBorder = {0xcf, 0xcf, 0xcf}; + } + return t; +} + +QString hex(const QColor &c) +{ + return c.name(QColor::HexRgb); +} + +QString pillStyle(const Theme &t, PillKind k) +{ + const auto p = t.pill(k); + return QStringLiteral( + "background:%1; color:%2; border:1px solid %3;" + "padding:0px 5px; border-radius:2px;" + "font-size:10px;") + .arg(hex(p.bg), hex(p.fg), hex(p.border)); +} + +QLabel *makePill(const QString &text, PillKind kind, const Theme &t, QWidget *parent = nullptr) +{ + auto *l = new QLabel(text, parent); + l->setStyleSheet(pillStyle(t, kind)); + l->setAlignment(Qt::AlignCenter); + return l; +} + +struct MatchSummary +{ + QString icon; + QString kind; + QString value; +}; + +MatchSummary summarise(const AgentConfig::Match &m) +{ + if (m.isEmpty()) + return {QStringLiteral("·"), AgentRosterWidget::tr("fallback"), + AgentRosterWidget::tr("any file")}; + if (!m.filePatterns.isEmpty()) + return {QStringLiteral("*"), AgentRosterWidget::tr("path"), + m.filePatterns.join(QStringLiteral(", "))}; + if (!m.pathPatterns.isEmpty()) + return {QStringLiteral("*"), AgentRosterWidget::tr("path"), + m.pathPatterns.join(QStringLiteral(", "))}; + if (!m.projectNames.isEmpty()) + return {QStringLiteral("▣"), AgentRosterWidget::tr("project"), + m.projectNames.join(QStringLiteral(", "))}; + return {QStringLiteral("·"), AgentRosterWidget::tr("any"), {}}; +} + +} // namespace + +class AgentRosterRow : public QFrame +{ + Q_OBJECT +public: + AgentRosterRow(int index, + const QString &name, + const AgentConfig *cfg, + bool active, + bool first, + bool last, + const Theme &theme, + QWidget *parent = nullptr); + +signals: + void moveUpRequested(int index); + void moveDownRequested(int index); + void editRequested(int index); + void removeRequested(int index); + +private: + QWidget *buildIdentityLine(const QString &displayName, + const QString &model, + bool active, + bool isUser, + const Theme &t); + QWidget *buildMetaLine(const AgentConfig *cfg, bool active, const Theme &t); + QWidget *buildActions(const Theme &t, bool first, bool last); + + int m_index; +}; + +AgentRosterRow::AgentRosterRow(int index, + const QString &name, + const AgentConfig *cfg, + bool active, + bool first, + bool last, + const Theme &theme, + QWidget *parent) + : QFrame(parent), m_index(index) +{ + setAutoFillBackground(true); + QPalette pal = palette(); + pal.setColor(QPalette::Window, active ? theme.rowMatchBg : theme.cardBg); + setPalette(pal); + + setStyleSheet(QStringLiteral("AgentRosterRow { border:0; border-top:%1; }") + .arg(index == 0 ? QStringLiteral("0px") + : QStringLiteral("1px solid %1").arg(hex(theme.rowSeparator)))); + + auto *outer = new QHBoxLayout(this); + outer->setContentsMargins(8, 6, 8, 6); + outer->setSpacing(8); + + auto *moveCol = new QVBoxLayout; + moveCol->setContentsMargins(0, 0, 0, 0); + moveCol->setSpacing(1); + auto makeArrow = [&](const QString &glyph, const QString &tip, bool enabled) { + auto *b = new QToolButton(this); + b->setText(glyph); + b->setToolTip(tip); + b->setEnabled(enabled); + b->setAutoRaise(true); + b->setFixedSize(18, 14); + QFont f = b->font(); + f.setPointSizeF(f.pointSizeF() * 0.75); + b->setFont(f); + return b; + }; + auto *upBtn = makeArrow(QStringLiteral("▲"), tr("Move up"), !first); + auto *dnBtn = makeArrow(QStringLiteral("▼"), tr("Move down"), !last); + moveCol->addWidget(upBtn); + moveCol->addWidget(dnBtn); + outer->addLayout(moveCol); + + auto *idxLbl = new QLabel(QStringLiteral("%1.").arg(index + 1), this); + QFont monoFont = QFontDatabase::systemFont(QFontDatabase::FixedFont); + idxLbl->setFont(monoFont); + QPalette idxPal = idxLbl->palette(); + idxPal.setColor(QPalette::WindowText, active ? theme.text : theme.textFaint); + idxLbl->setPalette(idxPal); + idxLbl->setFixedWidth(22); + idxLbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + outer->addWidget(idxLbl); + + auto *body = new QVBoxLayout; + body->setContentsMargins(0, 0, 0, 0); + body->setSpacing(2); + + const QString displayName = cfg ? cfg->name : tr("%1 (missing)").arg(name); + const QString model = cfg ? cfg->model : QString(); + const bool isUser = cfg && cfg->isUserSource(); + + body->addWidget(buildIdentityLine(displayName, model, active, isUser, theme)); + body->addWidget(buildMetaLine(cfg, active, theme)); + outer->addLayout(body, /*stretch*/ 1); + + outer->addWidget(buildActions(theme, first, last)); + + if (cfg && !cfg->description.isEmpty()) + setToolTip(cfg->description); + + connect(upBtn, &QToolButton::clicked, this, [this]() { emit moveUpRequested(m_index); }); + connect(dnBtn, &QToolButton::clicked, this, [this]() { emit moveDownRequested(m_index); }); +} + +QWidget *AgentRosterRow::buildIdentityLine(const QString &displayName, + const QString &model, + bool active, + bool isUser, + const Theme &t) +{ + auto *w = new QWidget(this); + auto *line = new QHBoxLayout(w); + line->setContentsMargins(0, 0, 0, 0); + line->setSpacing(6); + + auto *nameLbl = new QLabel(displayName, w); + QFont nameFont = nameLbl->font(); + nameFont.setBold(true); + nameLbl->setFont(nameFont); + line->addWidget(nameLbl); + + if (!model.isEmpty()) { + auto *modelLbl = new QLabel(model, w); + modelLbl->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + QPalette mp = modelLbl->palette(); + mp.setColor(QPalette::WindowText, t.textSoft); + modelLbl->setPalette(mp); + line->addWidget(modelLbl); + } + + if (active) + line->addWidget(makePill(QStringLiteral("✓ ") + tr("active"), PillKind::Active, t, w)); + if (isUser) + line->addWidget(makePill(tr("user"), PillKind::User, t, w)); + + line->addStretch(1); + return w; +} + +QWidget *AgentRosterRow::buildMetaLine(const AgentConfig *cfg, bool active, const Theme &t) +{ + auto *w = new QWidget(this); + auto *line = new QHBoxLayout(w); + line->setContentsMargins(0, 0, 0, 0); + line->setSpacing(4); + + if (cfg) { + const auto sm = summarise(cfg->match); + auto *chip = new QLabel(w); + const QColor bg = active ? t.pill(PillKind::Match).bg : t.matchChipBg; + const QColor bd = active ? t.pill(PillKind::Match).border : t.matchChipBorder; + const QColor fg = active ? t.pill(PillKind::Match).fg : t.matchChipText; + QString chipText = QStringLiteral( + "%1 " + "%3: %4") + .arg(sm.icon, + hex(active ? t.pill(PillKind::Match).fg : t.textFaint), + sm.kind, + sm.value.toHtmlEscaped()); + chip->setTextFormat(Qt::RichText); + chip->setText(chipText); + chip->setStyleSheet(QStringLiteral("background:%1; color:%2; border:1px solid %3;" + "padding:0px 6px; border-radius:2px; font-size:11px;") + .arg(hex(bg), hex(fg), hex(bd))); + chip->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + line->addWidget(chip); + + auto *arrow = new QLabel(QStringLiteral("→"), w); + QPalette ap = arrow->palette(); + ap.setColor(QPalette::WindowText, t.textFaint); + arrow->setPalette(ap); + line->addWidget(arrow); + + if (!cfg->providerInstance.isEmpty()) + line->addWidget(makePill(cfg->providerInstance, PillKind::Template, t, w)); + + constexpr int kMaxTagPills = 3; + const int tagCount = cfg->tags.size(); + for (int i = 0; i < std::min(tagCount, kMaxTagPills); ++i) + line->addWidget(makePill(cfg->tags.at(i), PillKind::Tag, t, w)); + if (tagCount > kMaxTagPills) { + auto *more = makePill(QStringLiteral("+%1").arg(tagCount - kMaxTagPills), + PillKind::Tag, t, w); + more->setToolTip(cfg->tags.mid(kMaxTagPills).join(QStringLiteral(", "))); + line->addWidget(more); + } + } else { + auto *missing = new QLabel(tr("agent configuration is no longer available"), w); + QPalette mp = missing->palette(); + mp.setColor(QPalette::WindowText, t.textMute); + missing->setPalette(mp); + line->addWidget(missing); + } + line->addStretch(1); + return w; +} + +QWidget *AgentRosterRow::buildActions(const Theme &, bool /*first*/, bool /*last*/) +{ + auto *w = new QWidget(this); + auto *l = new QHBoxLayout(w); + l->setContentsMargins(0, 0, 0, 0); + l->setSpacing(4); + + auto *edit = new QToolButton(w); + edit->setText(tr("Edit…")); + edit->setToolButtonStyle(Qt::ToolButtonTextOnly); + edit->setAutoRaise(false); + edit->setFocusPolicy(Qt::TabFocus); + edit->setMinimumHeight(22); + + auto *remove = new QToolButton(w); + remove->setText(QStringLiteral("✕")); + remove->setToolTip(tr("Remove from list")); + remove->setAutoRaise(false); + remove->setFixedSize(22, 22); + + l->addWidget(edit); + l->addWidget(remove); + + connect(edit, &QToolButton::clicked, this, [this]() { emit editRequested(m_index); }); + connect(remove, &QToolButton::clicked, this, [this]() { emit removeRequested(m_index); }); + + return w; +} + +AgentRosterWidget::AgentRosterWidget(QWidget *parent) + : QWidget(parent) +{ + const Theme t = themeFor(this); + + auto *outer = new QVBoxLayout(this); + outer->setContentsMargins(0, 0, 0, 0); + outer->setSpacing(6); + + auto *titleRow = new QHBoxLayout; + titleRow->setContentsMargins(0, 0, 0, 0); + titleRow->setSpacing(6); + m_accentDot = new QLabel(this); + m_accentDot->setFixedSize(10, 10); + m_titleLabel = new QLabel(this); + QFont tf = m_titleLabel->font(); + tf.setBold(true); + m_titleLabel->setFont(tf); + titleRow->addWidget(m_accentDot); + titleRow->addWidget(m_titleLabel); + titleRow->addStretch(1); + outer->addLayout(titleRow); + + m_hintLabel = new QLabel(this); + m_hintLabel->setWordWrap(true); + QPalette hp = m_hintLabel->palette(); + hp.setColor(QPalette::WindowText, t.textMute); + m_hintLabel->setPalette(hp); + QFont hf = m_hintLabel->font(); + hf.setPointSizeF(hf.pointSizeF() * 0.9); + m_hintLabel->setFont(hf); + outer->addWidget(m_hintLabel); + + m_rowsFrame = new QFrame(this); + m_rowsFrame->setObjectName(QStringLiteral("rosterCard")); + m_rowsFrame->setStyleSheet( + QStringLiteral("QFrame#rosterCard { background:%1; border:1px solid %2; border-radius:2px; }") + .arg(hex(t.cardBg), hex(t.cardBorder))); + m_rowsLayout = new QVBoxLayout(m_rowsFrame); + m_rowsLayout->setContentsMargins(0, 0, 0, 0); + m_rowsLayout->setSpacing(0); + + m_emptyHint = new QLabel(tr("No agents in roster. Click \"Add agent…\" to populate."), + m_rowsFrame); + m_emptyHint->setAlignment(Qt::AlignCenter); + m_emptyHint->setContentsMargins(10, 12, 10, 12); + QFont eh = m_emptyHint->font(); + eh.setItalic(true); + m_emptyHint->setFont(eh); + QPalette ep = m_emptyHint->palette(); + ep.setColor(QPalette::WindowText, t.textFaint); + m_emptyHint->setPalette(ep); + m_rowsLayout->addWidget(m_emptyHint); + + outer->addWidget(m_rowsFrame); + + auto *footer = new QHBoxLayout; + footer->setContentsMargins(0, 0, 0, 0); + footer->setSpacing(6); + m_addBtn = new QPushButton(tr("+ Add agent…"), this); + footer->addWidget(m_addBtn); + footer->addStretch(1); + m_footerHint = new QLabel(tr("first matching agent is used"), this); + QPalette fp = m_footerHint->palette(); + fp.setColor(QPalette::WindowText, t.textMute); + m_footerHint->setPalette(fp); + QFont ff = m_footerHint->font(); + ff.setPointSizeF(ff.pointSizeF() * 0.9); + m_footerHint->setFont(ff); + footer->addWidget(m_footerHint); + outer->addLayout(footer); + + connect(m_addBtn, &QPushButton::clicked, this, &AgentRosterWidget::onAddClicked); +} + +void AgentRosterWidget::setSlot(const QString &title, const QString &hint, const QColor &accent) +{ + m_titleLabel->setText(title); + m_hintLabel->setText(hint); + m_hintLabel->setVisible(!hint.isEmpty()); + if (accent.isValid()) { + m_accentDot->setStyleSheet( + QStringLiteral("background:%1; border:1px solid %2;") + .arg(accent.name(), accent.darker(140).name())); + m_accentDot->setVisible(true); + } else { + m_accentDot->setStyleSheet({}); + m_accentDot->setVisible(false); + } +} + +void AgentRosterWidget::setRoster(const QStringList &names, AgentFactory *factory) +{ + m_factory = factory; + m_names = names; + rebuildRows(); +} + +void AgentRosterWidget::setRoutingContext(const AgentRouter::Context &ctx) +{ + m_routingCtx = ctx; + recomputeActive(); + rebuildRows(); +} + +void AgentRosterWidget::recomputeActive() +{ + if (!m_factory || m_names.isEmpty() + || (m_routingCtx.filePath.isEmpty() && m_routingCtx.projectName.isEmpty())) { + m_activeIndex = -1; + return; + } + const QString picked = AgentRouter::pickAgent(m_names, m_routingCtx, *m_factory); + m_activeIndex = picked.isEmpty() ? -1 : m_names.indexOf(picked); +} + +void AgentRosterWidget::rebuildRows() +{ + // Tear down existing row widgets (keep the empty hint). + while (m_rowsLayout->count() > 0) { + QLayoutItem *it = m_rowsLayout->takeAt(0); + if (auto *w = it->widget()) { + if (w == m_emptyHint) + w->setVisible(false); + else + w->deleteLater(); + } + delete it; + } + + if (m_names.isEmpty()) { + m_emptyHint->setVisible(true); + m_rowsLayout->addWidget(m_emptyHint); + return; + } + + recomputeActive(); + const Theme t = themeFor(this); + + for (int i = 0; i < m_names.size(); ++i) { + const QString &name = m_names.at(i); + const AgentConfig *cfg = m_factory ? m_factory->configByName(name) : nullptr; + auto *row = new AgentRosterRow(i, + name, + cfg, + i == m_activeIndex, + /*first*/ i == 0, + /*last*/ i == m_names.size() - 1, + t, + m_rowsFrame); + connect(row, &AgentRosterRow::moveUpRequested, this, &AgentRosterWidget::onRowMoveUp); + connect(row, &AgentRosterRow::moveDownRequested, this, &AgentRosterWidget::onRowMoveDown); + connect(row, &AgentRosterRow::editRequested, this, &AgentRosterWidget::onRowEdit); + connect(row, &AgentRosterRow::removeRequested, this, &AgentRosterWidget::onRowRemove); + m_rowsLayout->addWidget(row); + } +} + +void AgentRosterWidget::onAddClicked() +{ + if (!m_factory) + return; + + AgentSelectionDialog dialog(m_factory->configs(), + /*currentName*/ QString{}, + m_factory.data(), + Core::ICore::dialogParent()); + if (dialog.exec() != QDialog::Accepted) + return; + const QString picked = dialog.selectedName(); + if (picked.isEmpty() || m_names.contains(picked)) + return; + + m_names.append(picked); + rebuildRows(); + emit rosterChanged(m_names); +} + +void AgentRosterWidget::onRowMoveUp(int index) +{ + if (index <= 0 || index >= m_names.size()) + return; + m_names.swapItemsAt(index, index - 1); + rebuildRows(); + emit rosterChanged(m_names); +} + +void AgentRosterWidget::onRowMoveDown(int index) +{ + if (index < 0 || index >= m_names.size() - 1) + return; + m_names.swapItemsAt(index, index + 1); + rebuildRows(); + emit rosterChanged(m_names); +} + +void AgentRosterWidget::onRowRemove(int index) +{ + if (index < 0 || index >= m_names.size()) + return; + m_names.removeAt(index); + rebuildRows(); + emit rosterChanged(m_names); +} + +void AgentRosterWidget::onRowEdit(int index) +{ + if (index < 0 || index >= m_names.size()) + return; + emit editAgentRequested(m_names.at(index)); +} + +} // namespace QodeAssist::Settings + +#include "AgentRosterWidget.moc" diff --git a/sources/settings/AgentRosterWidget.hpp b/sources/settings/AgentRosterWidget.hpp new file mode 100644 index 0000000..e424eb8 --- /dev/null +++ b/sources/settings/AgentRosterWidget.hpp @@ -0,0 +1,70 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +#include + +class QLabel; +class QToolButton; +class QPushButton; +class QVBoxLayout; +class QFrame; + +namespace QodeAssist { +class AgentFactory; +} + +namespace QodeAssist::Settings { + +class AgentRosterRow; + +class AgentRosterWidget : public QWidget +{ + Q_OBJECT +public: + explicit AgentRosterWidget(QWidget *parent = nullptr); + + void setSlot(const QString &title, const QString &hint, const QColor &accent); + void setRoster(const QStringList &names, AgentFactory *factory); + + [[nodiscard]] QStringList roster() const { return m_names; } + + void setRoutingContext(const AgentRouter::Context &ctx); + +signals: + void rosterChanged(const QStringList &names); + void editAgentRequested(const QString &agentName); + +private: + void rebuildRows(); + void recomputeActive(); + + void onAddClicked(); + void onRowMoveUp(int index); + void onRowMoveDown(int index); + void onRowRemove(int index); + void onRowEdit(int index); + + QStringList m_names; + QPointer m_factory; + AgentRouter::Context m_routingCtx; + int m_activeIndex = -1; + + QLabel *m_accentDot = nullptr; + QLabel *m_titleLabel = nullptr; + QLabel *m_hintLabel = nullptr; + QFrame *m_rowsFrame = nullptr; + QVBoxLayout *m_rowsLayout = nullptr; + QLabel *m_emptyHint = nullptr; + QPushButton *m_addBtn = nullptr; + QLabel *m_footerHint = nullptr; +}; + +} // namespace QodeAssist::Settings diff --git a/sources/settings/AgentSelectionDialog.cpp b/sources/settings/AgentSelectionDialog.cpp new file mode 100644 index 0000000..6e21ddd --- /dev/null +++ b/sources/settings/AgentSelectionDialog.cpp @@ -0,0 +1,471 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "AgentSelectionDialog.hpp" + +#include "AgentSlotWidget.hpp" +#include "PipelinesConfig.hpp" +#include "SettingsTr.hpp" + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace QodeAssist::Settings { + +// -- ListRowCard ------------------------------------------------------- + +ListRowCard::ListRowCard(QWidget *parent) + : QFrame(parent) +{ + setObjectName(QStringLiteral("ListRowCard")); + setAttribute(Qt::WA_StyledBackground, true); + setCursor(Qt::PointingHandCursor); + setFrameShape(QFrame::NoFrame); + applyTheme(); +} + +bool ListRowCard::matches(const QString &needle) const +{ + if (needle.isEmpty()) + return true; + return m_searchHaystack.contains(needle.toLower()); +} + +void ListRowCard::setSelected(bool selected) +{ + if (m_selected == selected) + return; + m_selected = selected; + applyTheme(); +} + +void ListRowCard::buildSearchHaystack(const QStringList &parts) +{ + m_searchHaystack = parts.join(QLatin1Char(' ')).toLower(); +} + +void ListRowCard::mousePressEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) + emit clicked(); + QFrame::mousePressEvent(event); +} + +void ListRowCard::mouseDoubleClickEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) + emit activated(); + QFrame::mouseDoubleClickEvent(event); +} + +void ListRowCard::enterEvent(QEnterEvent *event) +{ + QFrame::enterEvent(event); + m_hover = true; + applyTheme(); +} + +void ListRowCard::leaveEvent(QEvent *event) +{ + QFrame::leaveEvent(event); + m_hover = false; + applyTheme(); +} + +void ListRowCard::changeEvent(QEvent *event) +{ + QFrame::changeEvent(event); + if (m_inApplyTheme) + return; + if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange) + applyTheme(); +} + +void ListRowCard::applyTheme() +{ + if (m_inApplyTheme) + return; + QScopedValueRollback guard(m_inApplyTheme, true); + + const auto t = CardStyle::toneFor(CardStyle::isDark(palette())); + QString bg = t.bg; + QString bd = t.cardBd; + if (m_selected) { + bg = t.selectedBg; + bd = t.selectedBd; + } else if (m_hover) { + bg = t.hoverBg; + } + setStyleSheet(QStringLiteral( + "#ListRowCard { background-color: %1; border: 1px solid %2; }") + .arg(bg, bd)); +} + +// -- AgentRowCard ------------------------------------------------------ + +AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent) + : ListRowCard(parent) +{ + setItemName(cfg.name); + QStringList haystack{cfg.name, cfg.providerInstance, cfg.model, + cfg.description, cfg.role, + cfg.endpoint}; + haystack += cfg.tags; + buildSearchHaystack(haystack); + + auto *name = new QLabel(cfg.name, this); + QFont nameFont = name->font(); + nameFont.setBold(true); + nameFont.setPixelSize(13); + name->setFont(nameFont); + name->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Preferred); + + QLabel *model = nullptr; + if (!cfg.model.isEmpty()) { + model = new QLabel(QStringLiteral("· %1").arg(cfg.model), this); + model->setFont(CardStyle::monoFont(11)); + model->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + model->setMinimumWidth(0); + } + + Pill *sourcePill = nullptr; + if (cfg.isUserSource()) { + sourcePill = new Pill( + Pill::User, + cfg.overridesBundled ? Tr::tr("Override") : Tr::tr("User"), + this); + } + + auto *description = new QLabel(this); + description->setWordWrap(false); + QFont descFont = description->font(); + descFont.setItalic(true); + description->setFont(descFont); + description->setText(cfg.description.isEmpty() + ? Tr::tr("No description provided.") + : cfg.description); + description->setMinimumWidth(0); + description->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + description->setTextInteractionFlags(Qt::NoTextInteraction); + + QStringList endpointParts; + if (!cfg.endpoint.isEmpty()) + endpointParts << cfg.endpoint; + endpointParts << (cfg.enableThinking ? Tr::tr("thinking") : Tr::tr("no-thinking")); + endpointParts << (cfg.enableTools ? Tr::tr("tools") : Tr::tr("no-tools")); + + auto *endpoint = new QLabel(endpointParts.join(QStringLiteral(" · ")), this); + endpoint->setFont(CardStyle::monoFont(11)); + endpoint->setMinimumWidth(0); + endpoint->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + endpoint->setTextInteractionFlags(Qt::NoTextInteraction); + + auto *headerRow = new QHBoxLayout; + headerRow->setContentsMargins(0, 0, 0, 0); + headerRow->setSpacing(6); + headerRow->addWidget(name); + if (model) + headerRow->addWidget(model, 1); + else + headerRow->addStretch(1); + if (sourcePill) + headerRow->addWidget(sourcePill); + + auto *outer = new QVBoxLayout(this); + outer->setContentsMargins(10, 8, 10, 8); + outer->setSpacing(3); + outer->addLayout(headerRow); + outer->addWidget(description); + outer->addWidget(endpoint); + + if (!cfg.tags.isEmpty()) { + constexpr int kMaxTagPills = 4; + auto *tagsRow = new QHBoxLayout; + tagsRow->setContentsMargins(0, 0, 0, 0); + tagsRow->setSpacing(4); + const int tagCount = cfg.tags.size(); + for (int i = 0; i < std::min(tagCount, kMaxTagPills); ++i) + tagsRow->addWidget(new Pill(Pill::Tag, cfg.tags.at(i), this)); + if (tagCount > kMaxTagPills) { + auto *more = new Pill( + Pill::Tag, + QStringLiteral("+%1").arg(tagCount - kMaxTagPills), + this); + more->setToolTip(cfg.tags.mid(kMaxTagPills).join(QStringLiteral(", "))); + tagsRow->addWidget(more); + } + tagsRow->addStretch(1); + outer->addLayout(tagsRow); + } + + const auto t = CardStyle::toneFor(CardStyle::isDark(palette())); + QPalette descPal = description->palette(); + descPal.setColor(QPalette::WindowText, + QColor(cfg.description.isEmpty() ? t.textFaint : t.textSoft)); + description->setPalette(descPal); + QPalette endPal = endpoint->palette(); + endPal.setColor(QPalette::WindowText, QColor(t.textFaint)); + endpoint->setPalette(endPal); + if (model) { + QPalette mp = model->palette(); + mp.setColor(QPalette::WindowText, QColor(t.textMute)); + model->setPalette(mp); + } + + QString tooltip; + if (!cfg.description.isEmpty()) + tooltip += cfg.description + QStringLiteral("\n\n"); + if (!cfg.providerInstance.isEmpty()) + tooltip += Tr::tr("Provider instance: %1\n").arg(cfg.providerInstance); + if (!cfg.role.isEmpty()) + tooltip += Tr::tr("Role: %1\n").arg(cfg.role); + if (!cfg.endpoint.isEmpty()) + tooltip += Tr::tr("Endpoint: %1\n").arg(cfg.endpoint); + setToolTip(tooltip.trimmed()); +} + +// -- ProviderSection --------------------------------------------------- + +ProviderSection::ProviderSection(const QString &name, QWidget *parent) + : QWidget(parent) +{ + m_arrow = new QLabel(QStringLiteral("▾")); + m_label = new QLabel(name); + CardStyle::applySectionFont(m_label); + + QFont arrowFont = m_label->font(); + arrowFont.setCapitalization(QFont::MixedCase); + m_arrow->setFont(arrowFont); + QPalette ap = m_arrow->palette(); + ap.setColor(QPalette::WindowText, ap.color(QPalette::Mid)); + m_arrow->setPalette(ap); + + m_header = new QFrame; + m_header->setObjectName(QStringLiteral("ProviderHeader")); + m_header->setCursor(Qt::PointingHandCursor); + m_header->setFrameShape(QFrame::NoFrame); + auto *headerLayout = new QHBoxLayout(m_header); + headerLayout->setContentsMargins(2, 4, 2, 2); + headerLayout->setSpacing(6); + headerLayout->addWidget(m_arrow); + headerLayout->addWidget(m_label); + headerLayout->addStretch(1); + m_header->installEventFilter(this); + + m_content = new QWidget; + m_contentLayout = new QVBoxLayout(m_content); + m_contentLayout->setContentsMargins(0, 0, 0, 0); + m_contentLayout->setSpacing(4); + m_content->setVisible(false); + m_arrow->setText(QStringLiteral("▸")); + m_expanded = false; + + auto *outer = new QVBoxLayout(this); + outer->setContentsMargins(0, 0, 0, 0); + outer->setSpacing(0); + outer->addWidget(m_header); + outer->addWidget(m_content); +} + +void ProviderSection::addCard(ListRowCard *card) +{ + m_contentLayout->addWidget(card); + m_cards.append(card); +} + +int ProviderSection::applyFilter(const QString &needle) +{ + int visible = 0; + for (auto *card : m_cards) { + const bool show = card->matches(needle); + card->setVisible(show); + if (show) + ++visible; + } + return visible; +} + +void ProviderSection::setExpanded(bool expanded) +{ + if (m_expanded == expanded) + return; + m_expanded = expanded; + m_content->setVisible(expanded); + m_arrow->setText(expanded ? QStringLiteral("▾") : QStringLiteral("▸")); +} + +bool ProviderSection::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == m_header && event->type() == QEvent::MouseButtonRelease) { + auto *me = static_cast(event); + if (me->button() == Qt::LeftButton) { + setExpanded(!m_expanded); + return true; + } + } + return QWidget::eventFilter(watched, event); +} + +// -- AgentSelectionDialog ---------------------------------------------- + +AgentSelectionDialog::AgentSelectionDialog( + const std::vector &configs, + const QString ¤tName, + AgentFactory *agentFactory, + QWidget *parent) + : QDialog(parent) + , m_agentFactory(agentFactory) +{ + setWindowTitle(Tr::tr("Change Agent")); + resize(720, 600); + setSizeGripEnabled(true); + + if (!m_agentFactory) + m_localConfigs = configs; + + m_filter = new QLineEdit(this); + m_filter->setPlaceholderText( + Tr::tr("Filter by name, provider, model, template, description…")); + m_filter->setClearButtonEnabled(true); + + auto *topRow = new QHBoxLayout; + topRow->setContentsMargins(0, 0, 0, 0); + topRow->setSpacing(6); + topRow->addWidget(m_filter, 1); + + m_scroll = new QScrollArea(this); + m_scroll->setWidgetResizable(true); + m_scroll->setFrameShape(QFrame::StyledPanel); + m_scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + + auto *buttons + = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + m_okButton = buttons->button(QDialogButtonBox::Ok); + m_okButton->setText(Tr::tr("Change")); + m_okButton->setEnabled(false); + + auto *layout = new QVBoxLayout(this); + layout->addLayout(topRow); + layout->addWidget(m_scroll); + layout->addWidget(buttons); + + rebuild(currentName); + + connect(m_filter, &QLineEdit::textChanged, this, + [this](const QString &text) { applyFilter(text); }); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +void AgentSelectionDialog::selectCard(ListRowCard *card) +{ + if (m_currentCard == card) + return; + if (m_currentCard) + m_currentCard->setSelected(false); + m_currentCard = card; + if (m_currentCard) { + m_currentCard->setSelected(true); + m_selectedName = m_currentCard->itemName(); + } else { + m_selectedName.clear(); + } + if (m_okButton) + m_okButton->setEnabled(!m_selectedName.isEmpty()); +} + +void AgentSelectionDialog::rebuild(const QString ¤tName) +{ + m_sections.clear(); + m_currentCard = nullptr; + m_selectedName.clear(); + if (m_okButton) + m_okButton->setEnabled(false); + + const auto &configs + = m_agentFactory ? m_agentFactory->configs() : m_localConfigs; + + auto *content = new QWidget; + auto *contentLayout = new QVBoxLayout(content); + contentLayout->setContentsMargins(12, 12, 12, 12); + contentLayout->setSpacing(6); + + QMap> byProvider; + for (const auto &cfg : configs) { + if (cfg.hidden) continue; // hidden profiles stay loaded but don't surface in the picker + const QString key = cfg.providerInstance.isEmpty() + ? Tr::tr("(Unknown provider instance)") + : cfg.providerInstance; + byProvider[key].push_back(&cfg); + } + + AgentRowCard *toSelect = nullptr; + ProviderSection *sectionToExpand = nullptr; + + for (auto it = byProvider.cbegin(); it != byProvider.cend(); ++it) { + auto *section = new ProviderSection(it.key()); + auto sortedConfigs = it.value(); + std::sort(sortedConfigs.begin(), sortedConfigs.end(), + [](const AgentConfig *a, const AgentConfig *b) { return a->name < b->name; }); + for (const AgentConfig *cfg : sortedConfigs) { + auto *card = new AgentRowCard(*cfg); + connect(card, &ListRowCard::clicked, this, + [this, card]() { selectCard(card); }); + connect(card, &ListRowCard::activated, this, [this, card]() { + selectCard(card); + accept(); + }); + section->addCard(card); + if (cfg->name == currentName) { + toSelect = card; + sectionToExpand = section; + } + } + contentLayout->addWidget(section); + m_sections.append(section); + } + contentLayout->addStretch(1); + + m_scroll->setWidget(content); + + if (sectionToExpand) + sectionToExpand->setExpanded(true); + + if (toSelect) { + selectCard(toSelect); + QTimer::singleShot(0, this, [this, toSelect]() { + m_scroll->ensureWidgetVisible(toSelect, 0, 60); + }); + } + + applyFilter(m_filter ? m_filter->text() : QString()); +} + +void AgentSelectionDialog::applyFilter(const QString &needle) +{ + const QString trimmed = needle.trimmed(); + for (auto *section : m_sections) { + const int visible = section->applyFilter(trimmed); + section->setVisible(visible > 0); + if (!trimmed.isEmpty()) + section->setExpanded(visible > 0); + } +} + +} // namespace QodeAssist::Settings diff --git a/sources/settings/AgentSelectionDialog.hpp b/sources/settings/AgentSelectionDialog.hpp new file mode 100644 index 0000000..56ba69f --- /dev/null +++ b/sources/settings/AgentSelectionDialog.hpp @@ -0,0 +1,182 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +class QLineEdit; +class QPushButton; +class QScrollArea; +class QVBoxLayout; + +namespace QodeAssist { +class AgentFactory; +} + +namespace QodeAssist::Settings { + +namespace CardStyle { + +struct Tone +{ + QString bg; + QString hoverBg; + QString selectedBg; + QString selectedBd; + QString cardBd; + QString textSoft; + QString textMute; + QString textFaint; +}; + +inline bool isDark(const QPalette &p) +{ + return p.color(QPalette::Window).lightness() < 128; +} + +inline Tone toneFor(bool dark) +{ + return dark + ? Tone{"#333333", "#3a3a3a", "#2a4566", "#3a6fb7", "#4a4a4a", + "#c2c2c2", "#9a9a9a", "#7a7a7a"} + : Tone{"#f6f6f6", "#ececec", "#cfe1f7", "#3a6fb7", "#bdbdbd", + "#3a3a3a", "#5a5a5a", "#8a8a8a"}; +} + +inline QFont monoFont(int pixelSize) +{ + QFont f; + f.setFamilies({QStringLiteral("SF Mono"), + QStringLiteral("Cascadia Code"), + QStringLiteral("Consolas"), + QStringLiteral("Liberation Mono"), + QStringLiteral("Menlo"), + QStringLiteral("Courier New")}); + f.setStyleHint(QFont::Monospace); + f.setPixelSize(pixelSize); + return f; +} + +inline void applySectionFont(QLabel *l) +{ + QFont f = l->font(); + f.setPointSizeF(f.pointSizeF() * 0.85); + f.setBold(true); + f.setCapitalization(QFont::AllUppercase); + f.setLetterSpacing(QFont::AbsoluteSpacing, 0.5); + l->setFont(f); + QPalette p = l->palette(); + p.setColor(QPalette::WindowText, p.color(QPalette::Mid)); + l->setPalette(p); +} + +} // namespace CardStyle + +class ListRowCard : public QFrame +{ + Q_OBJECT +public: + QString itemName() const { return m_itemName; } + bool matches(const QString &needle) const; + void setSelected(bool selected); + bool isSelected() const { return m_selected; } + +signals: + void clicked(); + void activated(); + +protected: + explicit ListRowCard(QWidget *parent = nullptr); + + void setItemName(const QString &name) { m_itemName = name; } + void buildSearchHaystack(const QStringList &parts); + + void mousePressEvent(QMouseEvent *event) override; + void mouseDoubleClickEvent(QMouseEvent *event) override; + void enterEvent(QEnterEvent *event) override; + void leaveEvent(QEvent *event) override; + void changeEvent(QEvent *event) override; + +private: + void applyTheme(); + + QString m_itemName; + QString m_searchHaystack; + bool m_selected = false; + bool m_hover = false; + bool m_inApplyTheme = false; +}; + +class AgentRowCard : public ListRowCard +{ + Q_OBJECT +public: + explicit AgentRowCard(const AgentConfig &cfg, QWidget *parent = nullptr); + + QString agentName() const { return itemName(); } +}; + +class ProviderSection : public QWidget +{ + Q_OBJECT +public: + explicit ProviderSection(const QString &name, QWidget *parent = nullptr); + + void addCard(ListRowCard *card); + void setExpanded(bool expanded); + bool isExpanded() const { return m_expanded; } + const QList &cards() const { return m_cards; } + + int applyFilter(const QString &needle); // returns number of visible cards + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + QFrame *m_header = nullptr; + QLabel *m_arrow = nullptr; + QLabel *m_label = nullptr; + QWidget *m_content = nullptr; + QVBoxLayout *m_contentLayout = nullptr; + QList m_cards; + bool m_expanded = true; +}; + +class AgentSelectionDialog : public QDialog +{ + Q_OBJECT +public: + AgentSelectionDialog( + const std::vector &configs, + const QString ¤tName, + AgentFactory *agentFactory = nullptr, + QWidget *parent = nullptr); + + QString selectedName() const { return m_selectedName; } + +private: + void rebuild(const QString ¤tName); + void selectCard(ListRowCard *card); + void applyFilter(const QString &needle); + + QLineEdit *m_filter = nullptr; + QScrollArea *m_scroll = nullptr; + QPushButton *m_okButton = nullptr; + ListRowCard *m_currentCard = nullptr; + QList m_sections; + QString m_selectedName; + + AgentFactory *m_agentFactory = nullptr; + std::vector m_localConfigs; // fallback when no factory +}; + +} // namespace QodeAssist::Settings diff --git a/sources/settings/AgentSlotWidget.cpp b/sources/settings/AgentSlotWidget.cpp new file mode 100644 index 0000000..b3bc4d4 --- /dev/null +++ b/sources/settings/AgentSlotWidget.cpp @@ -0,0 +1,361 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "AgentSlotWidget.hpp" + +#include "SettingsTr.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace QodeAssist::Settings { + +namespace { + +bool isDarkPalette(const QPalette &p) +{ + return p.color(QPalette::Window).lightness() < 128; +} + +QFont monoFont(int pixelSize) +{ + QFont f; + f.setFamilies({QStringLiteral("SF Mono"), + QStringLiteral("Cascadia Code"), + QStringLiteral("Consolas"), + QStringLiteral("Liberation Mono"), + QStringLiteral("Menlo"), + QStringLiteral("Courier New")}); + f.setStyleHint(QFont::Monospace); + f.setPixelSize(pixelSize); + return f; +} + +struct Tone +{ + QString cardBg; + QString cardBd; + QString textSoft; + QString textMute; + QString textFaint; +}; + +Tone toneFor(bool dark) +{ + return dark ? Tone{"#333333", "#4a4a4a", "#c2c2c2", "#9a9a9a", "#7a7a7a"} + : Tone{"#f6f6f6", "#bdbdbd", "#3a3a3a", "#5a5a5a", "#8a8a8a"}; +} + +struct PillTone +{ + QString bg; + QString fg; + QString bd; +}; + +PillTone pillTone(Pill::Kind kind, bool dark) +{ + switch (kind) { + case Pill::Template: + return dark ? PillTone{"#2c3f5a", "#cfe1f7", "#4a6286"} + : PillTone{"#dbe7f6", "#1f3f73", "#a8c1e0"}; + case Pill::On: + return dark ? PillTone{"#2f4530", "#bce0bd", "#4a6c4b"} + : PillTone{"#dbe9d3", "#2c5a1c", "#a3bc97"}; + case Pill::Off: + return dark ? PillTone{"#3a3a3a", "#8a8a8a", "#4a4a4a"} + : PillTone{"#ececec", "#7a7a7a", "#c8c8c8"}; + case Pill::User: + return dark ? PillTone{"#4a3f24", "#e6cd92", "#6a5a30"} + : PillTone{"#f0e4cf", "#75541a", "#cdb98a"}; + case Pill::Tag: + return dark ? PillTone{"#2e2e3a", "#b9b9cf", "#46465a"} + : PillTone{"#e7e7f2", "#46466e", "#c1c1d5"}; + } + return {}; +} + +} // namespace + +// -- Pill -------------------------------------------------------------- + +Pill::Pill(Kind kind, const QString &text, QWidget *parent) + : QLabel(text, parent) + , m_kind(kind) +{ + setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum); + setAlignment(Qt::AlignCenter); + QFont f = font(); + f.setPixelSize(11); + setFont(f); + applyTheme(); +} + +void Pill::setKind(Kind kind) +{ + if (m_kind == kind) + return; + m_kind = kind; + applyTheme(); +} + +void Pill::changeEvent(QEvent *event) +{ + QLabel::changeEvent(event); + if (m_inApplyTheme) + return; + if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange) + applyTheme(); +} + +void Pill::applyTheme() +{ + if (m_inApplyTheme) + return; + QScopedValueRollback guard(m_inApplyTheme, true); + + const auto t = pillTone(m_kind, isDarkPalette(palette())); + setStyleSheet(QStringLiteral( + "QLabel { background-color: %1; color: %2;" + " border: 1px solid %3; padding: 1px 7px; }") + .arg(t.bg, t.fg, t.bd)); +} + +// -- AgentSlotWidget --------------------------------------------------- + +AgentSlotWidget::AgentSlotWidget(QWidget *parent) + : QWidget(parent) +{ + setAttribute(Qt::WA_StyledBackground, true); + setObjectName(QStringLiteral("AgentSlot")); + + m_name = new QLabel(this); + QFont nameFont = m_name->font(); + nameFont.setBold(true); + m_name->setFont(nameFont); + m_name->setTextInteractionFlags(Qt::TextSelectableByMouse); + + m_sourcePill = new Pill(Pill::User, {}, this); + m_sourcePill->hide(); + + m_changeBtn = new QPushButton(Tr::tr("Change…"), this); + m_changeBtn->setCursor(Qt::PointingHandCursor); + connect(m_changeBtn, &QPushButton::clicked, this, &AgentSlotWidget::changeRequested); + + m_editBtn = new QPushButton(Tr::tr("Edit"), this); + m_editBtn->setCursor(Qt::PointingHandCursor); + m_editBtn->setToolTip(Tr::tr("Open this agent in the Agents settings page.")); + connect(m_editBtn, &QPushButton::clicked, this, &AgentSlotWidget::editRequested); + + auto makeFieldLabel = [this]() { + auto *l = new QLabel(this); + l->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + return l; + }; + auto makeMonoValue = [this]() { + auto *l = new QLabel(this); + l->setFont(monoFont(11)); + l->setTextInteractionFlags(Qt::TextSelectableByMouse); + l->setMinimumWidth(0); + l->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + return l; + }; + auto makePlainValue = [this]() { + auto *l = new QLabel(this); + l->setTextInteractionFlags(Qt::TextSelectableByMouse); + l->setMinimumWidth(0); + l->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + return l; + }; + + m_modelLabel = makeFieldLabel(); + m_modelLabel->setText(Tr::tr("Model")); + m_modelValue = makeMonoValue(); + + m_urlLabel = makeFieldLabel(); + m_urlLabel->setText(Tr::tr("Provider")); + m_urlValue = makeMonoValue(); + + m_endpointLabel = makeFieldLabel(); + m_endpointLabel->setText(Tr::tr("Endpoint")); + m_endpointValue = makeMonoValue(); + + m_templateLabel = makeFieldLabel(); + m_templateLabel->setText(Tr::tr("Template")); + m_templateValue = makePlainValue(); + + m_description = new QLabel(this); + m_description->setWordWrap(true); + m_description->setTextInteractionFlags(Qt::TextSelectableByMouse); + QFont descFont = m_description->font(); + descFont.setItalic(true); + m_description->setFont(descFont); + + m_thinkingPill = new Pill(Pill::Off, {}, this); + m_toolsPill = new Pill(Pill::Off, {}, this); + + // Header row + auto *nameRow = new QHBoxLayout; + nameRow->setContentsMargins(0, 0, 0, 0); + nameRow->setSpacing(6); + nameRow->addWidget(m_name); + nameRow->addWidget(m_sourcePill); + nameRow->addStretch(1); + + auto *buttonsBox = new QHBoxLayout; + buttonsBox->setContentsMargins(0, 0, 0, 0); + buttonsBox->setSpacing(4); + buttonsBox->addWidget(m_editBtn); + buttonsBox->addWidget(m_changeBtn); + + auto *headerRow = new QHBoxLayout; + headerRow->setContentsMargins(0, 0, 0, 0); + headerRow->setSpacing(8); + headerRow->addLayout(nameRow, 1); + headerRow->addLayout(buttonsBox, 0); + + // Two-column field grid + auto *grid = new QGridLayout; + grid->setContentsMargins(0, 0, 0, 0); + grid->setHorizontalSpacing(8); + grid->setVerticalSpacing(2); + grid->setColumnMinimumWidth(0, 62); + grid->setColumnStretch(0, 0); + grid->setColumnStretch(1, 1); + + grid->addWidget(m_modelLabel, 0, 0); + grid->addWidget(m_modelValue, 0, 1); + grid->addWidget(m_urlLabel, 1, 0); + grid->addWidget(m_urlValue, 1, 1); + grid->addWidget(m_endpointLabel, 2, 0); + grid->addWidget(m_endpointValue, 2, 1); + grid->addWidget(m_templateLabel, 3, 0); + grid->addWidget(m_templateValue, 3, 1); + + auto *pillsRow = new QHBoxLayout; + pillsRow->setContentsMargins(0, 0, 0, 0); + pillsRow->setSpacing(4); + pillsRow->addWidget(m_thinkingPill); + pillsRow->addWidget(m_toolsPill); + pillsRow->addStretch(1); + + auto *outer = new QVBoxLayout(this); + outer->setContentsMargins(10, 10, 10, 10); + outer->setSpacing(6); + outer->addLayout(headerRow); + outer->addLayout(grid); + outer->addWidget(m_description); + outer->addLayout(pillsRow); + + applyTheme(); + clear(); +} + +void AgentSlotWidget::changeEvent(QEvent *event) +{ + QWidget::changeEvent(event); + if (m_inApplyTheme) + return; + if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange) + applyTheme(); +} + +void AgentSlotWidget::applyTheme() +{ + if (m_inApplyTheme) + return; + QScopedValueRollback guard(m_inApplyTheme, true); + + const Tone t = toneFor(isDarkPalette(palette())); + + setStyleSheet(QStringLiteral( + "#AgentSlot { background-color: %1; border: 1px solid %2; }") + .arg(t.cardBg, t.cardBd)); + + auto applyColor = [](QLabel *label, const QString &color) { + QPalette p = label->palette(); + p.setColor(QPalette::WindowText, QColor(color)); + label->setPalette(p); + }; + + for (QLabel *l : {m_modelLabel, m_urlLabel, m_endpointLabel, m_templateLabel}) + applyColor(l, t.textMute); + + applyColor(m_description, t.textSoft); +} + +void AgentSlotWidget::setAgentConfig(const AgentConfig &cfg) +{ + m_name->setText(cfg.name); + + if (cfg.isUserSource()) { + m_sourcePill->setText(cfg.overridesBundled + ? Tr::tr("User overrides bundled") + : Tr::tr("User")); + m_sourcePill->show(); + } else { + m_sourcePill->hide(); + } + + m_modelValue->setText(cfg.model); + m_modelValue->setToolTip(cfg.model); + // The agent profile no longer carries a URL — the URL belongs to + // the referenced provider instance and is editable on the Providers + // settings page. Surface the instance name in this row instead. + m_urlValue->setText(cfg.providerInstance); + m_urlValue->setToolTip(cfg.providerInstance); + m_endpointValue->setText(cfg.endpoint); + m_endpointValue->setToolTip(cfg.endpoint); + // Templates are now inline in the agent profile — no separate name to + // surface. Keep the row but show '—' so the layout stays stable. + m_templateLabel->hide(); + m_templateValue->hide(); + + m_description->setText(cfg.description.isEmpty() + ? Tr::tr("No description provided.") + : cfg.description); + + const Tone t = toneFor(isDarkPalette(palette())); + QPalette descPal = m_description->palette(); + descPal.setColor(QPalette::WindowText, + QColor(cfg.description.isEmpty() ? t.textFaint : t.textSoft)); + m_description->setPalette(descPal); + + m_thinkingPill->setKind(cfg.enableThinking ? Pill::On : Pill::Off); + m_thinkingPill->setText(cfg.enableThinking ? Tr::tr("thinking on") + : Tr::tr("thinking off")); + m_toolsPill->setKind(cfg.enableTools ? Pill::On : Pill::Off); + m_toolsPill->setText(cfg.enableTools ? Tr::tr("tools on") + : Tr::tr("tools off")); + m_thinkingPill->show(); + m_toolsPill->show(); + + m_editBtn->setEnabled(!cfg.name.isEmpty()); +} + +void AgentSlotWidget::clear() +{ + const Tone t = toneFor(isDarkPalette(palette())); + + m_name->setText(QStringLiteral("%2") + .arg(t.textFaint, Tr::tr("(no agent selected)"))); + m_sourcePill->hide(); + + for (QLabel *l : {m_modelValue, m_urlValue, m_endpointValue, m_templateValue}) { + l->clear(); + l->setToolTip({}); + } + + m_description->setText({}); + m_thinkingPill->hide(); + m_toolsPill->hide(); + m_editBtn->setEnabled(false); +} + +} // namespace QodeAssist::Settings diff --git a/sources/settings/AgentSlotWidget.hpp b/sources/settings/AgentSlotWidget.hpp new file mode 100644 index 0000000..421fdfd --- /dev/null +++ b/sources/settings/AgentSlotWidget.hpp @@ -0,0 +1,77 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +#include + +class QPushButton; + +namespace QodeAssist::Settings { + +class Pill : public QLabel +{ + Q_OBJECT +public: + enum Kind { Template, On, Off, User, Tag }; + + Pill(Kind kind, const QString &text = {}, QWidget *parent = nullptr); + void setKind(Kind kind); + +protected: + void changeEvent(QEvent *event) override; + +private: + void applyTheme(); + + Kind m_kind; + bool m_inApplyTheme = false; +}; + +class AgentSlotWidget : public QWidget +{ + Q_OBJECT +public: + explicit AgentSlotWidget(QWidget *parent = nullptr); + + void setAgentConfig(const AgentConfig &cfg); + void clear(); + + QPushButton *changeButton() const { return m_changeBtn; } + +signals: + void changeRequested(); + void editRequested(); + +protected: + void changeEvent(QEvent *event) override; + +private: + void applyTheme(); + + QLabel *m_name = nullptr; + Pill *m_sourcePill = nullptr; + QPushButton *m_changeBtn = nullptr; + QPushButton *m_editBtn = nullptr; + + QLabel *m_modelLabel = nullptr; + QLabel *m_modelValue = nullptr; + QLabel *m_urlLabel = nullptr; + QLabel *m_urlValue = nullptr; + QLabel *m_endpointLabel = nullptr; + QLabel *m_endpointValue = nullptr; + QLabel *m_templateLabel = nullptr; + QLabel *m_templateValue = nullptr; + + QLabel *m_description = nullptr; + + Pill *m_thinkingPill = nullptr; + Pill *m_toolsPill = nullptr; + + bool m_inApplyTheme = false; +}; + +} // namespace QodeAssist::Settings diff --git a/sources/settings/CMakeLists.txt b/sources/settings/CMakeLists.txt new file mode 100644 index 0000000..95d8660 --- /dev/null +++ b/sources/settings/CMakeLists.txt @@ -0,0 +1,38 @@ +add_library(QodeAssistAgentPipelines OBJECT + AgentPipelinesPage.hpp AgentPipelinesPage.cpp + PipelinesConfig.hpp PipelinesConfig.cpp + AgentRosterWidget.hpp AgentRosterWidget.cpp + AgentSlotWidget.hpp AgentSlotWidget.cpp + AgentSelectionDialog.hpp AgentSelectionDialog.cpp +) + +target_include_directories(QodeAssistAgentPipelines PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/sources/providers + ${CMAKE_SOURCE_DIR}/sources/common + ${CMAKE_SOURCE_DIR}/sources/agents + ${CMAKE_SOURCE_DIR}/settings + ${CMAKE_SOURCE_DIR}/logger +) + +target_link_libraries(QodeAssistAgentPipelines PRIVATE + Qt::Core + Qt::Gui + Qt::Widgets + Qt::Network + QtCreator::Core + QtCreator::Utils + Agents + Providers + Common + LLMQore + QodeAssistLogger + TomlSerializer + tomlplusplus::tomlplusplus +) + +target_compile_definitions(QodeAssistAgentPipelines PRIVATE + QODEASSIST_QT_CREATOR_VERSION_MAJOR=${QODEASSIST_QT_CREATOR_VERSION_MAJOR} + QODEASSIST_QT_CREATOR_VERSION_MINOR=${QODEASSIST_QT_CREATOR_VERSION_MINOR} + QODEASSIST_QT_CREATOR_VERSION_PATCH=${QODEASSIST_QT_CREATOR_VERSION_PATCH} +) diff --git a/sources/settings/PipelinesConfig.cpp b/sources/settings/PipelinesConfig.cpp new file mode 100644 index 0000000..d0590eb --- /dev/null +++ b/sources/settings/PipelinesConfig.cpp @@ -0,0 +1,280 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "PipelinesConfig.hpp" + +#include + +#include +#include +#include +#include + +#include + +#include + +#include "Logger.hpp" +#include "TomlWriter.hpp" +#include + +namespace QodeAssist::Settings { + +namespace { + +constexpr const char *kSection = "pipelines"; +constexpr const char *kCodeCompletion = "code_completion"; +constexpr const char *kChatAssistant = "chat_assistant"; +constexpr const char *kChatCompression = "chat_compression"; +constexpr const char *kQuickRefactor = "quick_refactor"; +constexpr int kMaxAgentNameLen = 256; + +QString trimAndCap(const QString &raw) +{ + QString s = raw.trimmed(); + if (s.size() > kMaxAgentNameLen) + s.truncate(kMaxAgentNameLen); + return s; +} + +QStringList toStringList(const toml::node *node, const QString &slotKey, bool *schemaOk) +{ + QStringList out; + if (!node) + return out; + const auto *arr = node->as_array(); + if (!arr) { + LOG_MESSAGE(QStringLiteral( + "[Pipelines] schema error: '%1' must be an array of strings, got non-array") + .arg(slotKey)); + if (schemaOk) + *schemaOk = false; + return out; + } + out.reserve(static_cast(arr->size())); + for (size_t i = 0; i < arr->size(); ++i) { + const auto &el = (*arr)[i]; + if (const auto *s = el.as_string()) { + const QString name = trimAndCap(QString::fromStdString(s->get())); + if (name.isEmpty()) + continue; + if (out.contains(name)) { + LOG_MESSAGE(QStringLiteral("[Pipelines] '%1' element #%2 is a duplicate, dropping") + .arg(slotKey) + .arg(i)); + continue; + } + out.append(name); + } else { + LOG_MESSAGE(QStringLiteral("[Pipelines] '%1' element #%2 is not a string, dropping") + .arg(slotKey) + .arg(i)); + if (schemaOk) + *schemaOk = false; + } + } + return out; +} + +void fillMissingFromDefaults(PipelineRosters &r, const toml::table §ion) +{ + const PipelineRosters defs = PipelineRosters::defaults(); + if (!section.contains(kCodeCompletion)) + r.codeCompletion = defs.codeCompletion; + if (!section.contains(kChatAssistant)) + r.chatAssistant = defs.chatAssistant; + if (!section.contains(kChatCompression)) + r.chatCompression = defs.chatCompression; + if (!section.contains(kQuickRefactor)) + r.quickRefactor = defs.quickRefactor; +} + +} // namespace + +PipelineRosters PipelineRosters::defaults() +{ + PipelineRosters r; + r.codeCompletion = {QStringLiteral("Ollama Qwen2.5-Coder Completion")}; + r.chatAssistant = {QStringLiteral("Ollama Chat")}; + r.chatCompression = {QStringLiteral("Ollama Compression")}; + r.quickRefactor = {QStringLiteral("Ollama Quick Refactor")}; + return r; +} + +QString PipelinesConfig::filePath() +{ + return Core::ICore::userResourcePath(QStringLiteral("qodeassist/config/pipelines.toml")) + .toFSPathString(); +} + +PipelinesLoadResult PipelinesConfig::load() +{ + PipelinesLoadResult result; + const QString path = filePath(); + QFile f(path); + if (!f.exists()) { + result.rosters = PipelineRosters::defaults(); + result.status = PipelinesLoadStatus::FileMissing; + return result; + } + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + result.rosters = PipelineRosters::defaults(); + result.status = PipelinesLoadStatus::ParseError; + result.message = QStringLiteral("cannot open %1: %2").arg(path, f.errorString()); + LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(result.message)); + return result; + } + const QByteArray contents = f.readAll(); + f.close(); + + toml::table tbl; + try { + tbl = toml::parse(std::string_view(contents.constData(), + static_cast(contents.size())), + path.toStdString()); + } catch (const toml::parse_error &e) { + result.rosters = PipelineRosters::defaults(); + result.status = PipelinesLoadStatus::ParseError; + result.message = QStringLiteral("parse error in %1: %2") + .arg(path, QString::fromStdString(std::string(e.description()))); + LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(result.message)); + return result; + } catch (const std::exception &e) { + result.rosters = PipelineRosters::defaults(); + result.status = PipelinesLoadStatus::ParseError; + result.message = QStringLiteral("error reading %1: %2") + .arg(path, QString::fromUtf8(e.what())); + LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(result.message)); + return result; + } catch (...) { + result.rosters = PipelineRosters::defaults(); + result.status = PipelinesLoadStatus::ParseError; + result.message = QStringLiteral("unknown error reading %1").arg(path); + LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(result.message)); + return result; + } + + const auto *section = tbl[kSection].as_table(); + if (!section) { + LOG_MESSAGE(QStringLiteral("[Pipelines] no [pipelines] section in %1; using defaults") + .arg(path)); + result.rosters = PipelineRosters::defaults(); + result.status = PipelinesLoadStatus::SchemaError; + result.message = QStringLiteral("missing [pipelines] section"); + return result; + } + + bool schemaOk = true; + result.rosters.codeCompletion + = toStringList((*section)[kCodeCompletion].node(), kCodeCompletion, &schemaOk); + result.rosters.chatAssistant + = toStringList((*section)[kChatAssistant].node(), kChatAssistant, &schemaOk); + result.rosters.chatCompression + = toStringList((*section)[kChatCompression].node(), kChatCompression, &schemaOk); + result.rosters.quickRefactor + = toStringList((*section)[kQuickRefactor].node(), kQuickRefactor, &schemaOk); + + fillMissingFromDefaults(result.rosters, *section); + + if (!schemaOk) { + result.status = PipelinesLoadStatus::SchemaError; + result.message = QStringLiteral("some entries had wrong type; see log"); + } + return result; +} + +bool PipelinesConfig::save(const PipelineRosters &rosters, QString *errorOut) +{ + const QString path = filePath(); + const QFileInfo info(path); + if (!QDir().mkpath(info.absolutePath())) { + const QString err = QStringLiteral("cannot create configuration directory: %1") + .arg(info.absolutePath()); + LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(err)); + if (errorOut) + *errorOut = err; + return false; + } + + TomlSerializer::TomlWriter w; + w.writeComment(QStringLiteral( + "QodeAssist pipelines — slot → ordered list of agent names.")); + w.writeComment(QStringLiteral( + "The router walks each list top-down at request time and uses")); + w.writeComment(QStringLiteral("the first matching agent.")); + w.writeBlankLine(); + w.writeTableHeader(QString::fromUtf8(kSection)); + w.writeStringArray(QString::fromUtf8(kCodeCompletion), rosters.codeCompletion); + w.writeStringArray(QString::fromUtf8(kChatAssistant), rosters.chatAssistant); + w.writeStringArray(QString::fromUtf8(kChatCompression), rosters.chatCompression); + w.writeStringArray(QString::fromUtf8(kQuickRefactor), rosters.quickRefactor); + + QSaveFile out(path); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + if (errorOut) + *errorOut = out.errorString(); + return false; + } + const QByteArray payload = w.toUtf8(); + const qint64 written = out.write(payload); + if (written != payload.size()) { + const QString err = QStringLiteral("short write (%1/%2): %3") + .arg(written) + .arg(payload.size()) + .arg(out.errorString()); + LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(err)); + out.cancelWriting(); + if (errorOut) + *errorOut = err; + return false; + } + if (!out.commit()) { + if (errorOut) + *errorOut = out.errorString(); + return false; + } + return true; +} + +bool PipelinesConfig::validate(const QodeAssist::AgentFactory &factory, QString *errorOut) +{ + PipelinesLoadResult lr = load(); + PipelineRosters &r = lr.rosters; + bool changed = false; + + auto fix = [&](QStringList ¤t) { + QStringList kept; + kept.reserve(current.size()); + for (const QString &raw : current) { + const QString name = trimAndCap(raw); + if (name.isEmpty() || kept.contains(name)) + continue; + if (factory.configByName(name)) + kept.append(name); + } + if (kept != current) { + current = std::move(kept); + changed = true; + } + }; + + fix(r.codeCompletion); + fix(r.chatAssistant); + fix(r.chatCompression); + fix(r.quickRefactor); + + if (!changed && lr.status == PipelinesLoadStatus::Ok) + return true; + + QString saveErr; + if (!save(r, &saveErr)) { + const QString msg = QStringLiteral("failed to persist after validation: %1").arg(saveErr); + LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(msg)); + if (errorOut) + *errorOut = msg; + return false; + } + return true; +} + +} // namespace QodeAssist::Settings diff --git a/sources/settings/PipelinesConfig.hpp b/sources/settings/PipelinesConfig.hpp new file mode 100644 index 0000000..53fda8f --- /dev/null +++ b/sources/settings/PipelinesConfig.hpp @@ -0,0 +1,47 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +namespace QodeAssist { +class AgentFactory; +} + +namespace QodeAssist::Settings { + +struct PipelineRosters +{ + QStringList codeCompletion; + QStringList chatAssistant; + QStringList chatCompression; + QStringList quickRefactor; + + [[nodiscard]] static PipelineRosters defaults(); +}; + +enum class PipelinesLoadStatus { Ok, FileMissing, ParseError, SchemaError }; + +struct PipelinesLoadResult +{ + PipelineRosters rosters; + PipelinesLoadStatus status = PipelinesLoadStatus::Ok; + QString message; +}; + +class PipelinesConfig +{ +public: + [[nodiscard]] static QString filePath(); + + [[nodiscard]] static PipelinesLoadResult load(); + + [[nodiscard]] static bool save(const PipelineRosters &rosters, QString *errorOut = nullptr); + + [[nodiscard]] static bool validate( + const QodeAssist::AgentFactory &factory, QString *errorOut = nullptr); +}; + +} // namespace QodeAssist::Settings