feat: Add agents and agents settings

This commit is contained in:
Petr Mironychev
2026-05-26 12:30:11 +02:00
parent 51ebe3e523
commit 97236c6069
70 changed files with 4308 additions and 296 deletions

View File

@@ -0,0 +1,475 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "AgentDetailPane.hpp"
#include "SectionBox.hpp"
#include "SettingsTheme.hpp"
#include "SettingsUiBuilders.hpp"
#include <ProviderInstance.hpp>
#include <ProviderInstanceFactory.hpp>
#include <QColor>
#include <QComboBox>
#include <QEvent>
#include <QFile>
#include <QFont>
#include <QFrame>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QScopedValueRollback>
#include <QToolButton>
#include <QVBoxLayout>
namespace QodeAssist::Settings {
namespace {
constexpr qint64 kRawTomlMaxBytes = 256 * 1024;
enum class FileReadStatus { Ok, Empty, Truncated, OpenFailed };
struct FileReadResult
{
FileReadStatus status = FileReadStatus::OpenFailed;
QString content;
QString error;
};
FileReadResult readFileTextCapped(const QString &path, qint64 maxBytes)
{
FileReadResult result;
QFile f(path);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
result.status = FileReadStatus::OpenFailed;
result.error = f.errorString();
return result;
}
const qint64 size = f.size();
const QByteArray bytes = f.read(maxBytes);
result.content = QString::fromUtf8(bytes);
if (size == 0)
result.status = FileReadStatus::Empty;
else if (size > maxBytes)
result.status = FileReadStatus::Truncated;
else
result.status = FileReadStatus::Ok;
return result;
}
} // namespace
AgentDetailPane::AgentDetailPane(QWidget *parent)
: QWidget(parent)
{
m_name = new QLabel(this);
QFont nf = m_name->font();
nf.setBold(true);
nf.setPixelSize(15);
m_name->setFont(nf);
m_name->setTextInteractionFlags(Qt::TextSelectableByMouse);
m_path = new QLabel(this);
m_path->setFont(monospaceFont(11));
m_path->setTextInteractionFlags(Qt::TextSelectableByMouse);
QPalette pp = m_path->palette();
pp.setColor(QPalette::WindowText, pp.color(QPalette::Mid));
m_path->setPalette(pp);
m_openBtn = new QPushButton(tr("Open in editor"), this);
m_dupBtn = new QPushButton(tr("Duplicate…"), this);
m_deleteBtn = new QPushButton(tr("Delete"), this);
connect(m_openBtn, &QPushButton::clicked, this,
[this] { if (m_current) emit openInEditorRequested(*m_current); });
connect(m_dupBtn, &QPushButton::clicked, this,
[this] { if (m_current) emit customizeRequested(*m_current); });
connect(m_deleteBtn, &QPushButton::clicked, this,
[this] { if (m_current) emit deleteRequested(*m_current); });
auto *actions = new QHBoxLayout;
actions->setContentsMargins(0, 0, 0, 0);
actions->setSpacing(6);
actions->addWidget(m_openBtn);
actions->addWidget(m_dupBtn);
actions->addWidget(m_deleteBtn);
auto *titleRow = new QHBoxLayout;
titleRow->setContentsMargins(0, 0, 0, 0);
titleRow->setSpacing(8);
titleRow->addWidget(m_name);
titleRow->addStretch(1);
auto *headerLeft = new QVBoxLayout;
headerLeft->setContentsMargins(0, 0, 0, 0);
headerLeft->setSpacing(2);
headerLeft->addLayout(titleRow);
headerLeft->addWidget(m_path);
auto *headerRow = new QHBoxLayout;
headerRow->setContentsMargins(0, 0, 0, 0);
headerRow->setSpacing(8);
headerRow->addLayout(headerLeft, 1);
headerRow->addLayout(actions);
auto *headerSep = new QFrame(this);
headerSep->setFrameShape(QFrame::HLine);
headerSep->setFrameShadow(QFrame::Sunken);
m_description = new QLabel(this);
m_description->setWordWrap(true);
m_description->setTextInteractionFlags(Qt::TextSelectableByMouse);
auto *identity = new SectionBox(tr("Identity"), this);
m_nameValue = makeReadOnlyLine();
m_extendsLabel = new QLabel(tr("Extends:"), this);
m_extendsLabel->setMinimumWidth(96);
m_extendsLabel->setAlignment(Qt::AlignLeft | Qt::AlignTop);
m_extendsValue = makeReadOnlyLine();
m_descriptionEdit = new QPlainTextEdit(this);
m_descriptionEdit->setReadOnly(true);
m_descriptionEdit->setMaximumHeight(56);
m_tagsValue = makeReadOnlyLine();
auto *idGrid = new QGridLayout;
idGrid->setContentsMargins(0, 0, 0, 0);
idGrid->setHorizontalSpacing(8);
idGrid->setVerticalSpacing(4);
FormBuilder idForm(idGrid);
idForm.row(tr("Name:"), m_nameValue);
{
auto *holder = new QWidget;
holder->setLayout(singleField(m_extendsValue));
const int row = idForm.currentRow();
idGrid->addWidget(m_extendsLabel, row, 0, Qt::AlignTop);
idGrid->addWidget(holder, row, 1);
m_extendsHolder = holder;
idForm = FormBuilder(idGrid, row + 1);
}
idForm.row(tr("Description:"), m_descriptionEdit);
idForm.row(tr("Tags:"), m_tagsValue,
tr("Comma-separated. Free-form — used to filter and "
"group the agent list."));
identity->bodyLayout()->addLayout(idGrid);
auto *roleSection = new SectionBox(tr("System role"), this);
auto *roleHint = makeHintLabel(
tr("Prepended to every request as the system message."));
m_roleText = new QPlainTextEdit(this);
m_roleText->setReadOnly(true);
m_roleText->setMinimumHeight(120);
roleSection->bodyLayout()->addWidget(roleHint);
roleSection->bodyLayout()->addWidget(m_roleText);
auto *contextSection = new SectionBox(tr("Context"), this);
auto *contextHint = makeHintLabel(
tr("Jinja2 template rendered with ContextManager bindings into the "
"agent.context system-prompt layer. Empty = no context block."));
m_contextText = new QPlainTextEdit(this);
m_contextText->setReadOnly(true);
m_contextText->setFont(monospaceFont(11));
m_contextText->setMinimumHeight(120);
contextSection->bodyLayout()->addWidget(contextHint);
contextSection->bodyLayout()->addWidget(m_contextText);
auto *connection = new SectionBox(tr("Connection"), this);
m_providerCombo = new QComboBox(this);
m_providerCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents);
m_providerCombo->setEnabled(false);
m_endpointValue = makeReadOnlyLine(true);
m_modelValue = makeReadOnlyLine(true);
auto *connGrid = new QGridLayout;
connGrid->setContentsMargins(0, 0, 0, 0);
connGrid->setHorizontalSpacing(8);
connGrid->setVerticalSpacing(4);
FormBuilder(connGrid)
.row(tr("Provider:"), m_providerCombo,
tr("The provider instance this agent uses. URL is "
"inherited from the instance."))
.row(tr("Endpoint:"), m_endpointValue,
tr("Appended to the provider's URL. Blank uses the "
"provider default."))
.row(tr("Model:"), m_modelValue);
connection->bodyLayout()->addLayout(connGrid);
m_effectiveUrl = new QLabel(this);
m_effectiveUrl->setFont(monospaceFont(11));
m_effectiveUrl->setTextInteractionFlags(Qt::TextSelectableByMouse);
m_effectiveUrl->setWordWrap(true);
m_effectiveUrl->setContentsMargins(6, 4, 6, 4);
m_effectiveUrl->setAutoFillBackground(true);
connection->bodyLayout()->addWidget(m_effectiveUrl);
auto *match = new SectionBox(tr("Match"), this);
auto *matchHint = makeHintLabel(
tr("When a feature slot has multiple bound agents, the first whose "
"match rules satisfy the current context wins."));
m_filePatternsValue = makeReadOnlyLine(true);
auto *matchGrid = new QGridLayout;
matchGrid->setContentsMargins(0, 0, 0, 0);
matchGrid->setHorizontalSpacing(8);
matchGrid->setVerticalSpacing(4);
FormBuilder(matchGrid).row(tr("File patterns:"), m_filePatternsValue,
tr("Globs, comma-separated. Empty matches every file."));
match->bodyLayout()->addWidget(matchHint);
match->bodyLayout()->addLayout(matchGrid);
auto *templ = new SectionBox(tr("Template"), this);
auto *templHint = makeHintLabel(
tr("Jinja2 template (via inja) rendered to the request body. "
"Built-in context: ctx.prefix, ctx.suffix, ctx.history, "
"ctx.system_prompt, agent.model."));
m_messageFormat = new QPlainTextEdit(this);
m_messageFormat->setReadOnly(true);
m_messageFormat->setFont(monospaceFont(11));
m_messageFormat->setMinimumHeight(140);
templ->bodyLayout()->addWidget(templHint);
auto *mfLabel = new QLabel(tr("message_format:"), this);
templ->bodyLayout()->addWidget(mfLabel);
templ->bodyLayout()->addWidget(m_messageFormat);
m_diagnostics = new SectionBox(tr("Load errors"), this);
m_diagnosticsView = new QPlainTextEdit(this);
m_diagnosticsView->setReadOnly(true);
m_diagnosticsView->setMaximumHeight(110);
m_diagnosticsView->setFont(monospaceFont(11));
m_diagnostics->bodyLayout()->addWidget(m_diagnosticsView);
m_diagnostics->setVisible(false);
m_rawToggle = new QToolButton(this);
m_rawToggle->setText(tr("▸ Show raw TOML"));
m_rawToggle->setCursor(Qt::PointingHandCursor);
m_rawToggle->setAutoRaise(true);
m_rawToggle->setCheckable(true);
m_rawToml = new QPlainTextEdit(this);
m_rawToml->setReadOnly(true);
m_rawToml->setFont(monospaceFont(11));
m_rawToml->setMinimumHeight(140);
m_rawToml->setVisible(false);
connect(m_rawToggle, &QToolButton::toggled, this, [this](bool on) {
m_rawToml->setVisible(on);
m_rawToggle->setText(on ? tr("▾ Hide raw TOML") : tr("▸ Show raw TOML"));
});
auto *root = new QVBoxLayout(this);
root->setContentsMargins(12, 12, 12, 12);
root->setSpacing(10);
root->addLayout(headerRow);
root->addWidget(headerSep);
root->addWidget(m_description);
root->addWidget(identity);
root->addWidget(connection);
root->addWidget(match);
root->addWidget(templ);
root->addWidget(roleSection);
root->addWidget(contextSection);
root->addWidget(m_diagnostics);
root->addWidget(m_rawToggle, 0, Qt::AlignLeft);
root->addWidget(m_rawToml);
root->addStretch(1);
clear();
applyCodePalette();
}
void AgentDetailPane::setInstanceFactory(Providers::ProviderInstanceFactory *factory)
{
m_instanceFactory = factory;
m_providerComboPopulated = false;
populateProviderCombo();
}
void AgentDetailPane::populateProviderCombo()
{
if (m_providerComboPopulated)
return;
m_providerCombo->clear();
m_providerComboHasSentinel = false;
if (m_instanceFactory) {
for (const auto &inst : m_instanceFactory->instances()) {
m_providerCombo->addItem(
QStringLiteral("%1 (%2)").arg(inst.name, inst.clientApi), inst.name);
}
}
m_providerComboPopulated = true;
}
void AgentDetailPane::setAgent(const AgentConfig &cfg)
{
m_currentStorage = cfg;
m_current = &m_currentStorage;
const bool user = cfg.isUserSource();
m_name->setText(cfg.name);
m_path->setText(cfg.sourcePath);
m_description->setText(cfg.description.isEmpty()
? tr("No description provided.")
: cfg.description);
m_nameValue->setText(cfg.name);
if (cfg.extendsName.isEmpty()) {
m_extendsLabel->setVisible(false);
m_extendsHolder->setVisible(false);
} else {
m_extendsLabel->setVisible(true);
m_extendsHolder->setVisible(true);
m_extendsValue->setText(cfg.extendsName);
}
m_descriptionEdit->setPlainText(cfg.description);
m_tagsValue->setText(cfg.tags.join(QStringLiteral(", ")));
populateProviderCombo();
if (m_providerComboHasSentinel) {
m_providerCombo->removeItem(0);
m_providerComboHasSentinel = false;
}
QString resolvedUrl;
if (m_instanceFactory) {
if (const auto *inst = m_instanceFactory->instanceByName(cfg.providerInstance))
resolvedUrl = inst->url;
}
const int idx = m_providerCombo->findData(cfg.providerInstance);
if (idx >= 0) {
m_providerCombo->setCurrentIndex(idx);
} else if (!cfg.providerInstance.isEmpty()) {
m_providerCombo->insertItem(
0, tr("%1 (missing — not in provider library)")
.arg(cfg.providerInstance),
cfg.providerInstance);
m_providerCombo->setCurrentIndex(0);
m_providerComboHasSentinel = true;
}
m_endpointValue->setText(cfg.endpoint);
m_endpointValue->setPlaceholderText(tr("(provider default)"));
m_modelValue->setText(cfg.model);
const QString eff = resolvedUrl + cfg.endpoint;
m_effectiveUrl->setText(
eff.isEmpty()
? tr("# effective request line\n(unknown — provider instance not found)")
: QStringLiteral("# %1\nPOST %2")
.arg(tr("effective request line"), eff));
m_roleText->setPlainText(
cfg.role.isEmpty() ? tr("(no system role set)") : cfg.role);
m_contextText->setPlainText(
cfg.context.isEmpty() ? tr("(no context block)") : cfg.context);
m_filePatternsValue->setText(cfg.match.filePatterns.join(QStringLiteral(", ")));
m_filePatternsValue->setPlaceholderText(tr("(matches every file)"));
m_messageFormat->setPlainText(
cfg.messageFormat.isEmpty() ? tr("(inherited from parent / none)")
: cfg.messageFormat);
const FileReadResult raw = readFileTextCapped(cfg.sourcePath, kRawTomlMaxBytes);
switch (raw.status) {
case FileReadStatus::Ok:
m_rawToml->setPlainText(raw.content);
break;
case FileReadStatus::Truncated:
m_rawToml->setPlainText(
raw.content + QStringLiteral("\n\n")
+ tr("(truncated at %1 bytes)").arg(kRawTomlMaxBytes));
break;
case FileReadStatus::Empty:
m_rawToml->setPlainText(tr("(source file is empty)"));
break;
case FileReadStatus::OpenFailed:
m_rawToml->setPlainText(tr("(source file unavailable: %1)").arg(raw.error));
break;
}
m_openBtn->setEnabled(user);
m_openBtn->setToolTip(user ? QString()
: tr("Bundled agents are read-only — "
"duplicate to edit."));
m_deleteBtn->setEnabled(user);
m_deleteBtn->setToolTip(user ? QString()
: tr("Bundled agents cannot be deleted."));
m_dupBtn->setEnabled(true);
applyCodePalette();
}
void AgentDetailPane::clear()
{
m_currentStorage = AgentConfig{};
m_current = nullptr;
m_name->setText(tr("Select an agent"));
m_path->clear();
m_description->setText(tr("Pick an agent from the list to see its details."));
m_nameValue->clear();
m_extendsLabel->setVisible(false);
m_extendsHolder->setVisible(false);
m_descriptionEdit->clear();
m_tagsValue->clear();
if (m_providerComboHasSentinel) {
m_providerCombo->removeItem(0);
m_providerComboHasSentinel = false;
}
m_providerCombo->setCurrentIndex(-1);
m_endpointValue->clear();
m_modelValue->clear();
m_effectiveUrl->clear();
m_roleText->clear();
m_contextText->clear();
m_filePatternsValue->clear();
m_messageFormat->clear();
m_rawToml->clear();
m_openBtn->setEnabled(false);
m_dupBtn->setEnabled(false);
m_deleteBtn->setEnabled(false);
}
void AgentDetailPane::setLoadDiagnostics(const QStringList &errors, const QStringList &warnings)
{
QStringList lines;
for (const QString &e : errors)
lines << tr("error: %1").arg(e);
for (const QString &w : warnings)
lines << tr("warning: %1").arg(w);
m_diagnostics->setVisible(!lines.isEmpty());
m_diagnosticsView->setPlainText(lines.join(QLatin1Char('\n')));
}
void AgentDetailPane::changeEvent(QEvent *event)
{
QWidget::changeEvent(event);
if (m_inApplyPalette)
return;
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
applyCodePalette();
}
QLineEdit *AgentDetailPane::makeReadOnlyLine(bool mono)
{
auto *e = new QLineEdit(this);
e->setReadOnly(true);
if (mono)
e->setFont(monospaceFont(11));
return e;
}
void AgentDetailPane::applyCodePalette()
{
QScopedValueRollback<bool> guard(m_inApplyPalette, true);
const Theme theme = themeFor(palette());
QPalette p = m_effectiveUrl->palette();
p.setColor(QPalette::Window, QColor(theme.codeBg));
p.setColor(QPalette::WindowText, palette().color(QPalette::Text));
m_effectiveUrl->setPalette(p);
m_effectiveUrl->setStyleSheet(QStringLiteral(
"QLabel { background:%1; border:1px solid %2; }")
.arg(theme.codeBg, theme.rowSeparator));
}
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,91 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QPointer>
#include <QStringList>
#include <QWidget>
#include <AgentConfig.hpp>
class QComboBox;
class QLabel;
class QLineEdit;
class QPlainTextEdit;
class QPushButton;
class QToolButton;
namespace QodeAssist::Providers {
class ProviderInstanceFactory;
}
namespace QodeAssist::Settings {
class SectionBox;
class AgentDetailPane : public QWidget
{
Q_OBJECT
public:
explicit AgentDetailPane(QWidget *parent = nullptr);
void setInstanceFactory(Providers::ProviderInstanceFactory *factory);
void setAgent(const AgentConfig &cfg);
void clear();
void setLoadDiagnostics(const QStringList &errors, const QStringList &warnings);
signals:
void openInEditorRequested(const AgentConfig &cfg);
void customizeRequested(const AgentConfig &cfg);
void deleteRequested(const AgentConfig &cfg);
protected:
void changeEvent(QEvent *event) override;
private:
QLineEdit *makeReadOnlyLine(bool mono = false);
void applyCodePalette();
void populateProviderCombo();
bool m_inApplyPalette = false;
bool m_providerComboPopulated = false;
bool m_providerComboHasSentinel = false;
AgentConfig m_currentStorage;
const AgentConfig *m_current = nullptr;
QLabel *m_name = nullptr;
QLabel *m_path = nullptr;
QPushButton *m_openBtn = nullptr;
QPushButton *m_dupBtn = nullptr;
QPushButton *m_deleteBtn = nullptr;
QLabel *m_description = nullptr;
QLineEdit *m_nameValue = nullptr;
QLabel *m_extendsLabel = nullptr;
QWidget *m_extendsHolder = nullptr;
QLineEdit *m_extendsValue = nullptr;
QPlainTextEdit *m_descriptionEdit = nullptr;
QLineEdit *m_tagsValue = nullptr;
QComboBox *m_providerCombo = nullptr;
QPointer<Providers::ProviderInstanceFactory> m_instanceFactory;
QLineEdit *m_endpointValue = nullptr;
QLineEdit *m_modelValue = nullptr;
QLabel *m_effectiveUrl = nullptr;
QLineEdit *m_filePatternsValue = nullptr;
QPlainTextEdit *m_roleText = nullptr;
QPlainTextEdit *m_contextText = nullptr;
QPlainTextEdit *m_messageFormat = nullptr;
SectionBox *m_diagnostics = nullptr;
QPlainTextEdit *m_diagnosticsView = nullptr;
QToolButton *m_rawToggle = nullptr;
QPlainTextEdit *m_rawToml = nullptr;
};
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,120 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "AgentDuplicator.hpp"
#include <Agent.hpp>
#include <AgentConfig.hpp>
#include <AgentFactory.hpp>
#include <QByteArray>
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
namespace QodeAssist::Settings {
namespace {
QString tomlEscape(const QString &s)
{
QString out;
out.reserve(s.size());
for (QChar c : s) {
switch (c.unicode()) {
case '\\': out += QLatin1String("\\\\"); break;
case '"': out += QLatin1String("\\\""); break;
case '\n': out += QLatin1String("\\n"); break;
case '\r': out += QLatin1String("\\r"); break;
case '\t': out += QLatin1String("\\t"); break;
default: out += c;
}
}
return out;
}
constexpr int kMaxUniqueAttempts = 1000;
QString uniqueFilename(const QString &userDir, const QString &parentBasename)
{
QString fileName = parentBasename + QStringLiteral("_custom.toml");
for (int n = 2; n < kMaxUniqueAttempts
&& QFile::exists(QDir(userDir).filePath(fileName));
++n)
fileName = QStringLiteral("%1_custom_%2.toml").arg(parentBasename).arg(n);
return QDir(userDir).filePath(fileName);
}
QString uniqueName(const QString &parentName, const AgentFactory &factory)
{
QString newName = QStringLiteral("%1 (Custom)").arg(parentName);
for (int n = 2; n < kMaxUniqueAttempts && factory.configByName(newName); ++n)
newName = QStringLiteral("%1 (Custom %2)").arg(parentName).arg(n);
return newName;
}
QString trUser(const char *src)
{
return QCoreApplication::translate("QodeAssist::Settings::AgentDuplicator", src);
}
} // namespace
AgentDuplicateResult duplicateAgentInUserDir(
const AgentConfig &parent, const AgentFactory &factory)
{
AgentDuplicateResult result;
if (parent.name.trimmed().isEmpty()) {
result.error = trUser("Parent agent has no name; cannot duplicate.");
return result;
}
const QString userDir = AgentFactory::userAgentsDir();
if (!QDir().mkpath(userDir)) {
result.error = trUser("Cannot create user agents folder: %1").arg(userDir);
return result;
}
const QString parentBasename = QFileInfo(parent.sourcePath).baseName();
result.filePath = uniqueFilename(userDir, parentBasename);
if (QFile::exists(result.filePath)) {
result.error = trUser("Could not find a free filename after %1 attempts.")
.arg(kMaxUniqueAttempts);
return result;
}
result.newName = uniqueName(parent.name, factory);
if (factory.configByName(result.newName)) {
result.error = trUser("Could not find a free agent name after %1 attempts.")
.arg(kMaxUniqueAttempts);
return result;
}
QSaveFile f(result.filePath);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
result.error = trUser("Cannot create %1: %2").arg(result.filePath, f.errorString());
return result;
}
const QString description
= QStringLiteral("User customization of '%1'. Override fields below to taste; "
"values not overridden are inherited from the parent.")
.arg(parent.name);
const QString body = QStringLiteral(
"schema_version = 1\n"
"name = \"%1\"\n"
"extends = \"%2\"\n"
"description = \"%3\"\n")
.arg(tomlEscape(result.newName),
tomlEscape(parent.name),
tomlEscape(description));
const QByteArray payload = body.toUtf8();
if (f.write(payload) != payload.size() || !f.commit()) {
result.error = trUser("Failed to write %1: %2").arg(result.filePath, f.errorString());
return result;
}
result.ok = true;
return result;
}
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,26 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
namespace QodeAssist {
class AgentFactory;
struct AgentConfig;
}
namespace QodeAssist::Settings {
struct AgentDuplicateResult
{
bool ok = false;
QString filePath;
QString newName;
QString error;
};
AgentDuplicateResult duplicateAgentInUserDir(
const AgentConfig &parent, const AgentFactory &factory);
} // namespace QodeAssist::Settings

127
settings/AgentListItem.cpp Normal file
View File

@@ -0,0 +1,127 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "AgentListItem.hpp"
#include "SettingsTheme.hpp"
#include "TagChip.hpp"
#include <QEvent>
#include <QFont>
#include <QHBoxLayout>
#include <QLabel>
#include <QMouseEvent>
#include <QPalette>
#include <QScopedValueRollback>
#include <QVBoxLayout>
namespace QodeAssist::Settings {
AgentListItem::AgentListItem(const AgentConfig &cfg, QWidget *parent)
: QFrame(parent)
, m_name(cfg.name)
{
setObjectName(QStringLiteral("AgentListItem"));
setFrameShape(QFrame::NoFrame);
setAutoFillBackground(true);
setCursor(Qt::PointingHandCursor);
auto *dot = new QLabel(QStringLiteral(""), this);
QFont df = dot->font();
df.setPixelSize(10);
dot->setFont(df);
QPalette dp = dot->palette();
dp.setColor(QPalette::WindowText, dp.color(QPalette::Mid));
dot->setPalette(dp);
auto *nameLbl = new QLabel(cfg.name, this);
QFont nf = nameLbl->font();
nf.setBold(true);
nf.setPixelSize(12);
nameLbl->setFont(nf);
auto *headerRow = new QHBoxLayout;
headerRow->setContentsMargins(0, 0, 0, 0);
headerRow->setSpacing(6);
headerRow->addWidget(dot, 0, Qt::AlignVCenter);
headerRow->addWidget(nameLbl, 1);
auto *col = new QVBoxLayout;
col->setContentsMargins(0, 0, 0, 0);
col->setSpacing(2);
col->addLayout(headerRow);
if (!cfg.model.isEmpty()) {
auto *model = new QLabel(cfg.model, this);
model->setFont(monospaceFont(11));
model->setContentsMargins(16, 0, 0, 0);
QPalette mp = model->palette();
mp.setColor(QPalette::WindowText, mp.color(QPalette::Mid));
model->setPalette(mp);
col->addWidget(model);
}
if (!cfg.tags.isEmpty()) {
auto *tagsHolder = new QWidget(this);
auto *tagsLay = new QHBoxLayout(tagsHolder);
tagsLay->setContentsMargins(16, 2, 0, 0);
tagsLay->setSpacing(3);
for (const QString &t : cfg.tags) {
auto *chip = new TagChip(t, -1, tagsHolder);
connect(chip, &TagChip::clicked, this, &AgentListItem::tagClicked);
m_chips.append(chip);
tagsLay->addWidget(chip);
}
tagsLay->addStretch(1);
col->addWidget(tagsHolder);
}
auto *outer = new QVBoxLayout(this);
outer->setContentsMargins(8, 6, 8, 6);
outer->setSpacing(0);
outer->addLayout(col);
applyTheme();
}
void AgentListItem::setSelected(bool selected)
{
if (m_selected == selected)
return;
m_selected = selected;
applyTheme();
}
void AgentListItem::setActiveTags(const QSet<QString> &active)
{
for (auto *chip : m_chips)
chip->setActive(active.contains(chip->tag()));
}
void AgentListItem::mouseReleaseEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton)
emit clicked(m_name);
QFrame::mouseReleaseEvent(event);
}
void AgentListItem::changeEvent(QEvent *event)
{
QFrame::changeEvent(event);
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
applyTheme();
}
void AgentListItem::applyTheme()
{
if (m_inApplyTheme)
return;
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
const Theme theme = themeFor(palette());
setStyleSheet(QStringLiteral(
"#AgentListItem { background:%1; border-top:1px solid %2; }")
.arg(m_selected ? theme.rowSelectedBg : QStringLiteral("transparent"),
theme.rowSeparator));
}
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,44 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QFrame>
#include <QList>
#include <QSet>
#include <QString>
#include <AgentConfig.hpp>
namespace QodeAssist::Settings {
class TagChip;
class AgentListItem : public QFrame
{
Q_OBJECT
public:
explicit AgentListItem(const AgentConfig &cfg, QWidget *parent = nullptr);
QString agentName() const { return m_name; }
void setSelected(bool selected);
void setActiveTags(const QSet<QString> &active);
signals:
void clicked(const QString &name);
void tagClicked(const QString &tag);
protected:
void mouseReleaseEvent(QMouseEvent *event) override;
void changeEvent(QEvent *event) override;
private:
void applyTheme();
QString m_name;
bool m_selected = false;
bool m_inApplyTheme = false;
QList<TagChip *> m_chips;
};
} // namespace QodeAssist::Settings

