refactor: Move to agent architecture

This commit is contained in:
Petr Mironychev
2026-05-30 14:50:49 +02:00
parent 34ce787320
commit ccc2ec2e80
364 changed files with 10801 additions and 19020 deletions

View File

@@ -1,274 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#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

@@ -1,40 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#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

@@ -16,6 +16,7 @@
#include <QVBoxLayout>
#include <coreplugin/icore.h>
#include <utils/theme/theme.h>
#include <Agent.hpp>
#include <AgentConfig.hpp>
@@ -23,6 +24,7 @@
#include <AgentRouter.hpp>
#include "AgentSelectionDialog.hpp"
#include "SettingsTheme.hpp"
#include "SettingsTr.hpp"
namespace QodeAssist::Settings {
@@ -73,73 +75,67 @@ struct Theme
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}};
}
const QColor bg = Utils::creatorColor(Utils::Theme::BackgroundColorNormal);
const QColor fg = Utils::creatorColor(Utils::Theme::TextColorNormal);
const QColor muted = Utils::creatorColor(Utils::Theme::PanelTextColorMid);
const QColor faint = Utils::creatorColor(Utils::Theme::TextColorDisabled);
const QColor link = Utils::creatorColor(Utils::Theme::TextColorLink);
const QColor selBg = Utils::creatorColor(Utils::Theme::BackgroundColorSelected);
const QColor line = Utils::creatorColor(Utils::Theme::SplitterColor);
const bool isDark = bg.lightness() < 128;
const auto tint = [&](const QColor &role) -> Pill {
return {mix(bg, role, 0.16), isDark ? mix(fg, role, 0.65) : role, mix(bg, role, 0.42)};
};
switch (k) {
case PillKind::Template:
return tint(link);
case PillKind::On:
return tint(Utils::creatorColor(Utils::Theme::IconsRunColor));
case PillKind::Off:
return {mix(bg, faint, 0.12), faint, mix(bg, faint, 0.30)};
case PillKind::User:
return tint(Utils::creatorColor(Utils::Theme::IconsWarningColor));
case PillKind::Active:
return {selBg, fg, link};
case PillKind::Match:
return tint(link);
case PillKind::Tag:
case PillKind::Neutral:
return {mix(bg, muted, 0.14), muted, mix(bg, muted, 0.32)};
}
return {{0, 0, 0}, {0, 0, 0}, {0, 0, 0}};
return {bg, fg, line};
}
Theme themeFor(const QWidget *w)
Theme themeFor(const QWidget *)
{
const QPalette pal = w ? w->palette() : QApplication::palette();
const bool dark = pal.color(QPalette::Window).lightness() < 128;
const QColor bg = Utils::creatorColor(Utils::Theme::BackgroundColorNormal);
const QColor fg = Utils::creatorColor(Utils::Theme::TextColorNormal);
const QColor muted = Utils::creatorColor(Utils::Theme::PanelTextColorMid);
const QColor faint = Utils::creatorColor(Utils::Theme::TextColorDisabled);
const QColor line = Utils::creatorColor(Utils::Theme::SplitterColor);
const QColor selBg = Utils::creatorColor(Utils::Theme::BackgroundColorSelected);
const bool isDark = bg.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};
}
t.dark = isDark;
t.pageBg = mix(bg, isDark ? QColor(Qt::black) : QColor(Qt::white), 0.04);
t.cardBg = bg;
t.cardBorder = line;
t.groupBorder = line;
t.rowSeparator = line;
t.rowMatchBg = selBg;
t.listHeader = mix(bg, fg, 0.05);
t.text = fg;
t.textSoft = muted;
t.textMute = muted;
t.textFaint = faint;
t.matchChipBg = mix(bg, fg, 0.06);
t.matchChipBorder = line;
t.matchChipText = muted;
t.codeBg = mix(bg, fg, 0.06);
t.codeBorder = line;
return t;
}
@@ -153,7 +149,7 @@ 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;"
"padding:1px 6px; border-radius:3px;"
"font-size:10px;")
.arg(hex(p.bg), hex(p.fg), hex(p.border));
}
@@ -199,9 +195,11 @@ public:
AgentRosterRow(int index,
const QString &name,
const AgentConfig *cfg,
const QString &model,
bool active,
bool first,
bool last,
bool orderable,
const Theme &theme,
QWidget *parent = nullptr);
@@ -217,7 +215,7 @@ private:
bool active,
bool isUser,
const Theme &t);
QWidget *buildMetaLine(const AgentConfig *cfg, bool active, const Theme &t);
QWidget *buildMetaLine(const AgentConfig *cfg, bool active, bool showMatch, const Theme &t);
QWidget *buildActions(const Theme &t, bool first, bool last);
int m_index;
@@ -226,9 +224,11 @@ private:
AgentRosterRow::AgentRosterRow(int index,
const QString &name,
const AgentConfig *cfg,
const QString &model,
bool active,
bool first,
bool last,
bool orderable,
const Theme &theme,
QWidget *parent)
: QFrame(parent), m_index(index)
@@ -246,56 +246,57 @@ AgentRosterRow::AgentRosterRow(int index,
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);
if (orderable) {
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);
connect(upBtn, &QToolButton::clicked, this, [this]() { emit moveUpRequested(m_index); });
connect(dnBtn, &QToolButton::clicked, this, [this]() { emit moveDownRequested(m_index); });
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));
body->addWidget(buildMetaLine(cfg, active, /*showMatch*/ orderable, 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,
@@ -333,7 +334,8 @@ QWidget *AgentRosterRow::buildIdentityLine(const QString &displayName,
return w;
}
QWidget *AgentRosterRow::buildMetaLine(const AgentConfig *cfg, bool active, const Theme &t)
QWidget *AgentRosterRow::buildMetaLine(const AgentConfig *cfg, bool active, bool showMatch,
const Theme &t)
{
auto *w = new QWidget(this);
auto *line = new QHBoxLayout(w);
@@ -341,31 +343,33 @@ QWidget *AgentRosterRow::buildMetaLine(const AgentConfig *cfg, bool active, cons
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);
if (showMatch) {
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:1px 6px; border-radius:3px; 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);
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));
@@ -432,13 +436,10 @@ AgentRosterWidget::AgentRosterWidget(QWidget *parent)
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);
@@ -456,13 +457,13 @@ AgentRosterWidget::AgentRosterWidget(QWidget *parent)
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; }")
QStringLiteral("QFrame#rosterCard { background:%1; border:1px solid %2; border-radius:4px; }")
.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_emptyHint = new QLabel(tr("No agent selected yet — use \"Add agent…\" below."),
m_rowsFrame);
m_emptyHint->setAlignment(Qt::AlignCenter);
m_emptyHint->setContentsMargins(10, 12, 10, 12);
@@ -495,39 +496,71 @@ AgentRosterWidget::AgentRosterWidget(QWidget *parent)
connect(m_addBtn, &QPushButton::clicked, this, &AgentRosterWidget::onAddClicked);
}
void AgentRosterWidget::setSlot(const QString &title, const QString &hint, const QColor &accent)
void AgentRosterWidget::setSlot(
const QString &title,
const QString &hint,
const QStringList &presetTags)
{
m_presetTags = presetTags;
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;
if (m_factory != factory) {
QObject::disconnect(m_factoryConn);
QObject::disconnect(m_modelConn);
m_factory = factory;
if (m_factory) {
m_factoryConn = connect(m_factory, &AgentFactory::agentsChanged, this,
[this] { rebuildRows(); });
m_modelConn = connect(m_factory, &AgentFactory::agentModelChanged, this,
[this](const QString &name) {
if (m_names.contains(name))
rebuildRows();
});
}
}
m_names = names;
rebuildRows();
}
void AgentRosterWidget::setRoutingContext(const AgentRouter::Context &ctx)
void AgentRosterWidget::setOrderable(bool orderable)
{
m_routingCtx = ctx;
recomputeActive();
if (m_orderable == orderable)
return;
m_orderable = orderable;
rebuildRows();
}
void AgentRosterWidget::setSingle(bool single)
{
if (m_single == single)
return;
m_single = single;
if (single)
m_orderable = false;
rebuildRows();
}
void AgentRosterWidget::applyMode()
{
const bool hasEntry = !m_names.isEmpty();
m_addBtn->setText((m_single && hasEntry) ? tr("Change agent…") : tr("+ Add agent…"));
QString footer;
if (!m_single)
footer = m_orderable ? tr("first matching agent is used")
: tr("you pick the active agent in the chat panel");
m_footerHint->setText(footer);
m_footerHint->setVisible(!footer.isEmpty());
}
void AgentRosterWidget::recomputeActive()
{
if (!m_factory || m_names.isEmpty()
if (!m_orderable || !m_factory || m_names.isEmpty()
|| (m_routingCtx.filePath.isEmpty() && m_routingCtx.projectName.isEmpty())) {
m_activeIndex = -1;
return;
@@ -550,6 +583,8 @@ void AgentRosterWidget::rebuildRows()
delete it;
}
applyMode();
if (m_names.isEmpty()) {
m_emptyHint->setVisible(true);
m_rowsLayout->addWidget(m_emptyHint);
@@ -562,12 +597,15 @@ void AgentRosterWidget::rebuildRows()
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;
const QString model = cfg ? cfg->model : QString();
auto *row = new AgentRosterRow(i,
name,
cfg,
model,
i == m_activeIndex,
/*first*/ i == 0,
/*last*/ i == m_names.size() - 1,
m_orderable,
t,
m_rowsFrame);
connect(row, &AgentRosterRow::moveUpRequested, this, &AgentRosterWidget::onRowMoveUp);
@@ -584,16 +622,25 @@ void AgentRosterWidget::onAddClicked()
return;
AgentSelectionDialog dialog(m_factory->configs(),
/*currentName*/ QString{},
/*currentName*/ m_single ? m_names.value(0) : QString{},
m_factory.data(),
m_presetTags,
Core::ICore::dialogParent());
if (dialog.exec() != QDialog::Accepted)
return;
const QString picked = dialog.selectedName();
if (picked.isEmpty() || m_names.contains(picked))
if (picked.isEmpty())
return;
m_names.append(picked);
if (m_single) {
if (m_names.size() == 1 && m_names.first() == picked)
return;
m_names = {picked};
} else {
if (m_names.contains(picked))
return;
m_names.append(picked);
}
rebuildRows();
emit rosterChanged(m_names);
}

View File

@@ -4,7 +4,6 @@
#pragma once
#include <QColor>
#include <QPointer>
#include <QString>
#include <QStringList>
@@ -32,12 +31,23 @@ class AgentRosterWidget : public QWidget
public:
explicit AgentRosterWidget(QWidget *parent = nullptr);
void setSlot(const QString &title, const QString &hint, const QColor &accent);
void setSlot(
const QString &title,
const QString &hint,
const QStringList &presetTags = {});
void setRoster(const QStringList &names, AgentFactory *factory);
[[nodiscard]] QStringList roster() const { return m_names; }
// When false, the list is an unordered set: no move arrows, no position
// numbers, no "first matching" routing hint. Used by pipelines where the
// user — not a router — chooses the agent (e.g. the chat picker).
void setOrderable(bool orderable);
void setRoutingContext(const AgentRouter::Context &ctx);
// When true, the card holds at most one agent: "Add" becomes "Change",
// selecting replaces the current entry, and the routing footer is hidden.
// Implies a non-orderable list. Used by single-agent pipelines.
void setSingle(bool single);
[[nodiscard]] QStringList roster() const { return m_names; }
signals:
void rosterChanged(const QStringList &names);
@@ -46,6 +56,7 @@ signals:
private:
void rebuildRows();
void recomputeActive();
void applyMode();
void onAddClicked();
void onRowMoveUp(int index);
@@ -54,11 +65,15 @@ private:
void onRowEdit(int index);
QStringList m_names;
QStringList m_presetTags;
QPointer<AgentFactory> m_factory;
QMetaObject::Connection m_factoryConn;
QMetaObject::Connection m_modelConn;
AgentRouter::Context m_routingCtx;
int m_activeIndex = -1;
bool m_orderable = true;
bool m_single = false;
QLabel *m_accentDot = nullptr;
QLabel *m_titleLabel = nullptr;
QLabel *m_hintLabel = nullptr;
QFrame *m_rowsFrame = nullptr;

View File

@@ -4,9 +4,10 @@
#include "AgentSelectionDialog.hpp"
#include "AgentSlotWidget.hpp"
#include "PipelinesConfig.hpp"
#include "Pill.hpp"
#include "SettingsTr.hpp"
#include "TagFilterStrip.hpp"
#include <coreplugin/icore.h>
@@ -23,6 +24,7 @@
#include <QPushButton>
#include <QScopedValueRollback>
#include <QScrollArea>
#include <QSet>
#include <QTimer>
#include <QVBoxLayout>
#include <algorithm>
@@ -56,6 +58,14 @@ void ListRowCard::setSelected(bool selected)
applyTheme();
}
bool ListRowCard::hasAllTags(const QSet<QString> &activeTags) const
{
for (const QString &tag : activeTags)
if (!m_itemTags.contains(tag))
return false;
return true;
}
void ListRowCard::buildSearchHaystack(const QStringList &parts)
{
m_searchHaystack = parts.join(QLatin1Char(' ')).toLower();
@@ -124,8 +134,9 @@ AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent)
: ListRowCard(parent)
{
setItemName(cfg.name);
setItemTags(cfg.tags);
QStringList haystack{cfg.name, cfg.providerInstance, cfg.model,
cfg.description, cfg.role,
cfg.description, cfg.systemPrompt,
cfg.endpoint};
haystack += cfg.tags;
buildSearchHaystack(haystack);
@@ -147,10 +158,7 @@ AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent)
Pill *sourcePill = nullptr;
if (cfg.isUserSource()) {
sourcePill = new Pill(
Pill::User,
cfg.overridesBundled ? Tr::tr("Override") : Tr::tr("User"),
this);
sourcePill = new Pill(Pill::User, Tr::tr("User"), this);
}
auto *description = new QLabel(this);
@@ -234,8 +242,8 @@ AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent)
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.systemPrompt.isEmpty())
tooltip += Tr::tr("System prompt: %1\n").arg(cfg.systemPrompt);
if (!cfg.endpoint.isEmpty())
tooltip += Tr::tr("Endpoint: %1\n").arg(cfg.endpoint);
setToolTip(tooltip.trimmed());
@@ -257,6 +265,9 @@ ProviderSection::ProviderSection(const QString &name, QWidget *parent)
ap.setColor(QPalette::WindowText, ap.color(QPalette::Mid));
m_arrow->setPalette(ap);
m_count = new QLabel;
CardStyle::applySectionFont(m_count);
m_header = new QFrame;
m_header->setObjectName(QStringLiteral("ProviderHeader"));
m_header->setCursor(Qt::PointingHandCursor);
@@ -267,6 +278,7 @@ ProviderSection::ProviderSection(const QString &name, QWidget *parent)
headerLayout->addWidget(m_arrow);
headerLayout->addWidget(m_label);
headerLayout->addStretch(1);
headerLayout->addWidget(m_count);
m_header->installEventFilter(this);
m_content = new QWidget;
@@ -290,15 +302,17 @@ void ProviderSection::addCard(ListRowCard *card)
m_cards.append(card);
}
int ProviderSection::applyFilter(const QString &needle)
int ProviderSection::applyFilter(const QString &needle, const QSet<QString> &activeTags)
{
int visible = 0;
for (auto *card : m_cards) {
const bool show = card->matches(needle);
const bool show = card->matches(needle) && card->hasAllTags(activeTags);
card->setVisible(show);
if (show)
++visible;
}
if (m_count)
m_count->setText(QString::number(visible));
return visible;
}
@@ -329,12 +343,16 @@ AgentSelectionDialog::AgentSelectionDialog(
const std::vector<AgentConfig> &configs,
const QString &currentName,
AgentFactory *agentFactory,
const QStringList &presetTags,
QWidget *parent)
: QDialog(parent)
, m_agentFactory(agentFactory)
, m_presetTags(presetTags)
{
setWindowTitle(Tr::tr("Change Agent"));
const bool isChange = !currentName.isEmpty();
setWindowTitle(isChange ? Tr::tr("Change Agent") : Tr::tr("Add Agent"));
resize(720, 600);
setMinimumSize(560, 420);
setSizeGripEnabled(true);
if (!m_agentFactory)
@@ -350,6 +368,31 @@ AgentSelectionDialog::AgentSelectionDialog(
topRow->setSpacing(6);
topRow->addWidget(m_filter, 1);
m_tagStrip = new TagFilterStrip(this);
const bool dark = CardStyle::isDark(palette());
const auto tone = CardStyle::toneFor(dark);
m_resultCount = new QLabel(this);
{
QPalette rp = m_resultCount->palette();
rp.setColor(QPalette::WindowText, QColor(tone.textMute));
m_resultCount->setPalette(rp);
}
auto *expandAll = new QLabel(
QStringLiteral("<a href=\"#\">%1</a>").arg(Tr::tr("Expand all")), this);
auto *collapseAll = new QLabel(
QStringLiteral("<a href=\"#\">%1</a>").arg(Tr::tr("Collapse all")), this);
auto *controlsRow = new QHBoxLayout;
controlsRow->setContentsMargins(2, 0, 2, 0);
controlsRow->setSpacing(8);
controlsRow->addWidget(m_resultCount);
controlsRow->addStretch(1);
controlsRow->addWidget(expandAll);
controlsRow->addWidget(collapseAll);
m_scroll = new QScrollArea(this);
m_scroll->setWidgetResizable(true);
m_scroll->setFrameShape(QFrame::StyledPanel);
@@ -358,20 +401,54 @@ AgentSelectionDialog::AgentSelectionDialog(
auto *buttons
= new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
m_okButton = buttons->button(QDialogButtonBox::Ok);
m_okButton->setText(Tr::tr("Change"));
m_okButton->setText(isChange ? Tr::tr("Change") : Tr::tr("Add"));
m_okButton->setEnabled(false);
auto *layout = new QVBoxLayout(this);
layout->addLayout(topRow);
layout->addWidget(m_tagStrip);
layout->addLayout(controlsRow);
layout->addWidget(m_scroll);
layout->addWidget(buttons);
QMap<QString, int> tagCounts;
for (const auto &cfg : (m_agentFactory ? m_agentFactory->configs() : m_localConfigs)) {
if (cfg.hidden)
continue;
for (const QString &tag : cfg.tags)
tagCounts[tag] += 1;
}
QSet<QString> preset;
for (const QString &tag : m_presetTags)
if (tagCounts.contains(tag))
preset.insert(tag);
m_tagStrip->setAvailableTags(tagCounts, preset);
connect(m_tagStrip, &TagFilterStrip::activeTagsChanged, this,
[this](const QSet<QString> &) { applyFilters(); });
connect(expandAll, &QLabel::linkActivated, this, [this](const QString &) {
setAllExpanded(true);
});
connect(collapseAll, &QLabel::linkActivated, this, [this](const QString &) {
setAllExpanded(false);
});
rebuild(currentName);
connect(m_filter, &QLineEdit::textChanged, this,
[this](const QString &text) { applyFilter(text); });
[this](const QString &) { applyFilters(); });
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
m_filter->setFocus();
}
void AgentSelectionDialog::setAllExpanded(bool expanded)
{
for (auto *section : m_sections)
if (!section->isHidden())
section->setExpanded(expanded);
}
void AgentSelectionDialog::selectCard(ListRowCard *card)
@@ -441,6 +518,20 @@ void AgentSelectionDialog::rebuild(const QString &currentName)
contentLayout->addWidget(section);
m_sections.append(section);
}
m_emptyLabel = new QLabel(tr("No agents match the current filter."), content);
m_emptyLabel->setAlignment(Qt::AlignCenter);
m_emptyLabel->setContentsMargins(10, 24, 10, 24);
{
QFont ef = m_emptyLabel->font();
ef.setItalic(true);
m_emptyLabel->setFont(ef);
QPalette ep = m_emptyLabel->palette();
ep.setColor(QPalette::WindowText, ep.color(QPalette::Mid));
m_emptyLabel->setPalette(ep);
}
m_emptyLabel->setVisible(false);
contentLayout->addWidget(m_emptyLabel);
contentLayout->addStretch(1);
m_scroll->setWidget(content);
@@ -455,17 +546,36 @@ void AgentSelectionDialog::rebuild(const QString &currentName)
});
}
applyFilter(m_filter ? m_filter->text() : QString());
applyFilters();
}
void AgentSelectionDialog::applyFilter(const QString &needle)
void AgentSelectionDialog::applyFilters()
{
const QString trimmed = needle.trimmed();
const QString needle = m_filter ? m_filter->text().trimmed() : QString();
const QSet<QString> activeTags = m_tagStrip ? m_tagStrip->activeTags() : QSet<QString>();
const bool filtering = !needle.isEmpty() || !activeTags.isEmpty();
int total = 0;
for (auto *section : m_sections) {
const int visible = section->applyFilter(trimmed);
const int visible = section->applyFilter(needle, activeTags);
section->setVisible(visible > 0);
if (!trimmed.isEmpty())
if (filtering)
section->setExpanded(visible > 0);
total += visible;
}
if (m_emptyLabel)
m_emptyLabel->setVisible(total == 0);
if (m_resultCount)
m_resultCount->setText(total == 0 ? tr("No matches")
: tr("%n agent(s)", nullptr, total));
if (m_tagStrip) {
QMap<QString, int> liveCounts;
for (auto *section : m_sections)
for (auto *card : section->cards())
if (card->matches(needle) && card->hasAllTags(activeTags))
for (const QString &tag : card->itemTags())
liveCounts[tag] += 1;
m_tagStrip->setVisibleCounts(liveCounts);
}
}

