feat: Add agents pipelines

This commit is contained in:
Petr Mironychev
2026-05-26 16:44:45 +02:00
parent 97236c6069
commit fb887967ed
15 changed files with 2498 additions and 0 deletions

View File

@@ -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)

View File

@@ -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<AgentFactory> m_agentFactory;
QPointer<Settings::AgentsPageNavigator> m_agentsPageNavigator;
std::unique_ptr<Core::IOptionsPage> m_agentsOptionsPage;
QPointer<Settings::AgentPipelinesPageNavigator> m_agentPipelinesPageNavigator;
std::unique_ptr<Core::IOptionsPage> m_agentPipelinesOptionsPage;
#endif
};

View File

@@ -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";

View File

@@ -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?");

View File

@@ -0,0 +1,273 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "AgentPipelinesPage.hpp"
#include <coreplugin/dialogs/ioptionspage.h>
#include <coreplugin/icore.h>
#include <QColor>
#include <QFont>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QMessageBox>
#include <QPointer>
#include <QPushButton>
#include <QScrollArea>
#include <QTimer>
#include <QVBoxLayout>
#include "../../Version.hpp"
#include "AgentRosterWidget.hpp"
#include "AgentsSettingsPage.hpp"
#include "Logger.hpp"
#include "PipelinesConfig.hpp"
#include "SettingsConstants.hpp"
#include "SettingsTr.hpp"
#include <AgentFactory.hpp>
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> &agentFactory,
const QPointer<AgentPipelinesPageNavigator> &navigator,
const QPointer<AgentsPageNavigator> &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<AgentFactory> m_agentFactory;
QPointer<AgentPipelinesPageNavigator> m_navigator;
QPointer<AgentsPageNavigator> 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<AgentFactory> factoryPtr(agentFactory);
const QPointer<AgentPipelinesPageNavigator> navPtr(navigator);
const QPointer<AgentsPageNavigator> agentsNavPtr(agentsNavigator);
setWidgetCreator([factoryPtr, navPtr, agentsNavPtr] {
return new AgentPipelinesPageWidget(factoryPtr, navPtr, agentsNavPtr);
});
}
};
} // namespace
std::unique_ptr<Core::IOptionsPage> createAgentPipelinesSettingsPage(
AgentFactory *agentFactory,
AgentPipelinesPageNavigator *navigator,
AgentsPageNavigator *agentsNavigator)
{
return std::make_unique<AgentPipelinesOptionsPage>(
agentFactory, navigator, agentsNavigator);
}
} // namespace QodeAssist::Settings
#include "AgentPipelinesPage.moc"

View File

@@ -0,0 +1,39 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <memory>
#include <QObject>
#include <QString>
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<Core::IOptionsPage> createAgentPipelinesSettingsPage(
AgentFactory *agentFactory,
AgentPipelinesPageNavigator *navigator,
AgentsPageNavigator *agentsNavigator);
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,636 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "AgentRosterWidget.hpp"
#include <QApplication>
#include <QColor>
#include <QFontDatabase>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QStyle>
#include <QToolButton>
#include <QVBoxLayout>
#include <coreplugin/icore.h>
#include <Agent.hpp>
#include <AgentConfig.hpp>
#include <AgentFactory.hpp>
#include <AgentRouter.hpp>
#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(
"<span style='opacity:0.7'>%1</span> "
"<span style='color:%2'>%3:</span> %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"

View File

@@ -0,0 +1,70 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QColor>
#include <QPointer>
#include <QString>
#include <QStringList>
#include <QWidget>
#include <AgentRouter.hpp>
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<AgentFactory> 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

View File

@@ -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 <coreplugin/icore.h>
#include <AgentFactory.hpp>
#include <QDialogButtonBox>
#include <QEnterEvent>
#include <QEvent>
#include <QFont>
#include <QHBoxLayout>
#include <QLineEdit>
#include <QMap>
#include <QMouseEvent>
#include <QPushButton>
#include <QScopedValueRollback>
#include <QScrollArea>
#include <QTimer>
#include <QVBoxLayout>
#include <algorithm>
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<bool> 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<QMouseEvent *>(event);
if (me->button() == Qt::LeftButton) {
setExpanded(!m_expanded);
return true;
}
}
return QWidget::eventFilter(watched, event);
}
// -- AgentSelectionDialog ----------------------------------------------
AgentSelectionDialog::AgentSelectionDialog(
const std::vector<AgentConfig> &configs,
const QString &currentName,
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 &currentName)
{
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<QString, std::vector<const AgentConfig *>> 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

View File

@@ -0,0 +1,182 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QDialog>
#include <QFont>
#include <QFrame>
#include <QLabel>
#include <QPalette>
#include <QStringList>
#include <vector>
#include <AgentConfig.hpp>
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<ListRowCard *> &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<ListRowCard *> m_cards;
bool m_expanded = true;
};
class AgentSelectionDialog : public QDialog
{
Q_OBJECT
public:
AgentSelectionDialog(
const std::vector<AgentConfig> &configs,
const QString &currentName,
AgentFactory *agentFactory = nullptr,
QWidget *parent = nullptr);
QString selectedName() const { return m_selectedName; }
private:
void rebuild(const QString &currentName);
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<ProviderSection *> m_sections;
QString m_selectedName;
AgentFactory *m_agentFactory = nullptr;
std::vector<AgentConfig> m_localConfigs; // fallback when no factory
};
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,361 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "AgentSlotWidget.hpp"
#include "SettingsTr.hpp"
#include <QEvent>
#include <QFont>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QPalette>
#include <QPushButton>
#include <QScopedValueRollback>
#include <QVBoxLayout>
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<bool> 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<bool> 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("<i style=\"color:%1\">%2</i>")
.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

View File

@@ -0,0 +1,77 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QLabel>
#include <QWidget>
#include <AgentConfig.hpp>
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

View File

@@ -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}
)

View File

@@ -0,0 +1,280 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "PipelinesConfig.hpp"
#include <coreplugin/icore.h>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <toml++/toml.hpp>
#include <exception>
#include "Logger.hpp"
#include "TomlWriter.hpp"
#include <AgentFactory.hpp>
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<qsizetype>(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 &section)
{
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<size_t>(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 &current) {
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

View File

@@ -0,0 +1,47 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
#include <QStringList>
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