236
settings/AgentListPane.cpp Normal file
View File

@@ -0,0 +1,236 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "AgentListPane.hpp"
#include "AgentListItem.hpp"
#include "SettingsTheme.hpp"
#include "SettingsUiBuilders.hpp"
#include "TagFilterStrip.hpp"
#include <Agent.hpp>
#include <AgentFactory.hpp>
#include <QEvent>
#include <QFont>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QMap>
#include <QPalette>
#include <QScrollArea>
#include <QTimer>
#include <QVBoxLayout>
#include <algorithm>
namespace QodeAssist::Settings {
AgentListPane::AgentListPane(AgentFactory *factory, QWidget *parent)
: QFrame(parent)
, m_factory(factory)
{
setFrameShape(QFrame::StyledPanel);
m_filterEdit = new QLineEdit(this);
m_filterEdit->setPlaceholderText(tr("Filter agents…"));
m_filterEdit->setClearButtonEnabled(true);
auto *filterRow = new QHBoxLayout;
filterRow->setContentsMargins(6, 6, 6, 6);
filterRow->addWidget(m_filterEdit, 1);
m_filterHolder = new QWidget(this);
m_filterHolder->setObjectName(QStringLiteral("FilterHolder"));
m_filterHolder->setLayout(filterRow);
m_filterHolder->setAutoFillBackground(true);
m_tagStrip = new TagFilterStrip(this);
m_listScroll = new QScrollArea(this);
m_listScroll->setWidgetResizable(true);
m_listScroll->setFrameShape(QFrame::NoFrame);
m_listScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
auto *outer = new QVBoxLayout(this);
outer->setContentsMargins(0, 0, 0, 0);
outer->setSpacing(0);
outer->addWidget(m_filterHolder);
outer->addWidget(m_tagStrip);
outer->addWidget(m_listScroll, 1);
m_filterDebounce = new QTimer(this);
m_filterDebounce->setSingleShot(true);
m_filterDebounce->setInterval(100);
connect(m_filterDebounce, &QTimer::timeout, this, &AgentListPane::rebuildList);
connect(m_filterEdit, &QLineEdit::textChanged, this,
[this](const QString &) { m_filterDebounce->start(); });
connect(m_tagStrip, &TagFilterStrip::activeTagsChanged, this,
[this](const QSet<QString> &) { rebuildList(); },
Qt::QueuedConnection);
applyFilterHolderTheme();
}
void AgentListPane::selectByName(const QString &name)
{
if (name.isEmpty())
return;
setCurrentNameInternal(name, false);
rebuildList();
for (auto *item : m_rows) {
if (item->agentName() == name) {
QTimer::singleShot(0, this, [this, item] {
m_listScroll->ensureWidgetVisible(item, 0, 60);
});
break;
}
}
}
void AgentListPane::refresh()
{
QMap<QString, int> counts;
for (const auto *a : visibleAgents())
for (const QString &t : a->tags)
counts[t] += 1;
m_tagStrip->setAvailableTags(counts);
rebuildList();
}
void AgentListPane::changeEvent(QEvent *event)
{
QFrame::changeEvent(event);
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
applyFilterHolderTheme();
}
void AgentListPane::applyFilterHolderTheme()
{
if (!m_filterHolder)
return;
const Theme theme = themeFor(palette());
m_filterHolder->setStyleSheet(
QStringLiteral("QWidget#FilterHolder { background:%1;"
" border-bottom:1px solid %2; }")
.arg(theme.listHeaderBg, theme.rowSeparator));
}
std::vector<const AgentConfig *> AgentListPane::visibleAgents() const
{
std::vector<const AgentConfig *> out;
if (!m_factory)
return out;
for (const auto &a : m_factory->configs()) {
if (a.hidden)
continue;
out.push_back(&a);
}
return out;
}
bool AgentListPane::matchesFilters(const AgentConfig &a, const QString &lowerFilter) const
{
if (!lowerFilter.isEmpty()
&& !(a.name + QLatin1Char(' ') + a.model).toLower().contains(lowerFilter))
return false;
const QSet<QString> &active = m_tagStrip->activeTags();
for (const QString &t : active)
if (!a.tags.contains(t))
return false;
return true;
}
void AgentListPane::rebuildList()
{
const QString lowerFilter = m_filterEdit->text().trimmed().toLower();
std::vector<const AgentConfig *> userAgents;
std::vector<const AgentConfig *> bundledAgents;
for (const auto *a : visibleAgents()) {
if (!matchesFilters(*a, lowerFilter))
continue;
if (a->isUserSource())
userAgents.push_back(a);
else
bundledAgents.push_back(a);
}
auto byName = [](const AgentConfig *a, const AgentConfig *b) {
return a->name.localeAwareCompare(b->name) < 0;
};
std::sort(userAgents.begin(), userAgents.end(), byName);
std::sort(bundledAgents.begin(), bundledAgents.end(), byName);
QList<AgentListItem *> newRows;
auto *content = new QWidget;
content->setAutoFillBackground(true);
auto *contentLayout = new QVBoxLayout(content);
contentLayout->setContentsMargins(0, 0, 0, 0);
contentLayout->setSpacing(0);
const QSet<QString> &activeTags = m_tagStrip->activeTags();
auto addAgents = [&](const std::vector<const AgentConfig *> &agents) {
for (const AgentConfig *cfg : agents) {
auto *item = new AgentListItem(*cfg, content);
item->setSelected(cfg->name == m_currentName);
item->setActiveTags(activeTags);
connect(item, &AgentListItem::clicked, this, &AgentListPane::onRowClicked);
connect(item, &AgentListItem::tagClicked, this,
[this](const QString &) { refresh(); },
Qt::QueuedConnection);
contentLayout->addWidget(item);
newRows.append(item);
}
};
if (!userAgents.empty()) {
contentLayout->addWidget(makeSectionHeader(tr("User"), content));
addAgents(userAgents);
}
if (!bundledAgents.empty()) {
contentLayout->addWidget(makeSectionHeader(tr("Bundled"), content));
addAgents(bundledAgents);
}
if (newRows.isEmpty()) {
auto *empty = new QLabel(tr("No agents match these filters."), content);
empty->setAlignment(Qt::AlignCenter);
empty->setContentsMargins(10, 16, 10, 16);
QPalette ep = empty->palette();
ep.setColor(QPalette::WindowText, ep.color(QPalette::Mid));
empty->setPalette(ep);
contentLayout->addWidget(empty);
}
contentLayout->addStretch(1);
m_rows = newRows;
m_listScroll->setWidget(content);
const AgentConfig *current
= m_currentName.isEmpty() || !m_factory
? nullptr
: m_factory->configByName(m_currentName);
if (!current && !m_rows.isEmpty()) {
const QString fallback = m_rows.front()->agentName();
m_rows.front()->setSelected(true);
setCurrentNameInternal(fallback, /*emitSignal*/ true);
return;
}
emit currentAgentChanged(m_currentName);
}
void AgentListPane::onRowClicked(const QString &name)
{
setCurrentNameInternal(name, /*emitSignal*/ true);
}
void AgentListPane::setCurrentNameInternal(const QString &name, bool emitSignal)
{
if (name == m_currentName)
return;
m_currentName = name;
for (auto *item : m_rows)
item->setSelected(item->agentName() == name);
if (emitSignal)
emit currentAgentChanged(m_currentName);
}
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,62 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QFrame>
#include <QList>
#include <QSet>
#include <QString>
#include <vector>
#include <AgentConfig.hpp>
class QLineEdit;
class QScrollArea;
class QTimer;
class QVBoxLayout;
namespace QodeAssist {
class AgentFactory;
}
namespace QodeAssist::Settings {
class AgentListItem;
class TagFilterStrip;
class AgentListPane : public QFrame
{
Q_OBJECT
public:
explicit AgentListPane(AgentFactory *factory, QWidget *parent = nullptr);
QString currentName() const { return m_currentName; }
void selectByName(const QString &name);
void refresh();
signals:
void currentAgentChanged(const QString &name);
protected:
void changeEvent(QEvent *event) override;
private:
void rebuildList();
void applyFilterHolderTheme();
bool matchesFilters(const AgentConfig &a, const QString &lowerFilter) const;
std::vector<const AgentConfig *> visibleAgents() const;
void setCurrentNameInternal(const QString &name, bool emitSignal);
void onRowClicked(const QString &name);
AgentFactory *m_factory;
QLineEdit *m_filterEdit = nullptr;
QTimer *m_filterDebounce = nullptr;
QWidget *m_filterHolder = nullptr;
TagFilterStrip *m_tagStrip = nullptr;
QScrollArea *m_listScroll = nullptr;
QList<AgentListItem *> m_rows;
QString m_currentName;
};
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,280 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "AgentsSettingsPage.hpp"
#include "AgentDetailPane.hpp"
#include "AgentDuplicator.hpp"
#include "AgentListPane.hpp"
#include "SettingsTheme.hpp"
#include "SettingsConstants.hpp"
#include <coreplugin/dialogs/ioptionspage.h>
#include <coreplugin/editormanager/editormanager.h>
#include <utils/filepath.h>
#include <QDesktopServices>
#include <QDir>
#include <QFile>
#include <QFont>
#include <QFontMetrics>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QMessageBox>
#include <QPalette>
#include <QPointer>
#include <QPushButton>
#include <QScrollArea>
#include <QSplitter>
#include <QTimer>
#include <QUrl>
#include <QVBoxLayout>
#include <Agent.hpp>
#include <AgentFactory.hpp>
namespace QodeAssist::Settings {
AgentsPageNavigator::AgentsPageNavigator(QObject *parent)
: QObject(parent)
{}
void AgentsPageNavigator::requestSelectAgent(const QString &name)
{
m_pending = name;
emit selectAgentRequested(name);
}
QString AgentsPageNavigator::takePendingSelection()
{
QString p = m_pending;
m_pending.clear();
return p;
}
namespace {
class AgentsWidget : public Core::IOptionsPageWidget
{
Q_OBJECT
public:
explicit AgentsWidget(AgentFactory *agentFactory, AgentsPageNavigator *navigator)
: m_agentFactory(agentFactory)
, m_navigator(navigator)
{
Q_ASSERT(m_agentFactory);
m_titleLabel = new QLabel(tr("Agents"), this);
QFont tf = m_titleLabel->font();
tf.setBold(true);
tf.setPixelSize(13);
m_titleLabel->setFont(tf);
m_reload = new QPushButton(tr("Reload from disk"), this);
m_openUserDir = new QPushButton(tr("Open agents folder"), this);
m_userPathLabel = new QLabel(this);
m_userPathLabel->setFont(monospaceFont(11));
QPalette mutedPal = m_userPathLabel->palette();
mutedPal.setColor(QPalette::WindowText, mutedPal.color(QPalette::Mid));
m_userPathLabel->setPalette(mutedPal);
m_userPathLabel->setMaximumWidth(260);
auto *headerRow = new QHBoxLayout;
headerRow->setContentsMargins(0, 0, 0, 0);
headerRow->setSpacing(8);
headerRow->addWidget(m_titleLabel);
headerRow->addStretch(1);
headerRow->addWidget(m_reload);
headerRow->addWidget(m_userPathLabel);
headerRow->addWidget(m_openUserDir);
auto *headerSep = new QFrame(this);
headerSep->setFrameShape(QFrame::HLine);
headerSep->setFrameShadow(QFrame::Sunken);
m_listPane = new AgentListPane(m_agentFactory, this);
m_detail = new AgentDetailPane(this);
m_detail->setInstanceFactory(m_agentFactory->instanceFactory());
m_detailScroll = new QScrollArea(this);
m_detailScroll->setWidgetResizable(true);
m_detailScroll->setFrameShape(QFrame::StyledPanel);
m_detailScroll->setWidget(m_detail);
auto *splitter = new QSplitter(Qt::Horizontal, this);
splitter->addWidget(m_listPane);
splitter->addWidget(m_detailScroll);
splitter->setStretchFactor(0, 0);
splitter->setStretchFactor(1, 1);
splitter->setSizes({320, 700});
auto *root = new QVBoxLayout(this);
root->setContentsMargins(8, 8, 8, 8);
root->setSpacing(6);
root->addLayout(headerRow);
root->addWidget(headerSep);
root->addWidget(splitter, 1);
connect(m_reload, &QPushButton::clicked, this, &AgentsWidget::reloadFromDisk);
connect(m_openUserDir, &QPushButton::clicked, this, [] {
const QString dir = QodeAssist::AgentFactory::userAgentsDir();
QDir().mkpath(dir);
QDesktopServices::openUrl(QUrl::fromLocalFile(dir));
});
connect(m_listPane, &AgentListPane::currentAgentChanged, this,
[this](const QString &name) {
if (const AgentConfig *cfg = m_agentFactory->configByName(name))
m_detail->setAgent(*cfg);
else
m_detail->clear();
});
connect(m_detail, &AgentDetailPane::openInEditorRequested,
this, &AgentsWidget::openAgentInEditor);
connect(m_detail, &AgentDetailPane::customizeRequested,
this, &AgentsWidget::customizeAgent);
connect(m_detail, &AgentDetailPane::deleteRequested,
this, &AgentsWidget::deleteAgent);
if (m_navigator) {
connect(m_navigator, &AgentsPageNavigator::selectAgentRequested,
m_listPane, &AgentListPane::selectByName);
}
reloadFromDisk();
if (m_navigator) {
QTimer::singleShot(0, this, [this] {
if (!m_navigator)
return;
const QString pending = m_navigator->takePendingSelection();
if (!pending.isEmpty())
m_listPane->selectByName(pending);
});
}
}
void apply() final {}
private:
void reloadFromDisk()
{
m_agentFactory->reload();
m_detail->setLoadDiagnostics(
m_agentFactory->lastLoadErrors(), m_agentFactory->lastLoadWarnings());
updateUserPathLabel();
m_listPane->refresh();
}
void updateUserPathLabel()
{
const QString dir = QodeAssist::AgentFactory::userAgentsDir();
m_userPathLabel->setText(
QFontMetrics(m_userPathLabel->font()).elidedText(dir, Qt::ElideLeft, 256));
m_userPathLabel->setToolTip(dir);
}
void openAgentInEditor(const AgentConfig &agent)
{
const QString name = agent.name;
const QString sourcePath = agent.sourcePath;
const bool isUser = agent.isUserSource();
if (!isUser) {
QMessageBox::information(
this, tr("Open agent"),
tr("'%1' is bundled with the plugin and read-only.\n"
"Use Duplicate to create an editable user copy.")
.arg(name));
return;
}
if (sourcePath.isEmpty() || sourcePath.startsWith(QLatin1String(":/"))) {
QMessageBox::warning(
this, tr("Open agent"),
tr("Agent '%1' has no editable source file.").arg(name));
return;
}
if (!Core::EditorManager::openEditor(Utils::FilePath::fromString(sourcePath))) {
QMessageBox::warning(
this, tr("Open agent"),
tr("Could not open %1.").arg(sourcePath));
}
}
void customizeAgent(const AgentConfig &parent)
{
const AgentDuplicateResult res = duplicateAgentInUserDir(parent, *m_agentFactory);
if (!res.ok) {
QMessageBox::warning(this, tr("Duplicate"), res.error);
return;
}
const QString newName = res.newName;
reloadFromDisk();
m_listPane->selectByName(newName);
}
void deleteAgent(const AgentConfig &agent)
{
if (!agent.isUserSource())
return;
const QString name = agent.name;
const QString sourcePath = agent.sourcePath;
if (QMessageBox::question(
this, tr("Delete Agent"),
tr("Delete agent '%1'?\n\nThis will remove the file:\n%2")
.arg(name, sourcePath),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No)
!= QMessageBox::Yes)
return;
if (!QFile::remove(sourcePath)) {
QMessageBox::warning(
this, tr("Delete Agent"),
tr("Could not delete the agent file:\n%1").arg(sourcePath));
return;
}
reloadFromDisk();
}
AgentFactory *m_agentFactory;
QPointer<AgentsPageNavigator> m_navigator;
QLabel *m_titleLabel = nullptr;
QPushButton *m_reload = nullptr;
QPushButton *m_openUserDir = nullptr;
QLabel *m_userPathLabel = nullptr;
AgentListPane *m_listPane = nullptr;
QScrollArea *m_detailScroll = nullptr;
AgentDetailPane *m_detail = nullptr;
};
class AgentsSettingsPage : public Core::IOptionsPage
{
public:
AgentsSettingsPage(AgentFactory *agentFactory, AgentsPageNavigator *navigator)
{
setId(Constants::QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID);
setDisplayName(QObject::tr("Agents"));
setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY);
setWidgetCreator([agentFactory, navigator]() {
return new AgentsWidget(agentFactory, navigator);
});
}
};
} // namespace
std::unique_ptr<Core::IOptionsPage> createAgentsSettingsPage(
AgentFactory *agentFactory, AgentsPageNavigator *navigator)
{
return std::make_unique<AgentsSettingsPage>(agentFactory, navigator);
}
} // namespace QodeAssist::Settings
#include "AgentsSettingsPage.moc"

View File

@@ -0,0 +1,38 @@
// 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 : public QObject
{
Q_OBJECT
public:
explicit AgentsPageNavigator(QObject *parent = nullptr);
void requestSelectAgent(const QString &name);
QString takePendingSelection();
signals:
void selectAgentRequested(const QString &name);
private:
QString m_pending;
};
std::unique_ptr<Core::IOptionsPage> createAgentsSettingsPage(
AgentFactory *agentFactory, AgentsPageNavigator *navigator);
} // namespace QodeAssist::Settings