View File

@@ -9,6 +9,7 @@
#include <QFrame>
#include <QLabel>
#include <QPalette>
#include <QSet>
#include <QStringList>
#include <vector>
@@ -25,6 +26,8 @@ class AgentFactory;
namespace QodeAssist::Settings {
class TagFilterStrip;
namespace CardStyle {
struct Tone
@@ -87,9 +90,10 @@ class ListRowCard : public QFrame
Q_OBJECT
public:
QString itemName() const { return m_itemName; }
QStringList itemTags() const { return m_itemTags; }
bool matches(const QString &needle) const;
bool hasAllTags(const QSet<QString> &activeTags) const;
void setSelected(bool selected);
bool isSelected() const { return m_selected; }
signals:
void clicked();
@@ -99,6 +103,7 @@ protected:
explicit ListRowCard(QWidget *parent = nullptr);
void setItemName(const QString &name) { m_itemName = name; }
void setItemTags(const QStringList &tags) { m_itemTags = tags; }
void buildSearchHaystack(const QStringList &parts);
void mousePressEvent(QMouseEvent *event) override;
@@ -111,6 +116,7 @@ private:
void applyTheme();
QString m_itemName;
QStringList m_itemTags;
QString m_searchHaystack;
bool m_selected = false;
bool m_hover = false;
@@ -134,10 +140,10 @@ public:
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
// returns number of visible cards
int applyFilter(const QString &needle, const QSet<QString> &activeTags);
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
@@ -146,6 +152,7 @@ private:
QFrame *m_header = nullptr;
QLabel *m_arrow = nullptr;
QLabel *m_label = nullptr;
QLabel *m_count = nullptr;
QWidget *m_content = nullptr;
QVBoxLayout *m_contentLayout = nullptr;
QList<ListRowCard *> m_cards;
@@ -160,6 +167,7 @@ public:
const std::vector<AgentConfig> &configs,
const QString &currentName,
AgentFactory *agentFactory = nullptr,
const QStringList &presetTags = {},
QWidget *parent = nullptr);
QString selectedName() const { return m_selectedName; }
@@ -167,17 +175,22 @@ public:
private:
void rebuild(const QString &currentName);
void selectCard(ListRowCard *card);
void applyFilter(const QString &needle);
void applyFilters();
void setAllExpanded(bool expanded);
QLineEdit *m_filter = nullptr;
TagFilterStrip *m_tagStrip = nullptr;
QScrollArea *m_scroll = nullptr;
QPushButton *m_okButton = nullptr;
QLabel *m_resultCount = nullptr;
QLabel *m_emptyLabel = 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
QStringList m_presetTags;
};
} // namespace QodeAssist::Settings