View File

@@ -17,16 +17,23 @@ add_library(QodeAssistSettings STATIC
ProviderSettings.hpp ProviderSettings.cpp
ProviderNameMigration.hpp
ProvidersSettingsPage.hpp ProvidersSettingsPage.cpp
ProvidersSettingsHelpers.hpp
SettingsTheme.hpp
SettingsUiBuilders.hpp SettingsUiBuilders.cpp
SectionBox.hpp SectionBox.cpp
TagChip.hpp TagChip.cpp
ProviderListItem.hpp ProviderListItem.cpp
ProviderDetailPane.hpp ProviderDetailPane.cpp
NewProviderDialog.hpp NewProviderDialog.cpp
PluginUpdater.hpp PluginUpdater.cpp
UpdateDialog.hpp UpdateDialog.cpp
AgentRole.hpp AgentRole.cpp
AgentRoleDialog.hpp AgentRoleDialog.cpp
AgentRolesWidget.hpp AgentRolesWidget.cpp
AgentsSettingsPage.hpp AgentsSettingsPage.cpp
AgentDetailPane.hpp AgentDetailPane.cpp
AgentListItem.hpp AgentListItem.cpp
AgentListPane.hpp AgentListPane.cpp
AgentDuplicator.hpp AgentDuplicator.cpp
TagFilterStrip.hpp TagFilterStrip.cpp
)
target_link_libraries(QodeAssistSettings
@@ -38,6 +45,7 @@ target_link_libraries(QodeAssistSettings
QtCreator::Utils
QodeAssistLogger
ProvidersConfig
Agents
Skills
)
target_include_directories(QodeAssistSettings PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

View File

@@ -1,81 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "NewProviderDialog.hpp"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QVBoxLayout>
namespace QodeAssist::Settings {
NewProviderDialog::NewProviderDialog(const QStringList &types, QWidget *parent)
: QDialog(parent)
{
setWindowTitle(tr("New provider"));
setMinimumWidth(520);
auto *intro = new QLabel(
tr("A provider binds a client API to a URL and API key. "
"Agents reference providers by name."),
this);
intro->setWordWrap(true);
QPalette ip = intro->palette();
ip.setColor(QPalette::WindowText, ip.color(QPalette::Mid));
intro->setPalette(ip);
m_typeCombo = new QComboBox(this);
m_typeCombo->addItems(types);
m_typeCombo->setEditable(false);
m_nameEdit = new QLineEdit(this);
m_nameEdit->setPlaceholderText(tr("Shown in the providers list and referenced by agents."));
m_urlEdit = new QLineEdit(this);
m_urlEdit->setPlaceholderText(QStringLiteral("https://api.example.com"));
m_descriptionEdit = new QLineEdit(this);
m_descriptionEdit->setPlaceholderText(tr("Optional — what this provider is for."));
m_apiKeyEdit = new QLineEdit(this);
m_apiKeyEdit->setEchoMode(QLineEdit::Password);
m_apiKeyEdit->setPlaceholderText(tr("(stored — leave blank to set later)"));
auto *form = new QFormLayout;
form->addRow(tr("Client API:"), m_typeCombo);
form->addRow(tr("Name:"), m_nameEdit);
form->addRow(tr("URL:"), m_urlEdit);
form->addRow(tr("Description:"), m_descriptionEdit);
form->addRow(tr("API key:"), m_apiKeyEdit);
auto *buttons = new QDialogButtonBox(
QDialogButtonBox::Cancel | QDialogButtonBox::Ok, this);
buttons->button(QDialogButtonBox::Ok)->setText(tr("Create"));
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *root = new QVBoxLayout(this);
root->addWidget(intro);
root->addLayout(form);
root->addWidget(buttons);
connect(m_typeCombo, &QComboBox::currentTextChanged, this, [this](const QString &type) {
if (m_nameEdit->text().isEmpty())
m_nameEdit->setText(type);
});
if (!types.isEmpty() && m_nameEdit->text().isEmpty())
m_nameEdit->setText(types.front());
}
QString NewProviderDialog::providerType() const { return m_typeCombo->currentText(); }
QString NewProviderDialog::providerName() const { return m_nameEdit->text().trimmed(); }
QString NewProviderDialog::url() const { return m_urlEdit->text().trimmed(); }
QString NewProviderDialog::description() const { return m_descriptionEdit->text().trimmed(); }
QString NewProviderDialog::apiKey() const { return m_apiKeyEdit->text(); }
} // namespace QodeAssist::Settings

View File

@@ -1,34 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QDialog>
#include <QStringList>
class QComboBox;
class QLineEdit;
namespace QodeAssist::Settings {
class NewProviderDialog : public QDialog
{
Q_OBJECT
public:
explicit NewProviderDialog(const QStringList &types, QWidget *parent = nullptr);
QString providerType() const;
QString providerName() const;
QString url() const;
QString description() const;
QString apiKey() const;
private:
QComboBox *m_typeCombo = nullptr;
QLineEdit *m_nameEdit = nullptr;
QLineEdit *m_urlEdit = nullptr;
QLineEdit *m_descriptionEdit = nullptr;
QLineEdit *m_apiKeyEdit = nullptr;
};
} // namespace QodeAssist::Settings

View File

@@ -18,8 +18,9 @@
#include <solutions/terminal/terminalview.h>
#include "ProviderInstanceWriter.hpp"
#include "ProvidersSettingsHelpers.hpp"
#include "SectionBox.hpp"
#include "SettingsTheme.hpp"
#include "SettingsUiBuilders.hpp"
namespace QodeAssist::Settings {
@@ -112,15 +113,12 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent)
identityGrid->setContentsMargins(0, 0, 0, 0);
identityGrid->setHorizontalSpacing(8);
identityGrid->setVerticalSpacing(4);
int identityRow = 0;
identityRow = addFormRow(identityGrid, identityRow, tr("Name:"),
singleField(m_nameEdit));
identityRow = addFormRow(identityGrid, identityRow, tr("Client API:"),
singleField(m_typeEdit),
tr("The client API this provider speaks. "
"Cannot be changed after creation."));
identityRow = addFormRow(identityGrid, identityRow, tr("Description:"),
singleField(m_descriptionEdit));
FormBuilder(identityGrid)
.row(tr("Name:"), m_nameEdit)
.row(tr("Client API:"), m_typeEdit,
tr("The client API this provider speaks. "
"Cannot be changed after creation."))
.row(tr("Description:"), m_descriptionEdit);
identitySection->bodyLayout()->addLayout(identityGrid);
auto *endpointSection = new SectionBox(tr("Endpoint"), this);
@@ -130,11 +128,9 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent)
endpointGrid->setContentsMargins(0, 0, 0, 0);
endpointGrid->setHorizontalSpacing(8);
endpointGrid->setVerticalSpacing(4);
int endpointRow = 0;
endpointRow = addFormRow(endpointGrid, endpointRow, tr("URL:"),
singleField(m_urlEdit),
tr("Base URL. Agents append their endpoint path "
"(e.g. /chat/completions) to this."));
FormBuilder(endpointGrid).row(tr("URL:"), m_urlEdit,
tr("Base URL. Agents append their endpoint path "
"(e.g. /chat/completions) to this."));
endpointSection->bodyLayout()->addLayout(endpointGrid);
m_samplePreview = new QLabel(this);
@@ -176,14 +172,7 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent)
});
connect(m_apiKeyClearBtn, &QPushButton::clicked, this,
[this] { emit apiKeyClearRequested(); });
m_keyHint = new QLabel(this);
QFont khf = m_keyHint->font();
khf.setPixelSize(11);
m_keyHint->setFont(khf);
m_keyHint->setWordWrap(true);
QPalette khp = m_keyHint->palette();
khp.setColor(QPalette::WindowText, khp.color(QPalette::Mid));
m_keyHint->setPalette(khp);
m_keyHint = makeHintLabel(QString{}, this);
auto *keyRow = new QHBoxLayout;
keyRow->setContentsMargins(0, 0, 0, 0);
@@ -197,9 +186,9 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent)
credGrid->setContentsMargins(0, 0, 0, 0);
credGrid->setHorizontalSpacing(8);
credGrid->setVerticalSpacing(4);
int credRow = 0;
credRow = addFormRow(credGrid, credRow, tr("API key:"), keyRow);
credGrid->addWidget(m_keyHint, credRow, 1);
FormBuilder credForm(credGrid);
credForm.row(tr("API key:"), keyRow);
credGrid->addWidget(m_keyHint, credForm.currentRow(), 1);
credSection->bodyLayout()->addLayout(credGrid);
m_launchSection = new SectionBox(tr("Launch"), this);
@@ -483,12 +472,10 @@ Providers::ProviderInstance ProviderDetailPane::collectEdits() const
void ProviderDetailPane::applyPreviewPalette()
{
const bool dark = isDarkPalette(palette());
const QString bg = dark ? QStringLiteral("#1f1f1f") : QStringLiteral("#f4f4f4");
const QString bd = dark ? QStringLiteral("#3a3a3a") : QStringLiteral("#dcdcdc");
const Theme theme = themeFor(palette());
m_samplePreview->setStyleSheet(QStringLiteral(
"QLabel { background:%1; border:1px solid %2; }")
.arg(bg, bd));
.arg(theme.codeBg, theme.rowSeparator));
}
void ProviderDetailPane::applyTerminalPalette()

View File

@@ -10,7 +10,7 @@
#include <QVBoxLayout>
#include "ProviderInstance.hpp"
#include "ProvidersSettingsHelpers.hpp"
#include "SettingsTheme.hpp"
namespace QodeAssist::Settings {
@@ -99,12 +99,11 @@ void ProviderListItem::applyTheme()
if (m_inApplyTheme)
return;
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
const bool dark = isDarkPalette(palette());
const QString sep = dark ? QStringLiteral("#3a3a3a") : QStringLiteral("#dcdcdc");
const QString sel = dark ? QStringLiteral("#2c4060") : QStringLiteral("#cfe2ff");
const Theme theme = themeFor(palette());
setStyleSheet(QStringLiteral(
"#ProvListItem { background:%1; border-top: 1px solid %2; }")
.arg(m_selected ? sel : QStringLiteral("transparent"), sep));
.arg(m_selected ? theme.rowSelectedBg : QStringLiteral("transparent"),
theme.rowSeparator));
}
} // namespace QodeAssist::Settings

View File

@@ -227,6 +227,8 @@ public:
}
};
#ifndef QODEASSIST_EXPERIMENTAL
const ProviderSettingsPage providerSettingsPage;
#endif
} // namespace QodeAssist::Settings

View File

@@ -1,64 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QFont>
#include <QFontDatabase>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPalette>
#include <QString>
#include <QWidget>
namespace QodeAssist::Settings {
inline QFont monospaceFont(int pixelSize = 11)
{
QFont f = QFontDatabase::systemFont(QFontDatabase::FixedFont);
f.setStyleHint(QFont::Monospace);
if (pixelSize > 0)
f.setPixelSize(pixelSize);
return f;
}
inline bool isDarkPalette(const QPalette &p)
{
return p.color(QPalette::Window).lightness() < 128;
}
inline int addFormRow(
QGridLayout *grid, int row, const QString &label, QLayout *value, const QString &hint = {})
{
auto *l = new QLabel(label);
l->setMinimumWidth(96);
l->setAlignment(Qt::AlignLeft | Qt::AlignTop);
grid->addWidget(l, row, 0, Qt::AlignTop);
auto *holder = new QWidget;
holder->setLayout(value);
grid->addWidget(holder, row, 1);
if (hint.isEmpty())
return row + 1;
auto *h = new QLabel(hint);
QFont hf = h->font();
hf.setPixelSize(11);
h->setFont(hf);
h->setWordWrap(true);
QPalette p = h->palette();
p.setColor(QPalette::WindowText, p.color(QPalette::Mid));
h->setPalette(p);
grid->addWidget(h, row + 1, 1);
return row + 2;
}
inline QHBoxLayout *singleField(QWidget *w)
{
auto *lay = new QHBoxLayout;
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(4);
lay->addWidget(w, 1);
return lay;
}
} // namespace QodeAssist::Settings