View File

@@ -1,362 +0,0 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#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

@@ -1,78 +0,0 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#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

@@ -1,8 +1,7 @@
add_library(QodeAssistAgentPipelines OBJECT
AgentPipelinesPage.hpp AgentPipelinesPage.cpp
add_library(QodeAssistAgentPipelines STATIC
PipelinesConfig.hpp PipelinesConfig.cpp
Pill.hpp Pill.cpp
AgentRosterWidget.hpp AgentRosterWidget.cpp
AgentSlotWidget.hpp AgentSlotWidget.cpp
AgentSelectionDialog.hpp AgentSelectionDialog.cpp
)
@@ -29,6 +28,7 @@ target_link_libraries(QodeAssistAgentPipelines PRIVATE
QodeAssistLogger
TomlSerializer
tomlplusplus::tomlplusplus
QodeAssistSettings
)
target_compile_definitions(QodeAssistAgentPipelines PRIVATE

96
sources/settings/Pill.cpp Normal file
View File

@@ -0,0 +1,96 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "Pill.hpp"
#include "SettingsTheme.hpp"
#include <utils/theme/theme.h>
#include <QEvent>
#include <QFont>
#include <QScopedValueRollback>
namespace QodeAssist::Settings {
namespace {
struct PillTone
{
QColor bg;
QColor fg;
QColor border;
};
PillTone toneFor(Pill::Kind kind)
{
const QColor cardBg = Utils::creatorColor(Utils::Theme::BackgroundColorNormal);
const QColor text = Utils::creatorColor(Utils::Theme::TextColorNormal);
const QColor textMuted = Utils::creatorColor(Utils::Theme::PanelTextColorMid);
const QColor textFaint = Utils::creatorColor(Utils::Theme::TextColorDisabled);
const QColor accent = Utils::creatorColor(Utils::Theme::TextColorLink);
const QColor activeBg = Utils::creatorColor(Utils::Theme::BackgroundColorSelected);
const QColor border = Utils::creatorColor(Utils::Theme::SplitterColor);
const bool dark = cardBg.lightness() < 128;
const auto tint = [&](const QColor &role) -> PillTone {
return {mix(cardBg, role, 0.16), dark ? mix(text, role, 0.65) : role, mix(cardBg, role, 0.42)};
};
switch (kind) {
case Pill::On:
return tint(Utils::creatorColor(Utils::Theme::IconsRunColor));
case Pill::Off:
return {mix(cardBg, textFaint, 0.12), textFaint, mix(cardBg, textFaint, 0.30)};
case Pill::User:
return tint(Utils::creatorColor(Utils::Theme::IconsWarningColor));
case Pill::Accent:
case Pill::Match:
return tint(accent);
case Pill::Active:
return {activeBg, text, accent};
case Pill::Tag:
case Pill::Neutral:
return {mix(cardBg, textMuted, 0.14), textMuted, mix(cardBg, textMuted, 0.32)};
}
return {cardBg, text, border};
}
} // namespace
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::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 PillTone t = toneFor(m_kind);
setStyleSheet(QStringLiteral("QLabel { background-color:%1; color:%2;"
" border:1px solid %3; border-radius:3px;"
" padding:1px 7px; font-size:11px; }")
.arg(cssColor(t.bg), cssColor(t.fg), cssColor(t.border)));
}
} // namespace QodeAssist::Settings

32
sources/settings/Pill.hpp Normal file
View File

@@ -0,0 +1,32 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QLabel>
#include <QString>
namespace QodeAssist::Settings {
// Small rounded chip. Theme-aware: recolors itself on palette/style changes.
// All colors derive from the active Qt Creator theme (Utils::Theme).
class Pill : public QLabel
{
Q_OBJECT
public:
enum Kind { Neutral, Accent, On, Off, User, Tag, Active, Match };
explicit Pill(Kind kind, const QString &text = {}, QWidget *parent = nullptr);
protected:
void changeEvent(QEvent *event) override;
private:
void applyTheme();
Kind m_kind;
bool m_inApplyTheme = false;
};
} // namespace QodeAssist::Settings

View File

@@ -38,6 +38,30 @@ QString trimAndCap(const QString &raw)
return s;
}
QString toSingleString(const toml::node *node, const QString &slotKey, bool *schemaOk)
{
if (!node)
return {};
if (const auto *s = node->as_string())
return trimAndCap(QString::fromStdString(s->get()));
// Backward compatibility: older pipelines.toml stored these slots as an
// ordered array. Collapse to the first usable name.
if (const auto *arr = node->as_array()) {
for (size_t i = 0; i < arr->size(); ++i) {
if (const auto *s = (*arr)[i].as_string()) {
const QString name = trimAndCap(QString::fromStdString(s->get()));
if (!name.isEmpty())
return name;
}
}
return {};
}
LOG_MESSAGE(QStringLiteral("[Pipelines] schema error: '%1' must be a string").arg(slotKey));
if (schemaOk)
*schemaOk = false;
return {};
}
QStringList toStringList(const toml::node *node, const QString &slotKey, bool *schemaOk)
{
QStringList out;
@@ -94,12 +118,7 @@ void fillMissingFromDefaults(PipelineRosters &r, const toml::table &section)
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;
return PipelineRosters{};
}
QString PipelinesConfig::filePath()
@@ -171,9 +190,9 @@ PipelinesLoadResult PipelinesConfig::load()
result.rosters.chatAssistant
= toStringList((*section)[kChatAssistant].node(), kChatAssistant, &schemaOk);
result.rosters.chatCompression
= toStringList((*section)[kChatCompression].node(), kChatCompression, &schemaOk);
= toSingleString((*section)[kChatCompression].node(), kChatCompression, &schemaOk);
result.rosters.quickRefactor
= toStringList((*section)[kQuickRefactor].node(), kQuickRefactor, &schemaOk);
= toSingleString((*section)[kQuickRefactor].node(), kQuickRefactor, &schemaOk);
fillMissingFromDefaults(result.rosters, *section);
@@ -198,17 +217,22 @@ bool PipelinesConfig::save(const PipelineRosters &rosters, QString *errorOut)
}
TomlSerializer::TomlWriter w;
w.writeComment(QStringLiteral("QodeAssist pipelines — which agent each feature uses."));
w.writeComment(QStringLiteral(
"QodeAssist pipelines — slot → ordered list of agent names."));
"code_completion: ordered list; the router walks it top-down and uses"));
w.writeComment(QStringLiteral(
"The router walks each list top-down at request time and uses"));
w.writeComment(QStringLiteral("the first matching agent."));
" the first agent whose match rules fit the current file/project."));
w.writeComment(QStringLiteral(
"chat_assistant: agents offered in the chat picker (order irrelevant —"));
w.writeComment(QStringLiteral(" you choose one in the UI)."));
w.writeComment(QStringLiteral(
"chat_compression / quick_refactor: a single agent name."));
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);
w.writeString(QString::fromUtf8(kChatCompression), rosters.chatCompression);
w.writeString(QString::fromUtf8(kQuickRefactor), rosters.quickRefactor);
QSaveFile out(path);
if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
@@ -259,10 +283,19 @@ bool PipelinesConfig::validate(const QodeAssist::AgentFactory &factory, QString
}
};
auto fixOne = [&](QString &current) {
const QString name = trimAndCap(current);
const QString next = (!name.isEmpty() && factory.configByName(name)) ? name : QString();
if (next != current) {
current = next;
changed = true;
}
};
fix(r.codeCompletion);
fix(r.chatAssistant);
fix(r.chatCompression);
fix(r.quickRefactor);
fixOne(r.chatCompression);
fixOne(r.quickRefactor);
if (!changed && lr.status == PipelinesLoadStatus::Ok)
return true;

View File

@@ -15,10 +15,15 @@ namespace QodeAssist::Settings {
struct PipelineRosters
{
// Code completion is auto-routed: the router walks this ordered list at
// request time and uses the first agent whose match rules fit the file.
QStringList codeCompletion;
// Chat is user-driven: this is an unordered allow-list of the agents
// offered in the chat picker. The user picks; no routing happens.
QStringList chatAssistant;
QStringList chatCompression;
QStringList quickRefactor;
// Compression and quick refactor each use a single fixed agent.
QString chatCompression;
QString quickRefactor;
[[nodiscard]] static PipelineRosters defaults();
};