View File

@@ -28,7 +28,6 @@
#include <QTimer>
#include <QVBoxLayout>
#include "NewProviderDialog.hpp"
#include "ProviderDetailPane.hpp"
#include "ProviderInstance.hpp"
#include "ProviderInstanceFactory.hpp"
@@ -36,8 +35,8 @@
#include "ProviderLauncher.hpp"
#include "ProviderListItem.hpp"
#include "ProviderSecretsStore.hpp"
#include "ProvidersSettingsHelpers.hpp"
#include "SettingsConstants.hpp"
#include "SettingsTheme.hpp"
namespace QodeAssist::Settings {
@@ -80,13 +79,10 @@ public:
tf.setPixelSize(13);
m_titleLabel->setFont(tf);
m_newBtn = new QPushButton(tr("+ New provider…"), this);
auto *headerRow = new QHBoxLayout;
headerRow->setContentsMargins(0, 0, 0, 0);
headerRow->setSpacing(8);
headerRow->addWidget(m_titleLabel, 1);
headerRow->addWidget(m_newBtn);
auto *headerSep = new QFrame(this);
headerSep->setFrameShape(QFrame::HLine);
@@ -181,7 +177,6 @@ public:
root->addWidget(headerSep);
root->addWidget(splitter, 1);
connect(m_newBtn, &QPushButton::clicked, this, &ProvidersPageWidget::onNewClicked);
m_filterDebounce = new QTimer(this);
m_filterDebounce->setSingleShot(true);
m_filterDebounce->setInterval(100);
@@ -248,9 +243,9 @@ private slots:
header->setPalette(hp);
header->setContentsMargins(8, 4, 8, 4);
header->setAutoFillBackground(true);
const bool dark = isDarkPalette(palette());
const QString bg = dark ? QStringLiteral("#262626") : QStringLiteral("#f0f0f0");
header->setStyleSheet(QStringLiteral("QLabel { background:%1; }").arg(bg));
header->setStyleSheet(
QStringLiteral("QLabel { background:%1; }")
.arg(themeFor(palette()).listHeaderBg));
m_listLayout->insertWidget(m_listLayout->count() - 1, header);
std::vector<const Providers::ProviderInstance *> sorted;
@@ -316,57 +311,6 @@ private slots:
populateDetail(inst->name);
}
void onNewClicked()
{
if (!m_factory)
return;
NewProviderDialog dlg(m_factory->knownClientApis(), this);
if (dlg.exec() != QDialog::Accepted)
return;
Providers::ProviderInstance inst;
inst.name = dlg.providerName();
inst.clientApi = dlg.providerType();
inst.description = dlg.description();
inst.url = dlg.url();
inst.apiKeyRef = QStringLiteral("qodeassist/providers/%1").arg(inst.name);
if (inst.name.isEmpty()) {
QMessageBox::warning(this, tr("New provider"), tr("Name cannot be empty."));
return;
}
if (m_factory->instanceByName(inst.name)) {
QMessageBox::warning(this, tr("New provider"),
tr("An instance named '%1' already exists.").arg(inst.name));
return;
}
const QString validation = Providers::ProviderInstance::validate(
inst, m_factory->knownClientApis());
if (!validation.isEmpty()) {
QMessageBox::warning(this, tr("New provider"), validation);
return;
}
const QString softWarning = Providers::ProviderInstance::warnings(inst);
if (!softWarning.isEmpty()) {
if (QMessageBox::warning(this, tr("New provider"),
softWarning + QStringLiteral("\n\n")
+ tr("Save anyway?"),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No)
!= QMessageBox::Yes)
return;
}
QString writeErr;
if (Providers::ProviderInstanceWriter::writeToUserDir(
inst, /*previousPath=*/QString{}, &writeErr).isEmpty()) {
QMessageBox::warning(this, tr("New provider"), writeErr);
return;
}
if (m_secrets && !dlg.apiKey().isEmpty())
m_secrets->writeKey(inst.apiKeyRef, dlg.apiKey());
m_factory->reload();
selectInstance(inst.name);
}
void onDuplicateClicked()
{
if (!m_factory || m_currentName.isEmpty())
@@ -589,7 +533,6 @@ private:
QPointer<ProvidersPageNavigator> m_navigator;
QLabel *m_titleLabel = nullptr;
QPushButton *m_newBtn = nullptr;
QLineEdit *m_filterEdit = nullptr;
QScrollArea *m_listScroll = nullptr;

View File

@@ -138,6 +138,9 @@ const char QODE_ASSIST_GENERAL_OPTIONS_DISPLAY_CATEGORY[] = "QodeAssist";
// Provider Settings Page ID
const char QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID[] = "QodeAssist.7ProviderSettingsPageId";
// Agents Settings Page ID
const char QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID[] = "QodeAssist.8AgentsSettingsPageId";
// Provider API Keys
const char OPEN_ROUTER_API_KEY[] = "QodeAssist.openRouterApiKey";
const char OPEN_ROUTER_API_KEY_HISTORY[] = "QodeAssist.openRouterApiKeyHistory";

View File

@@ -0,0 +1,52 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QFont>
#include <QFontDatabase>
#include <QPalette>
#include <QString>
namespace QodeAssist::Settings {
struct Theme
{
bool dark = false;
QString listHeaderBg;
QString rowSeparator;
QString rowSelectedBg;
QString codeBg;
};
inline bool isDarkPalette(const QPalette &p)
{
return p.color(QPalette::Window).lightness() < 128;
}
inline Theme themeFor(const QPalette &p)
{
const bool dark = isDarkPalette(p);
if (dark)
return {true,
QStringLiteral("#262626"),
QStringLiteral("#3a3a3a"),
QStringLiteral("#2c4060"),
QStringLiteral("#1f1f1f")};
return {false,
QStringLiteral("#f0f0f0"),
QStringLiteral("#dcdcdc"),
QStringLiteral("#cfe2ff"),
QStringLiteral("#f4f4f4")};
}
inline QFont monospaceFont(int pixelSize = 11)
{
QFont f = QFontDatabase::systemFont(QFontDatabase::FixedFont);
f.setStyleHint(QFont::Monospace);
if (pixelSize > 0)
f.setPixelSize(pixelSize);
return f;
}
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,100 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "SettingsUiBuilders.hpp"
#include "SettingsTheme.hpp"
#include <QFont>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPalette>
#include <QWidget>
namespace QodeAssist::Settings {
void applyMutedSmallCaps(QLabel *label)
{
QFont f = label->font();
f.setPixelSize(10);
f.setLetterSpacing(QFont::AbsoluteSpacing, 0.4);
label->setFont(f);
QPalette p = label->palette();
p.setColor(QPalette::WindowText, p.color(QPalette::Mid));
label->setPalette(p);
}
QLabel *makeSectionHeader(const QString &title, QWidget *parent)
{
auto *header = new QLabel(title.toUpper(), parent);
applyMutedSmallCaps(header);
header->setContentsMargins(8, 4, 8, 4);
header->setAutoFillBackground(true);
const Theme theme = themeFor(parent ? parent->palette() : QPalette());
header->setStyleSheet(
QStringLiteral("QLabel { background:%1; border-top:1px solid %2;"
" border-bottom:1px solid %2; }")
.arg(theme.listHeaderBg, theme.rowSeparator));
return header;
}
QLabel *makeHintLabel(const QString &text, QWidget *parent)
{
auto *h = new QLabel(text, parent);
QFont hf = h->font();
hf.setPixelSize(11);
h->setFont(hf);
h->setWordWrap(true);
QPalette p = h->palette();
p.setColor(QPalette::WindowText, p.color(QPalette::Mid));
h->setPalette(p);
return h;
}
QHBoxLayout *singleField(QWidget *w)
{
auto *lay = new QHBoxLayout;
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(4);
lay->addWidget(w, 1);
return lay;
}
namespace {
QLabel *makeFormLabel(const QString &text)
{
auto *l = new QLabel(text);
l->setMinimumWidth(96);
l->setAlignment(Qt::AlignLeft | Qt::AlignTop);
return l;
}
} // namespace
FormBuilder::FormBuilder(QGridLayout *grid, int startRow)
: m_grid(grid)
, m_row(startRow)
{}
FormBuilder &FormBuilder::row(const QString &label, QLayout *value, const QString &hint)
{
m_grid->addWidget(makeFormLabel(label), m_row, 0, Qt::AlignTop);
auto *holder = new QWidget;
holder->setLayout(value);
m_grid->addWidget(holder, m_row, 1);
++m_row;
if (!hint.isEmpty()) {
m_grid->addWidget(makeHintLabel(hint), m_row, 1);
++m_row;
}
return *this;
}
FormBuilder &FormBuilder::row(const QString &label, QWidget *value, const QString &hint)
{
return row(label, singleField(value), hint);
}
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,39 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
class QGridLayout;
class QHBoxLayout;
class QLabel;
class QLayout;
class QWidget;
namespace QodeAssist::Settings {
void applyMutedSmallCaps(QLabel *label);
QLabel *makeSectionHeader(const QString &title, QWidget *parent);
QLabel *makeHintLabel(const QString &text, QWidget *parent = nullptr);
QHBoxLayout *singleField(QWidget *w);
class FormBuilder
{
public:
explicit FormBuilder(QGridLayout *grid, int startRow = 0);
FormBuilder &row(const QString &label, QLayout *value, const QString &hint = {});
FormBuilder &row(const QString &label, QWidget *value, const QString &hint = {});
int currentRow() const { return m_row; }
private:
QGridLayout *m_grid;
int m_row;
};
} // namespace QodeAssist::Settings

88
settings/TagChip.cpp Normal file
View File

@@ -0,0 +1,88 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "TagChip.hpp"
#include "SettingsTheme.hpp"
#include <QEvent>
#include <QFont>
#include <QHBoxLayout>
#include <QLabel>
#include <QMouseEvent>
#include <QPalette>
#include <QScopedValueRollback>
namespace QodeAssist::Settings {
TagChip::TagChip(const QString &tag, int count, QWidget *parent)
: QFrame(parent)
, m_tag(tag)
{
setObjectName(QStringLiteral("TagChip"));
setCursor(Qt::PointingHandCursor);
m_label = new QLabel(tag, this);
m_label->setFont(monospaceFont(11));
auto *row = new QHBoxLayout(this);
row->setContentsMargins(5, 0, 5, 0);
row->setSpacing(4);
row->addWidget(m_label);
if (count >= 0) {
m_count = new QLabel(QString::number(count), this);
QFont cf = m_count->font();
cf.setPixelSize(10);
m_count->setFont(cf);
row->addWidget(m_count);
}
applyTheme();
}
void TagChip::setActive(bool on)
{
if (m_active == on)
return;
m_active = on;
applyTheme();
}
void TagChip::mouseReleaseEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton)
emit clicked(m_tag);
QFrame::mouseReleaseEvent(event);
}
void TagChip::changeEvent(QEvent *event)
{
QFrame::changeEvent(event);
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
applyTheme();
}
void TagChip::applyTheme()
{
if (m_inApplyTheme)
return;
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
const Theme theme = themeFor(palette());
const QString text = palette().color(QPalette::WindowText).name();
const QString mute = palette().color(QPalette::Mid).name();
const QString border = m_active ? text : theme.rowSeparator;
const QString bg = m_active ? theme.rowSelectedBg : QStringLiteral("transparent");
setStyleSheet(QStringLiteral(
"#TagChip { background:%1; border:1px solid %2; }")
.arg(bg, border));
QPalette lp = m_label->palette();
lp.setColor(QPalette::WindowText, m_active ? QColor(text) : QColor(mute));
m_label->setPalette(lp);
if (m_count) {
QPalette cp = m_count->palette();
cp.setColor(QPalette::WindowText, QColor(mute));
m_count->setPalette(cp);
}
}
} // namespace QodeAssist::Settings

39
settings/TagChip.hpp Normal file
View File

@@ -0,0 +1,39 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QFrame>
#include <QString>
class QLabel;
namespace QodeAssist::Settings {
class TagChip : public QFrame
{
Q_OBJECT
public:
explicit TagChip(const QString &tag, int count, QWidget *parent = nullptr);
void setActive(bool on);
QString tag() const { return m_tag; }
signals:
void clicked(const QString &tag);
protected:
void mouseReleaseEvent(QMouseEvent *event) override;
void changeEvent(QEvent *event) override;
private:
void applyTheme();
QString m_tag;
bool m_active = false;
bool m_inApplyTheme = false;
QLabel *m_label = nullptr;
QLabel *m_count = nullptr;
};
} // namespace QodeAssist::Settings

163
settings/TagFilterStrip.cpp Normal file
View File

@@ -0,0 +1,163 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "TagFilterStrip.hpp"
#include "SettingsTheme.hpp"
#include "SettingsUiBuilders.hpp"
#include "TagChip.hpp"
#include <QEvent>
#include <QFont>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLayoutItem>
#include <QPalette>
#include <QScopedValueRollback>
#include <QStringList>
#include <QTimer>
#include <QVBoxLayout>
#include <algorithm>
namespace QodeAssist::Settings {
TagFilterStrip::TagFilterStrip(QWidget *parent)
: QWidget(parent)
{
setObjectName(QStringLiteral("TagStrip"));
setAutoFillBackground(true);
m_layout = new QVBoxLayout(this);
m_layout->setContentsMargins(8, 6, 8, 6);
m_layout->setSpacing(5);
applyTheme();
}
void TagFilterStrip::setAvailableTags(const QMap<QString, int> &countsByTag)
{
m_counts = countsByTag;
QSet<QString> stillExisting;
for (auto it = m_counts.cbegin(); it != m_counts.cend(); ++it)
stillExisting.insert(it.key());
QSet<QString> trimmed;
for (const QString &t : m_activeTags)
if (stillExisting.contains(t))
trimmed.insert(t);
const bool activeChanged = trimmed != m_activeTags;
if (activeChanged)
m_activeTags = trimmed;
rebuild();
if (activeChanged)
emit activeTagsChanged(m_activeTags);
}
void TagFilterStrip::changeEvent(QEvent *event)
{
QWidget::changeEvent(event);
if (m_inApplyTheme)
return;
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
applyTheme();
}
void TagFilterStrip::toggleTag(const QString &tag)
{
if (m_activeTags.contains(tag))
m_activeTags.remove(tag);
else
m_activeTags.insert(tag);
refreshActiveStates();
emit activeTagsChanged(m_activeTags);
}
void TagFilterStrip::refreshActiveStates()
{
for (auto it = m_chipByTag.cbegin(); it != m_chipByTag.cend(); ++it)
it.value()->setActive(m_activeTags.contains(it.key()));
}
void TagFilterStrip::applyTheme()
{
if (m_inApplyTheme)
return;
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
const Theme theme = themeFor(palette());
setStyleSheet(QStringLiteral("QWidget#TagStrip { background:%1;"
" border-bottom:1px solid %2; }")
.arg(theme.listHeaderBg, theme.rowSeparator));
}
void TagFilterStrip::rebuild()
{
while (auto *item = m_layout->takeAt(0)) {
if (auto *w = item->widget())
w->deleteLater();
if (auto *l = item->layout()) {
while (auto *sub = l->takeAt(0)) {
if (auto *sw = sub->widget())
sw->deleteLater();
delete sub;
}
}
delete item;
}
m_chipByTag.clear();
if (m_counts.isEmpty()) {
setVisible(false);
return;
}
setVisible(true);
auto *headerLine = new QHBoxLayout;
headerLine->setContentsMargins(0, 0, 0, 0);
headerLine->setSpacing(6);
auto *title = new QLabel(tr("FILTER BY TAG"), this);
applyMutedSmallCaps(title);
headerLine->addWidget(title);
headerLine->addStretch(1);
if (!m_activeTags.isEmpty()) {
auto *clear = new QLabel(QStringLiteral("<a href=\"#\">%1</a>").arg(tr("clear")), this);
connect(clear, &QLabel::linkActivated, this, [this](const QString &) {
if (m_activeTags.isEmpty())
return;
m_activeTags.clear();
refreshActiveStates();
emit activeTagsChanged(m_activeTags);
});
headerLine->addWidget(clear);
}
m_layout->addLayout(headerLine);
std::vector<std::pair<QString, int>> sorted;
sorted.reserve(m_counts.size());
for (auto it = m_counts.cbegin(); it != m_counts.cend(); ++it)
sorted.emplace_back(it.key(), it.value());
std::sort(sorted.begin(), sorted.end(),
[](const auto &a, const auto &b) {
if (a.second != b.second)
return a.second > b.second;
return a.first.localeAwareCompare(b.first) < 0;
});
auto *grid = new QGridLayout;
grid->setContentsMargins(0, 0, 0, 0);
grid->setHorizontalSpacing(3);
grid->setVerticalSpacing(3);
int col = 0, gridRow = 0;
for (const auto &[tag, count] : sorted) {
auto *chip = new TagChip(tag, count, this);
chip->setActive(m_activeTags.contains(tag));
connect(chip, &TagChip::clicked, this, &TagFilterStrip::toggleTag);
grid->addWidget(chip, gridRow, col, Qt::AlignLeft);
m_chipByTag.insert(tag, chip);
if (++col >= 4) {
col = 0;
++gridRow;
}
}
grid->setColumnStretch(4, 1);
m_layout->addLayout(grid);
}
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,46 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QHash>
#include <QMap>
#include <QSet>
#include <QString>
#include <QWidget>
class QVBoxLayout;
namespace QodeAssist::Settings {
class TagChip;
class TagFilterStrip : public QWidget
{
Q_OBJECT
public:
explicit TagFilterStrip(QWidget *parent = nullptr);
void setAvailableTags(const QMap<QString, int> &countsByTag);
const QSet<QString> &activeTags() const { return m_activeTags; }
signals:
void activeTagsChanged(const QSet<QString> &tags);
protected:
void changeEvent(QEvent *event) override;
private:
void rebuild();
void refreshActiveStates();
void applyTheme();
void toggleTag(const QString &tag);
QMap<QString, int> m_counts;
QSet<QString> m_activeTags;
QVBoxLayout *m_layout = nullptr;
QHash<QString, TagChip *> m_chipByTag;
bool m_inApplyTheme = false;
};
} // namespace QodeAssist::Settings