From 0382cd17a0a67cf36690b41a8de88c9d4be34a29 Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:36:06 +0200 Subject: [PATCH] refactor: Improve UX --- settings/AgentDetailPane.cpp | 308 ++++++++---------- settings/AgentDetailPane.hpp | 34 +- settings/AgentListItem.cpp | 93 +++--- settings/AgentListItem.hpp | 18 +- settings/AgentListPane.cpp | 102 ++++-- settings/AgentListPane.hpp | 8 +- settings/AgentModelDialog.cpp | 139 -------- settings/AgentsSettingsPage.cpp | 28 +- settings/CMakeLists.txt | 2 +- settings/GeneralSettings.cpp | 50 ++- settings/ModelSelector.cpp | 176 ++++++++++ ...AgentModelDialog.hpp => ModelSelector.hpp} | 49 +-- settings/ProviderDetailPane.cpp | 161 +++++---- settings/ProviderDetailPane.hpp | 4 +- settings/ProviderListItem.cpp | 57 +++- settings/ProviderListItem.hpp | 12 +- settings/ProvidersSettingsPage.cpp | 298 +++++++++++------ settings/SettingsUiBuilders.cpp | 43 +++ settings/SettingsUiBuilders.hpp | 4 + settings/TagFilterStrip.cpp | 23 +- settings/TagFilterStrip.hpp | 2 + sources/settings/AgentSelectionDialog.cpp | 179 ++++++---- sources/settings/AgentSelectionDialog.hpp | 40 ++- 23 files changed, 1051 insertions(+), 779 deletions(-) delete mode 100644 settings/AgentModelDialog.cpp create mode 100644 settings/ModelSelector.cpp rename settings/{AgentModelDialog.hpp => ModelSelector.hpp} (50%) diff --git a/settings/AgentDetailPane.cpp b/settings/AgentDetailPane.cpp index 4dbd2a3..f4f57a0 100644 --- a/settings/AgentDetailPane.cpp +++ b/settings/AgentDetailPane.cpp @@ -4,13 +4,11 @@ #include "AgentDetailPane.hpp" -#include "AgentModelDialog.hpp" +#include "ModelSelector.hpp" #include "SectionBox.hpp" #include "SettingsTheme.hpp" #include "SettingsUiBuilders.hpp" -#include - #include #include @@ -84,6 +82,9 @@ AgentDetailPane::AgentDetailPane(QWidget *parent) m_name->setFont(nf); m_name->setTextInteractionFlags(Qt::TextSelectableByMouse); + m_sourceBadge = new QLabel(this); + m_sourceBadge->setVisible(false); + m_path = new QLabel(this); m_path->setFont(monospaceFont(11)); m_path->setTextInteractionFlags(Qt::TextSelectableByMouse); @@ -91,20 +92,20 @@ AgentDetailPane::AgentDetailPane(QWidget *parent) pp.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); m_path->setPalette(pp); - m_openBtn = new QPushButton(tr("Open in editor"), this); m_dupBtn = new QPushButton(tr("Customize a copy…"), this); m_dupBtn->setToolTip( tr("Create an editable user agent that inherits from this one — " "override only the fields you want.")); + m_openBtn = new QPushButton(tr("Open in editor"), 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_openBtn, &QPushButton::clicked, this, [this] { + if (m_current) + emit openInEditorRequested(*m_current); + }); connect(m_deleteBtn, &QPushButton::clicked, this, [this] { if (m_current) emit deleteRequested(*m_current); @@ -114,14 +115,15 @@ AgentDetailPane::AgentDetailPane(QWidget *parent) auto *actions = new QHBoxLayout(m_actionsHolder); actions->setContentsMargins(0, 0, 0, 0); actions->setSpacing(6); - actions->addWidget(m_openBtn); actions->addWidget(m_dupBtn); + actions->addWidget(m_openBtn); actions->addWidget(m_deleteBtn); auto *titleRow = new QHBoxLayout; titleRow->setContentsMargins(0, 0, 0, 0); titleRow->setSpacing(8); titleRow->addWidget(m_name); + titleRow->addWidget(m_sourceBadge, 0, Qt::AlignVCenter); titleRow->addStretch(1); auto *headerLeft = new QVBoxLayout; @@ -144,41 +146,32 @@ AgentDetailPane::AgentDetailPane(QWidget *parent) 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 *setup = new SectionBox(tr("Configuration"), this); - 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); + m_modelSelector = new ModelSelector(this); + connect(m_modelSelector, &ModelSelector::modelSubmitted, this, [this](const QString &model) { + onModelSubmitted(model); + }); + + m_modelResetBtn = new QPushButton(tr("Reset"), this); + m_modelResetBtn->setToolTip(tr("Remove the model override and restore the agent's default")); + m_modelResetBtn->setVisible(false); + connect(m_modelResetBtn, &QPushButton::clicked, this, [this] { onResetModel(); }); + + auto *modelHolder = new QWidget(this); + auto *modelRow = new QHBoxLayout(modelHolder); + modelRow->setContentsMargins(0, 0, 0, 0); + modelRow->setSpacing(6); + modelRow->addWidget(m_modelSelector, 1); + modelRow->addWidget(m_modelResetBtn); + + m_modelStatus = makeHintLabel(QString(), this); + m_modelStatus->setVisible(false); + connect(m_modelSelector, &ModelSelector::statusChanged, this, [this](const QString &status) { + m_modelStatus->setText(status); + m_modelStatus->setVisible(!status.isEmpty()); + }); - auto *connection = new SectionBox(tr("Connection"), this); m_providerCombo = new QComboBox(this); m_providerCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents); m_providerCombo->setEnabled(false); @@ -199,65 +192,6 @@ AgentDetailPane::AgentDetailPane(QWidget *parent) providerRow->addWidget(m_providerCombo, 1); providerRow->addWidget(m_providerResetBtn); - m_endpointValue = makeReadOnlyLine(true); - - m_modelValue = makeReadOnlyLine(true); - m_modelValue->setPlaceholderText(tr("(set a model)")); - - m_modelChangeBtn = new QPushButton(tr("Change…"), this); - m_modelChangeBtn->setToolTip(tr("Pick a model from the provider or type one")); - m_modelChangeBtn->setEnabled(false); - connect(m_modelChangeBtn, &QPushButton::clicked, this, [this] { onChangeModel(); }); - - m_modelResetBtn = new QPushButton(tr("Reset"), this); - m_modelResetBtn->setToolTip(tr("Remove the model override and restore the agent's default")); - m_modelResetBtn->setVisible(false); - connect(m_modelResetBtn, &QPushButton::clicked, this, [this] { onResetModel(); }); - - auto *modelHolder = new QWidget(this); - auto *modelRow = new QHBoxLayout(modelHolder); - modelRow->setContentsMargins(0, 0, 0, 0); - modelRow->setSpacing(6); - modelRow->addWidget(m_modelValue, 1); - modelRow->addWidget(m_modelChangeBtn); - modelRow->addWidget(m_modelResetBtn); - - auto *connGrid = new QGridLayout; - connGrid->setContentsMargins(0, 0, 0, 0); - connGrid->setHorizontalSpacing(8); - connGrid->setVerticalSpacing(4); - FormBuilder(connGrid) - .row( - tr("Provider:"), - providerHolder, - tr("The provider instance this agent uses. URL is " - "inherited from the instance. Switching it is saved as a " - "per-agent override; Reset restores the agent's default.")) - .row( - tr("Endpoint:"), - m_endpointValue, - tr("Appended to the provider's URL. Blank uses the " - "provider default.")) - .row( - tr("Model:"), - modelHolder, - tr("Model sent to the provider. Click Change… to pick from the " - "provider's available models or type one. The choice is saved " - "as a per-agent override in settings; Reset restores the " - "agent's default.")); - 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 *capabilities = new SectionBox(tr("Capabilities"), this); - m_thinkingValue = new QLabel(this); - m_toolsCheck = new QCheckBox(tr("Allow tool calls"), this); m_toolsCheck->setEnabled(false); connect(m_toolsCheck, &QCheckBox::clicked, this, [this](bool on) { onToggleTools(on); }); @@ -275,38 +209,46 @@ AgentDetailPane::AgentDetailPane(QWidget *parent) toolsRow->addStretch(1); toolsRow->addWidget(m_toolsResetBtn); - auto *capGrid = new QGridLayout; - capGrid->setContentsMargins(0, 0, 0, 0); - capGrid->setHorizontalSpacing(8); - capGrid->setVerticalSpacing(4); - FormBuilder(capGrid) - .row( - tr("Thinking:"), - m_thinkingValue, - tr("Whether this agent requests extended thinking. Defined by the " - "agent template.")) - .row( - tr("Tools:"), - toolsHolder, - tr("Whether the agent may call tools. Toggle to save a per-agent " - "override in settings; Reset restores the agent's default.")); - capabilities->bodyLayout()->addLayout(capGrid); + m_thinkingValue = new QLabel(this); - 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.")); + auto *setupGrid = new QGridLayout; + setupGrid->setContentsMargins(0, 0, 0, 0); + setupGrid->setHorizontalSpacing(8); + setupGrid->setVerticalSpacing(4); + FormBuilder setupForm(setupGrid); + setupForm.row( + tr("Model:"), + modelHolder, + tr("Click to pick from the provider's models, or type a name and press Enter.")); + { + const int row = setupForm.currentRow(); + setupGrid->addWidget(m_modelStatus, row, 1); + setupForm = FormBuilder(setupGrid, row + 1); + } + setupForm.row(tr("Provider:"), providerHolder) + .row(tr("Tools:"), toolsHolder) + .row(tr("Thinking:"), m_thinkingValue); + setup->bodyLayout()->addLayout(setupGrid); + setup->bodyLayout()->addWidget(makeHintLabel( + tr("Changes apply immediately and are saved as per-agent overrides in settings; " + "Reset restores the value from the agent file."), + this)); + + m_extendsValue = makeReadOnlyLine(); + m_extendsValue->setPlaceholderText(tr("(none)")); + m_tagsValue = makeReadOnlyLine(); + m_tagsValue->setPlaceholderText(tr("(none)")); + m_endpointValue = makeReadOnlyLine(true); + m_endpointValue->setPlaceholderText(tr("(provider default)")); 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); + m_filePatternsValue->setPlaceholderText(tr("(matches every file)")); + + 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); m_rawToggle = new QToolButton(this); m_rawToggle->setText(tr("▸ Show raw TOML")); @@ -338,18 +280,49 @@ AgentDetailPane::AgentDetailPane(QWidget *parent) m_baseRawToggle->setText(on ? tr("▾ Hide base agent TOML") : tr("▸ Show base agent TOML")); }); + m_detailsBody = new QWidget(this); + auto *detailsLayout = new QVBoxLayout(m_detailsBody); + detailsLayout->setContentsMargins(0, 0, 0, 0); + detailsLayout->setSpacing(10); + + auto *detailsGrid = new QGridLayout; + detailsGrid->setContentsMargins(0, 0, 0, 0); + detailsGrid->setHorizontalSpacing(8); + detailsGrid->setVerticalSpacing(4); + FormBuilder(detailsGrid) + .row(tr("Extends:"), m_extendsValue) + .row(tr("Tags:"), m_tagsValue, tr("Free-form — used to filter and group the agent list.")) + .row(tr("Endpoint:"), m_endpointValue, tr("Appended to the provider's URL.")) + .row( + tr("File patterns:"), + m_filePatternsValue, + tr("Globs, comma-separated. When a feature slot has multiple bound " + "agents, the first whose match rules satisfy the context wins.")); + detailsLayout->addLayout(detailsGrid); + detailsLayout->addWidget(m_effectiveUrl); + detailsLayout->addWidget(m_rawToggle, 0, Qt::AlignLeft); + detailsLayout->addWidget(m_rawToml); + detailsLayout->addWidget(m_baseRawToggle, 0, Qt::AlignLeft); + detailsLayout->addWidget(m_baseRawToml); + m_detailsBody->setVisible(false); + + m_detailsToggle = new QToolButton(this); + m_detailsToggle->setText(tr("▸ More details")); + m_detailsToggle->setCursor(Qt::PointingHandCursor); + m_detailsToggle->setAutoRaise(true); + m_detailsToggle->setCheckable(true); + connect(m_detailsToggle, &QToolButton::toggled, this, [this](bool on) { + m_detailsBody->setVisible(on); + m_detailsToggle->setText(on ? tr("▾ More details") : tr("▸ More details")); + }); + m_body = new QWidget(this); auto *bodyLayout = new QVBoxLayout(m_body); bodyLayout->setContentsMargins(0, 0, 0, 0); bodyLayout->setSpacing(10); - bodyLayout->addWidget(identity); - bodyLayout->addWidget(connection); - bodyLayout->addWidget(capabilities); - bodyLayout->addWidget(match); - bodyLayout->addWidget(m_rawToggle, 0, Qt::AlignLeft); - bodyLayout->addWidget(m_rawToml); - bodyLayout->addWidget(m_baseRawToggle, 0, Qt::AlignLeft); - bodyLayout->addWidget(m_baseRawToml); + bodyLayout->addWidget(setup); + bodyLayout->addWidget(m_detailsToggle, 0, Qt::AlignLeft); + bodyLayout->addWidget(m_detailsBody); auto *root = new QVBoxLayout(this); root->setContentsMargins(12, 12, 12, 12); @@ -407,25 +380,19 @@ void AgentDetailPane::populateProviderCombo() m_providerComboPopulated = true; } -void AgentDetailPane::onChangeModel() +void AgentDetailPane::onModelSubmitted(const QString &model) { if (!m_agentFactory || !m_current) return; const QString name = m_current->name; - AgentModelDialog dialog(m_agentFactory, name, m_current->model, this); - if (dialog.exec() != QDialog::Accepted) - return; - - const QString model = dialog.selectedModel(); if (model == m_current->model) return; QString err; - if (!m_agentFactory->setAgentModelOverride(name, model, &err)) { + const bool ok = m_agentFactory->setAgentModelOverride(name, model, &err); + if (!ok) QMessageBox::warning(this, tr("Set model"), err); - return; - } if (const AgentConfig *cfg = m_agentFactory->configByName(name)) setAgent(*cfg); } @@ -540,22 +507,11 @@ void AgentDetailPane::setAgent(const AgentConfig &cfg) setDetailsVisible(true); m_name->setText(cfg.name); + m_sourceBadge->setText(user ? tr("User") : tr("Bundled")); 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) { @@ -589,14 +545,11 @@ void AgentDetailPane::setAgent(const AgentConfig &cfg) ? tr("Overridden in settings. Reset to use the agent's default provider.") : QString()); - m_endpointValue->setText(cfg.endpoint); - m_endpointValue->setPlaceholderText(tr("(provider default)")); - m_modelValue->setText(cfg.model); - m_modelChangeBtn->setEnabled(m_agentFactory != nullptr); + m_modelSelector->setAgent(m_agentFactory, cfg.name, cfg.providerInstance, cfg.model); const bool hasModelOverride = m_agentFactory && !m_agentFactory->agentModelOverride(cfg.name).isEmpty(); m_modelResetBtn->setVisible(hasModelOverride); - m_modelValue->setToolTip( + m_modelSelector->setToolTip( hasModelOverride ? tr("Overridden in settings. Reset to use the agent's default model.") : QString()); @@ -613,14 +566,16 @@ void AgentDetailPane::setAgent(const AgentConfig &cfg) hasToolsOverride ? tr("Overridden in settings. Reset to use the agent's default.") : QString()); + m_extendsValue->setText(cfg.extendsName); + m_tagsValue->setText(cfg.tags.join(QStringLiteral(", "))); + m_endpointValue->setText(cfg.endpoint); + m_filePatternsValue->setText(cfg.match.filePatterns.join(QStringLiteral(", "))); + 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_filePatternsValue->setText(cfg.match.filePatterns.join(QStringLiteral(", "))); - m_filePatternsValue->setPlaceholderText(tr("(matches every file)")); - fillRawToml(m_rawToml, cfg.sourcePath); const QString basePath = m_agentFactory ? m_agentFactory->sourcePathForName(cfg.extendsName) @@ -652,11 +607,6 @@ void AgentDetailPane::clear() 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; @@ -664,11 +614,8 @@ void AgentDetailPane::clear() m_providerCombo->setCurrentIndex(-1); m_providerCombo->setEnabled(false); m_providerResetBtn->setVisible(false); - m_endpointValue->clear(); - m_modelValue->clear(); - m_modelChangeBtn->setEnabled(false); + m_modelSelector->clearAgent(); m_modelResetBtn->setVisible(false); - m_effectiveUrl->clear(); m_thinkingValue->clear(); { QSignalBlocker block(m_toolsCheck); @@ -676,7 +623,11 @@ void AgentDetailPane::clear() } m_toolsCheck->setEnabled(false); m_toolsResetBtn->setVisible(false); + m_extendsValue->clear(); + m_tagsValue->clear(); + m_endpointValue->clear(); m_filePatternsValue->clear(); + m_effectiveUrl->clear(); m_rawToml->clear(); m_baseRawToml->clear(); m_baseRawToggle->setVisible(false); @@ -728,6 +679,7 @@ void AgentDetailPane::fillRawToml(QPlainTextEdit *view, const QString &path) void AgentDetailPane::setDetailsVisible(bool visible) { m_headerSep->setVisible(visible); + m_sourceBadge->setVisible(visible); m_path->setVisible(visible); m_actionsHolder->setVisible(visible); m_body->setVisible(visible); @@ -744,6 +696,8 @@ void AgentDetailPane::applyCodePalette() m_effectiveUrl->setStyleSheet( QStringLiteral("QLabel { background:%1; border:1px solid %2; }") .arg(cssColor(codeBg), cssColor(Utils::creatorColor(Utils::Theme::SplitterColor)))); + if (m_current) + styleSourceBadge(m_sourceBadge, m_current->isUserSource()); } } // namespace QodeAssist::Settings diff --git a/settings/AgentDetailPane.hpp b/settings/AgentDetailPane.hpp index 2010fdd..904799f 100644 --- a/settings/AgentDetailPane.hpp +++ b/settings/AgentDetailPane.hpp @@ -29,6 +29,7 @@ class ProviderInstanceFactory; namespace QodeAssist::Settings { +class ModelSelector; class SectionBox; class AgentDetailPane : public QWidget @@ -56,7 +57,7 @@ private: void setDetailsVisible(bool visible); void applyCodePalette(); void populateProviderCombo(); - void onChangeModel(); + void onModelSubmitted(const QString &model); void onResetModel(); void onChangeProvider(int index); void onResetProvider(); @@ -70,7 +71,11 @@ private: AgentConfig m_currentStorage; const AgentConfig *m_current = nullptr; + QPointer m_instanceFactory; + QPointer m_agentFactory; + QLabel *m_name = nullptr; + QLabel *m_sourceBadge = nullptr; QLabel *m_path = nullptr; QFrame *m_headerSep = nullptr; QWidget *m_actionsHolder = nullptr; @@ -80,32 +85,25 @@ private: 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; - + ModelSelector *m_modelSelector = nullptr; + QPushButton *m_modelResetBtn = nullptr; + QLabel *m_modelStatus = nullptr; QComboBox *m_providerCombo = nullptr; QPushButton *m_providerResetBtn = nullptr; - QPointer m_instanceFactory; - QPointer m_agentFactory; - QLineEdit *m_endpointValue = nullptr; - QLineEdit *m_modelValue = nullptr; - QPushButton *m_modelChangeBtn = nullptr; - QPushButton *m_modelResetBtn = nullptr; - QLabel *m_effectiveUrl = nullptr; - - QLabel *m_thinkingValue = nullptr; QCheckBox *m_toolsCheck = nullptr; QPushButton *m_toolsResetBtn = nullptr; + QLabel *m_thinkingValue = nullptr; + QToolButton *m_detailsToggle = nullptr; + QWidget *m_detailsBody = nullptr; + QLineEdit *m_extendsValue = nullptr; + QLineEdit *m_tagsValue = nullptr; + QLineEdit *m_endpointValue = nullptr; + QLabel *m_effectiveUrl = nullptr; QLineEdit *m_filePatternsValue = nullptr; QToolButton *m_rawToggle = nullptr; QPlainTextEdit *m_rawToml = nullptr; - QToolButton *m_baseRawToggle = nullptr; QPlainTextEdit *m_baseRawToml = nullptr; }; diff --git a/settings/AgentListItem.cpp b/settings/AgentListItem.cpp index aa4bf43..4f252d1 100644 --- a/settings/AgentListItem.cpp +++ b/settings/AgentListItem.cpp @@ -5,8 +5,9 @@ #include "AgentListItem.hpp" #include "SettingsTheme.hpp" -#include "TagChip.hpp" +#include "SettingsUiBuilders.hpp" +#include #include #include @@ -16,73 +17,52 @@ #include #include #include -#include namespace QodeAssist::Settings { AgentListItem::AgentListItem(const AgentConfig &cfg, QWidget *parent) : QFrame(parent) , m_name(cfg.name) + , m_model(cfg.model) { 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, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); - dot->setPalette(dp); - - auto *nameLbl = new QLabel(cfg.name, this); - QFont nf = nameLbl->font(); + m_nameLabel = new QLabel(this); + QFont nf = m_nameLabel->font(); nf.setBold(true); nf.setPixelSize(12); - nameLbl->setFont(nf); + m_nameLabel->setFont(nf); + m_nameLabel->setTextFormat(Qt::RichText); - 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); - - m_modelLabel = new QLabel(cfg.model, this); + m_modelLabel = new Utils::ElidingLabel(this); + m_modelLabel->setElideMode(Qt::ElideMiddle); m_modelLabel->setFont(monospaceFont(11)); - m_modelLabel->setContentsMargins(16, 0, 0, 0); + m_modelLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); QPalette mp = m_modelLabel->palette(); mp.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); m_modelLabel->setPalette(mp); + m_modelLabel->setText(cfg.model); m_modelLabel->setVisible(!cfg.model.isEmpty()); - col->addWidget(m_modelLabel); - 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 *row = new QHBoxLayout(this); + row->setContentsMargins(8, 4, 8, 4); + row->setSpacing(8); + row->addWidget(m_nameLabel, 0); + row->addWidget(m_modelLabel, 1); - auto *outer = new QVBoxLayout(this); - outer->setContentsMargins(5, 6, 8, 6); - outer->setSpacing(0); - outer->addLayout(col); + QStringList tipLines; + if (!cfg.description.isEmpty()) + tipLines << cfg.description; + if (!cfg.model.isEmpty()) + tipLines << tr("Model: %1").arg(cfg.model); + if (!cfg.tags.isEmpty()) + tipLines << tr("Tags: %1").arg(cfg.tags.join(QStringLiteral(", "))); + setToolTip(tipLines.join(QStringLiteral("\n"))); + updateNameText(); applyTheme(); } @@ -94,20 +74,26 @@ void AgentListItem::setSelected(bool selected) applyTheme(); } -void AgentListItem::setActiveTags(const QSet &active) -{ - for (auto *chip : m_chips) - chip->setActive(active.contains(chip->tag())); -} - void AgentListItem::setModel(const QString &model) { - if (!m_modelLabel) - return; + m_model = model; m_modelLabel->setText(model); m_modelLabel->setVisible(!model.isEmpty()); } +void AgentListItem::setFilterHighlight(const QString &lowerFilter) +{ + if (m_filter == lowerFilter) + return; + m_filter = lowerFilter; + updateNameText(); +} + +void AgentListItem::updateNameText() +{ + m_nameLabel->setText(filterHighlightedHtml(m_name, m_filter)); +} + void AgentListItem::mouseReleaseEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) @@ -133,6 +119,7 @@ void AgentListItem::applyTheme() "#AgentListItem { background:transparent;" " border-top:1px solid %1; border-left:3px solid %2; }") .arg(cssColor(Utils::creatorColor(Utils::Theme::SplitterColor)), accent)); + updateNameText(); } } // namespace QodeAssist::Settings diff --git a/settings/AgentListItem.hpp b/settings/AgentListItem.hpp index a89e9b2..59c4d12 100644 --- a/settings/AgentListItem.hpp +++ b/settings/AgentListItem.hpp @@ -5,8 +5,6 @@ #pragma once #include -#include -#include #include #include @@ -15,9 +13,11 @@ QT_BEGIN_NAMESPACE class QLabel; QT_END_NAMESPACE -namespace QodeAssist::Settings { +namespace Utils { +class ElidingLabel; +} -class TagChip; +namespace QodeAssist::Settings { class AgentListItem : public QFrame { @@ -27,12 +27,11 @@ public: QString agentName() const { return m_name; } void setSelected(bool selected); - void setActiveTags(const QSet &active); void setModel(const QString &model); + void setFilterHighlight(const QString &lowerFilter); signals: void clicked(const QString &name); - void tagClicked(const QString &tag); protected: void mouseReleaseEvent(QMouseEvent *event) override; @@ -40,12 +39,15 @@ protected: private: void applyTheme(); + void updateNameText(); QString m_name; + QString m_model; + QString m_filter; bool m_selected = false; bool m_inApplyTheme = false; - QLabel *m_modelLabel = nullptr; - QList m_chips; + QLabel *m_nameLabel = nullptr; + Utils::ElidingLabel *m_modelLabel = nullptr; }; } // namespace QodeAssist::Settings diff --git a/settings/AgentListPane.cpp b/settings/AgentListPane.cpp index 5a93b8c..f7e8c71 100644 --- a/settings/AgentListPane.cpp +++ b/settings/AgentListPane.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -37,8 +38,13 @@ AgentListPane::AgentListPane(AgentFactory *factory, QWidget *parent) setFrameShape(QFrame::StyledPanel); m_filterEdit = new QLineEdit(this); - m_filterEdit->setPlaceholderText(tr("Filter agents…")); + m_filterEdit->setPlaceholderText(tr("Filter by name, model, provider, tag…")); m_filterEdit->setClearButtonEnabled(true); + m_filterEdit->setToolTip(tr("Type to filter; Up/Down moves through the list.")); + m_filterEdit->installEventFilter(this); + setFocusProxy(m_filterEdit); + + m_tagStrip = new TagFilterStrip(this); auto *filterRow = new QHBoxLayout; filterRow->setContentsMargins(6, 6, 6, 6); @@ -48,8 +54,6 @@ AgentListPane::AgentListPane(AgentFactory *factory, QWidget *parent) 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); @@ -59,19 +63,22 @@ AgentListPane::AgentListPane(AgentFactory *factory, QWidget *parent) 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_filterEdit, &QLineEdit::textChanged, this, [this](const QString &) { + m_filterDebounce->start(); + }); - connect(m_tagStrip, &TagFilterStrip::activeTagsChanged, this, - [this](const QSet &) { rebuildList(); }, - Qt::QueuedConnection); + connect( + m_tagStrip, + &TagFilterStrip::activeTagsChanged, + this, + [this](const QSet &) { rebuildList(); }, + Qt::QueuedConnection); if (m_factory) { connect(m_factory, &AgentFactory::agentModelChanged, this, [this](const QString &name) { @@ -93,7 +100,7 @@ void AgentListPane::selectByName(const QString &name) return; if (m_factory) { if (const AgentConfig *cfg = m_factory->configByName(name)) - m_expandedGroups.insert(groupKey(*cfg)); + m_collapsedGroups.remove(groupKey(*cfg)); } setCurrentNameInternal(name, false); m_notifyOnRebuild = true; @@ -121,6 +128,12 @@ void AgentListPane::refresh() rebuildList(); } +void AgentListPane::focusFilter() +{ + m_filterEdit->setFocus(Qt::OtherFocusReason); + m_filterEdit->selectAll(); +} + void AgentListPane::changeEvent(QEvent *event) { QFrame::changeEvent(event); @@ -128,6 +141,41 @@ void AgentListPane::changeEvent(QEvent *event) applyFilterHolderTheme(); } +bool AgentListPane::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == m_filterEdit && event->type() == QEvent::KeyPress) { + auto *ke = static_cast(event); + if (ke->key() == Qt::Key_Down) { + moveSelection(1); + return true; + } + if (ke->key() == Qt::Key_Up) { + moveSelection(-1); + return true; + } + } + return QFrame::eventFilter(watched, event); +} + +void AgentListPane::moveSelection(int delta) +{ + if (m_rows.isEmpty()) + return; + int cur = -1; + for (int i = 0; i < m_rows.size(); ++i) { + if (m_rows.at(i)->agentName() == m_currentName) { + cur = i; + break; + } + } + int next = cur < 0 ? (delta > 0 ? 0 : int(m_rows.size()) - 1) : cur + delta; + next = qBound(0, next, int(m_rows.size()) - 1); + if (next == cur) + return; + setCurrentNameInternal(m_rows.at(next)->agentName(), true); + m_listScroll->ensureWidgetVisible(m_rows.at(next), 0, 40); +} + void AgentListPane::applyFilterHolderTheme() { if (!m_filterHolder) @@ -156,9 +204,14 @@ std::vector AgentListPane::visibleAgents() const bool AgentListPane::matchesFilters(const AgentConfig &a, const QString &lowerFilter) const { - if (!lowerFilter.isEmpty() - && !(a.name + QLatin1Char(' ') + a.model).toLower().contains(lowerFilter)) - return false; + if (!lowerFilter.isEmpty()) { + const QString haystack = (a.name + QLatin1Char(' ') + a.model + QLatin1Char(' ') + + a.providerInstance + QLatin1Char(' ') + + a.tags.join(QLatin1Char(' '))) + .toLower(); + if (!haystack.contains(lowerFilter)) + return false; + } const QSet &active = m_tagStrip->activeTags(); for (const QString &t : active) if (!a.tags.contains(t)) @@ -199,14 +252,8 @@ void AgentListPane::rebuildList() for (const AgentConfig *cfg : agents) { auto *item = new AgentListItem(*cfg, content); item->setSelected(cfg->name == m_currentName); - item->setActiveTags(activeTags); + item->setFilterHighlight(lowerFilter); connect(item, &AgentListItem::clicked, this, &AgentListPane::onRowClicked); - connect( - item, - &AgentListItem::tagClicked, - this, - [this](const QString &tag) { m_tagStrip->toggleTag(tag); }, - Qt::QueuedConnection); contentLayout->addWidget(item); newRows.append(item); } @@ -231,7 +278,7 @@ void AgentListPane::rebuildList() const std::vector &group = byProvider[provider]; const QString key = sectionKey + QLatin1Char('/') + provider; liveKeys.insert(key); - const bool expanded = filtersActive || m_expandedGroups.contains(key); + const bool expanded = filtersActive || !m_collapsedGroups.contains(key); auto *header = new CollapsibleHeader(provider, int(group.size()), content); header->setExpanded(expanded); @@ -242,8 +289,8 @@ void AgentListPane::rebuildList() &CollapsibleHeader::toggled, this, [this, key] { - if (!m_expandedGroups.remove(key)) - m_expandedGroups.insert(key); + if (!m_collapsedGroups.remove(key)) + m_collapsedGroups.insert(key); rebuildList(); }, Qt::QueuedConnection); @@ -258,7 +305,7 @@ void AgentListPane::rebuildList() addSection(tr("User"), QStringLiteral("user"), userAgents); addSection(tr("Bundled"), QStringLiteral("bundled"), bundledAgents); if (!filtersActive) - m_expandedGroups.intersect(liveKeys); + m_collapsedGroups.intersect(liveKeys); if (userAgents.empty() && bundledAgents.empty()) { auto *empty = new QLabel(tr("No agents match these filters."), content); empty->setAlignment(Qt::AlignCenter); @@ -273,10 +320,9 @@ void AgentListPane::rebuildList() m_rows = newRows; m_listScroll->setWidget(content); - const AgentConfig *current - = m_currentName.isEmpty() || !m_factory - ? nullptr - : m_factory->configByName(m_currentName); + 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); diff --git a/settings/AgentListPane.hpp b/settings/AgentListPane.hpp index 7d8f011..112e283 100644 --- a/settings/AgentListPane.hpp +++ b/settings/AgentListPane.hpp @@ -4,11 +4,11 @@ #pragma once +#include #include #include #include #include -#include #include @@ -35,15 +35,19 @@ public: QString currentName() const { return m_currentName; } void selectByName(const QString &name); void refresh(); + void focusFilter(); + TagFilterStrip *tagStrip() const { return m_tagStrip; } signals: void currentAgentChanged(const QString &name); protected: void changeEvent(QEvent *event) override; + bool eventFilter(QObject *watched, QEvent *event) override; private: void rebuildList(); + void moveSelection(int delta); void applyFilterHolderTheme(); bool matchesFilters(const AgentConfig &a, const QString &lowerFilter) const; std::vector visibleAgents() const; @@ -60,7 +64,7 @@ private: QScrollArea *m_listScroll = nullptr; QList m_rows; QString m_currentName; - QSet m_expandedGroups; + QSet m_collapsedGroups; bool m_notifyOnRebuild = false; }; diff --git a/settings/AgentModelDialog.cpp b/settings/AgentModelDialog.cpp deleted file mode 100644 index c2d9585..0000000 --- a/settings/AgentModelDialog.cpp +++ /dev/null @@ -1,139 +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 "AgentModelDialog.hpp" - -#include "SettingsTheme.hpp" - -#include - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace QodeAssist::Settings { - -AgentModelDialog::AgentModelDialog( - AgentFactory *factory, const QString &agentName, const QString ¤tModel, QWidget *parent) - : QDialog(parent) - , m_factory(factory) - , m_agentName(agentName) -{ - setWindowTitle(tr("Select Model")); - resize(440, 380); - - m_modelEdit = new QLineEdit(currentModel, this); - m_modelEdit->setFont(monospaceFont(11)); - m_modelEdit->setPlaceholderText(tr("Type a model name or pick one below")); - m_modelEdit->setClearButtonEnabled(true); - - m_fetchBtn = new QPushButton(tr("Fetch available models"), this); - m_fetchBtn->setToolTip(tr("Query the agent's provider for its installed models")); - - m_status = new QLabel(this); - m_status->setFont(monospaceFont(10)); - QPalette sp = m_status->palette(); - sp.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); - m_status->setPalette(sp); - - m_list = new QListWidget(this); - m_list->setFont(monospaceFont(11)); - - auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); - - connect(m_list, &QListWidget::currentTextChanged, this, [this](const QString &text) { - if (!text.isEmpty()) - m_modelEdit->setText(text); - }); - connect(m_list, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *) { accept(); }); - connect(m_fetchBtn, &QPushButton::clicked, this, [this] { fetchModels(); }); - connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); - connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); - - auto *fetchRow = new QHBoxLayout; - fetchRow->setContentsMargins(0, 0, 0, 0); - fetchRow->setSpacing(8); - fetchRow->addWidget(m_fetchBtn); - fetchRow->addWidget(m_status, 1); - - auto *root = new QVBoxLayout(this); - root->addWidget(new QLabel(tr("Model:"), this)); - root->addWidget(m_modelEdit); - root->addLayout(fetchRow); - root->addWidget(m_list, 1); - root->addWidget(buttons); - - fetchModels(); -} - -QString AgentModelDialog::selectedModel() const -{ - return m_modelEdit->text().trimmed(); -} - -void AgentModelDialog::fetchModels() -{ - if (!m_factory) - return; - if (m_watcher && m_watcher->isRunning()) - return; - - QString err; - Agent *probe = m_factory->create(m_agentName, this, &err); - if (!probe) { - m_status->setText( - err.isEmpty() ? tr("Provider is not available.") - : tr("Provider is not available: %1").arg(err)); - return; - } - m_probe = probe; - - if (!m_watcher) { - m_watcher = new QFutureWatcher>(this); - connect(m_watcher, &QFutureWatcher>::finished, this, [this] { - onModelsFetched(); - }); - } - - m_fetchBtn->setEnabled(false); - m_status->setText(tr("Loading models…")); - m_watcher->setFuture(probe->installedModels()); -} - -void AgentModelDialog::onModelsFetched() -{ - QList models; - if (m_watcher->future().resultCount() > 0) - models = m_watcher->result(); - - if (m_probe) { - m_probe->deleteLater(); - m_probe.clear(); - } - - m_fetchBtn->setEnabled(true); - - const QString keep = m_modelEdit->text(); - { - QSignalBlocker block(m_list); - m_list->clear(); - m_list->addItems(models); - } - m_modelEdit->setText(keep); - - m_status->setText( - models.isEmpty() ? tr("No models returned — type the model name manually.") - : tr("%n model(s) available.", nullptr, static_cast(models.size()))); -} - -} // namespace QodeAssist::Settings diff --git a/settings/AgentsSettingsPage.cpp b/settings/AgentsSettingsPage.cpp index a6a7d45..bfccae4 100644 --- a/settings/AgentsSettingsPage.cpp +++ b/settings/AgentsSettingsPage.cpp @@ -8,24 +8,21 @@ #include "AgentDuplicator.hpp" #include "AgentListPane.hpp" #include "SettingsConstants.hpp" -#include "SettingsTheme.hpp" +#include "TagFilterStrip.hpp" #include #include #include -#include #include #include #include #include -#include #include #include #include #include -#include #include #include #include @@ -76,13 +73,7 @@ public: 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, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); - m_userPathLabel->setPalette(mutedPal); - m_userPathLabel->setMaximumWidth(260); + m_openUserDir->setToolTip(QodeAssist::AgentFactory::userAgentsDir()); auto *headerRow = new QHBoxLayout; headerRow->setContentsMargins(0, 0, 0, 0); @@ -90,7 +81,6 @@ public: headerRow->addWidget(m_titleLabel); headerRow->addStretch(1); headerRow->addWidget(m_reload); - headerRow->addWidget(m_userPathLabel); headerRow->addWidget(m_openUserDir); auto *headerSep = new QFrame(this); @@ -98,6 +88,7 @@ public: headerSep->setFrameShadow(QFrame::Sunken); m_listPane = new AgentListPane(m_agentFactory, this); + m_listPane->tagStrip()->setMaxColumns(8); m_detail = new AgentDetailPane(this); m_detail->setInstanceFactory(m_agentFactory->instanceFactory()); @@ -119,6 +110,7 @@ public: root->setSpacing(6); root->addLayout(headerRow); root->addWidget(headerSep); + root->addWidget(m_listPane->tagStrip()); root->addWidget(splitter, 1); connect(m_reload, &QPushButton::clicked, this, &AgentsWidget::reloadFromDisk); @@ -153,6 +145,8 @@ public: reloadFromDisk(); + QTimer::singleShot(0, this, [this] { m_listPane->focusFilter(); }); + if (m_navigator) { QTimer::singleShot(0, this, [this] { if (!m_navigator) @@ -170,18 +164,9 @@ private: void reloadFromDisk() { m_agentFactory->reload(); - 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; @@ -253,7 +238,6 @@ private: QLabel *m_titleLabel = nullptr; QPushButton *m_reload = nullptr; QPushButton *m_openUserDir = nullptr; - QLabel *m_userPathLabel = nullptr; AgentListPane *m_listPane = nullptr; QScrollArea *m_detailScroll = nullptr; diff --git a/settings/CMakeLists.txt b/settings/CMakeLists.txt index 98c81f1..eed0e7c 100644 --- a/settings/CMakeLists.txt +++ b/settings/CMakeLists.txt @@ -24,7 +24,7 @@ add_library(QodeAssistSettings STATIC UpdateDialog.hpp UpdateDialog.cpp AgentsSettingsPage.hpp AgentsSettingsPage.cpp AgentDetailPane.hpp AgentDetailPane.cpp - AgentModelDialog.hpp AgentModelDialog.cpp + ModelSelector.hpp ModelSelector.cpp AgentListItem.hpp AgentListItem.cpp AgentListPane.hpp AgentListPane.cpp AgentDuplicator.hpp AgentDuplicator.cpp diff --git a/settings/GeneralSettings.cpp b/settings/GeneralSettings.cpp index 267b6a1..a3ab0c4 100644 --- a/settings/GeneralSettings.cpp +++ b/settings/GeneralSettings.cpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include #include #include @@ -43,6 +45,16 @@ namespace { constexpr int kSaveDebounceMs = 300; +QString pluginVersionText() +{ + const auto pluginSpecs = ExtensionSystem::PluginManager::plugins(); + for (const ExtensionSystem::PluginSpec *spec : pluginSpecs) { + if (spec->name() == QLatin1String("QodeAssist")) + return QStringLiteral("QodeAssist %1").arg(spec->version()); + } + return QStringLiteral("QodeAssist"); +} + class AgentPipelinesWidget : public QWidget { Q_OBJECT @@ -261,12 +273,13 @@ GeneralSettings::GeneralSettings() requestTimeout.setSettingsKey(Constants::REQUEST_TIMEOUT); requestTimeout.setLabelText(Tr::tr("Request timeout (seconds):")); - requestTimeout.setToolTip(Tr::tr( - "Maximum time to wait for the model to send data before a request is aborted. " - "Applies to all requests — chat, code completion, quick refactor and chat compression. " - "The timer resets every time data is received, so this effectively limits the " - "time-to-first-token and any stall between tokens. Increase it for slow or local " - "models that need a long time to start responding. Set to 0 to disable the timeout.")); + requestTimeout.setToolTip( + Tr::tr( + "Maximum time to wait for the model to send data before a request is aborted. " + "Applies to all requests — chat, code completion, quick refactor and chat compression. " + "The timer resets every time data is received, so this effectively limits the " + "time-to-first-token and any stall between tokens. Increase it for slow or local " + "models that need a long time to start responding. Set to 0 to disable the timeout.")); requestTimeout.setRange(0, 3600); requestTimeout.setDefaultValue(120); @@ -282,9 +295,15 @@ GeneralSettings::GeneralSettings() setLayouter([this]() { using namespace Layouting; - auto networkGroup = Group{ - title(Tr::tr("Network")), - Column{Row{requestTimeout, Stretch{1}}}}; + auto *versionLabel = new QLabel(pluginVersionText()); + versionLabel->setEnabled(false); + + auto advancedGroup = Group{ + title(Tr::tr("Advanced")), + Column{ + Row{enableLogging, Stretch{1}}, + Row{enableCheckUpdate, Stretch{1}}, + Row{requestTimeout, Stretch{1}}}}; auto *supportLabel = new QLabel(Tr::tr("Support the development of QodeAssist:")); @@ -307,16 +326,13 @@ GeneralSettings::GeneralSettings() }; return Column{ - Row{supportLabel, supportLinks, Stretch{1}, checkUpdate, resetToDefaults}, - Space{8}, - Row{enableQodeAssist, Stretch{1}}, - Row{enableLogging, Stretch{1}}, - Row{enableCheckUpdate, Stretch{1}}, - Space{8}, - networkGroup, + Row{enableQodeAssist, Stretch{1}, versionLabel, checkUpdate, resetToDefaults}, Space{12}, pipelines, - Stretch{1}}; + Space{12}, + advancedGroup, + Stretch{1}, + Row{supportLabel, supportLinks, Stretch{1}}}; }); } diff --git a/settings/ModelSelector.cpp b/settings/ModelSelector.cpp new file mode 100644 index 0000000..f1a714b --- /dev/null +++ b/settings/ModelSelector.cpp @@ -0,0 +1,176 @@ +// 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 "ModelSelector.hpp" + +#include "SettingsTheme.hpp" + +#include +#include + +#include +#include +#include +#include +#include + +namespace QodeAssist::Settings { + +ModelSelector::ModelSelector(QWidget *parent) + : QComboBox(parent) +{ + setEditable(true); + setInsertPolicy(QComboBox::NoInsert); + setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon); + setMinimumContentsLength(24); + setFont(monospaceFont(11)); + lineEdit()->setPlaceholderText(tr("(set a model)")); + lineEdit()->setClearButtonEnabled(true); + setEnabled(false); + + auto *completer = new QCompleter(model(), this); + completer->setFilterMode(Qt::MatchContains); + completer->setCaseSensitivity(Qt::CaseInsensitive); + completer->setCompletionMode(QCompleter::PopupCompletion); + setCompleter(completer); + + connect(this, &QComboBox::activated, this, [this](int) { submitCurrent(); }); + connect(lineEdit(), &QLineEdit::returnPressed, this, [this] { submitCurrent(); }); + connect( + completer, + QOverload::of(&QCompleter::activated), + this, + [this](const QString &text) { + setEditText(text); + submitCurrent(); + }, + Qt::QueuedConnection); +} + +void ModelSelector::setAgent( + AgentFactory *factory, + const QString &agentName, + const QString &providerInstance, + const QString &model) +{ + const bool sameTarget = factory == m_factory && agentName == m_agentName + && providerInstance == m_providerInstance; + m_factory = factory; + m_agentName = agentName; + m_providerInstance = providerInstance; + m_model = model; + if (!sameTarget) { + m_fetched = false; + { + QSignalBlocker block(this); + clear(); + } + emit statusChanged(QString()); + } + { + QSignalBlocker block(this); + setCurrentIndex(findText(model)); + setEditText(model); + } + setEnabled(m_factory != nullptr); +} + +void ModelSelector::clearAgent() +{ + m_factory = nullptr; + m_agentName.clear(); + m_providerInstance.clear(); + m_model.clear(); + m_fetched = false; + { + QSignalBlocker block(this); + clear(); + clearEditText(); + } + setEnabled(false); + emit statusChanged(QString()); +} + +void ModelSelector::showPopup() +{ + if (!m_fetched && m_factory && !(m_watcher && m_watcher->isRunning())) + startFetch(); + QComboBox::showPopup(); +} + +void ModelSelector::startFetch() +{ + QString err; + Agent *probe = m_factory->create(m_agentName, this, &err); + if (!probe) { + emit statusChanged( + err.isEmpty() ? tr("Provider is not available.") + : tr("Provider is not available: %1").arg(err)); + return; + } + m_probe = probe; + m_fetchAgent = m_agentName; + m_fetchProvider = m_providerInstance; + + if (!m_watcher) { + m_watcher = new QFutureWatcher>(this); + connect(m_watcher, &QFutureWatcher>::finished, this, [this] { onFetched(); }); + } + + const QString keep = currentText(); + { + QSignalBlocker block(this); + clear(); + addItem(tr("Loading models…")); + if (auto *itemModel = qobject_cast(model())) + itemModel->item(0)->setEnabled(false); + setCurrentIndex(-1); + setEditText(keep); + } + emit statusChanged(tr("Loading models…")); + m_watcher->setFuture(probe->installedModels()); +} + +void ModelSelector::onFetched() +{ + QList models; + if (m_watcher->future().resultCount() > 0) + models = m_watcher->result(); + + if (m_probe) { + m_probe->deleteLater(); + m_probe.clear(); + } + + if (m_fetchAgent != m_agentName || m_fetchProvider != m_providerInstance) + return; + + m_fetched = true; + const QString keep = currentText(); + const bool popupOpen = view() && view()->isVisible(); + { + QSignalBlocker block(this); + clear(); + addItems(models); + setCurrentIndex(findText(keep)); + setEditText(keep); + } + emit statusChanged( + models.isEmpty() ? tr("No models returned — type the model name manually.") + : tr("%n model(s) available.", nullptr, static_cast(models.size()))); + if (popupOpen) { + hidePopup(); + QComboBox::showPopup(); + } +} + +void ModelSelector::submitCurrent() +{ + const QString model = currentText().trimmed(); + if (model.isEmpty() || model == m_model) + return; + emit modelSubmitted(model); +} + +} // namespace QodeAssist::Settings diff --git a/settings/AgentModelDialog.hpp b/settings/ModelSelector.hpp similarity index 50% rename from settings/AgentModelDialog.hpp rename to settings/ModelSelector.hpp index 743353c..a243e9a 100644 --- a/settings/AgentModelDialog.hpp +++ b/settings/ModelSelector.hpp @@ -4,52 +4,53 @@ #pragma once -#include +#include #include #include #include #include -QT_BEGIN_NAMESPACE -class QLabel; -class QLineEdit; -class QListWidget; -class QPushButton; -QT_END_NAMESPACE - namespace QodeAssist { -class AgentFactory; class Agent; +class AgentFactory; } // namespace QodeAssist namespace QodeAssist::Settings { -class AgentModelDialog : public QDialog +class ModelSelector : public QComboBox { Q_OBJECT public: - AgentModelDialog( + explicit ModelSelector(QWidget *parent = nullptr); + + void setAgent( AgentFactory *factory, const QString &agentName, - const QString ¤tModel, - QWidget *parent = nullptr); + const QString &providerInstance, + const QString &model); + void clearAgent(); - [[nodiscard]] QString selectedModel() const; +signals: + void modelSubmitted(const QString &model); + void statusChanged(const QString &status); + +protected: + void showPopup() override; private: - void fetchModels(); - void onModelsFetched(); - - AgentFactory *m_factory = nullptr; - QString m_agentName; - - QLineEdit *m_modelEdit = nullptr; - QListWidget *m_list = nullptr; - QLabel *m_status = nullptr; - QPushButton *m_fetchBtn = nullptr; + void startFetch(); + void onFetched(); + void submitCurrent(); + QPointer m_factory; QPointer m_probe; QFutureWatcher> *m_watcher = nullptr; + QString m_agentName; + QString m_providerInstance; + QString m_model; + QString m_fetchAgent; + QString m_fetchProvider; + bool m_fetched = false; }; } // namespace QodeAssist::Settings diff --git a/settings/ProviderDetailPane.cpp b/settings/ProviderDetailPane.cpp index 054bff2..f48ee90 100644 --- a/settings/ProviderDetailPane.cpp +++ b/settings/ProviderDetailPane.cpp @@ -36,6 +36,9 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent) nf.setPixelSize(15); m_nameLabel->setFont(nf); + m_sourceBadge = new QLabel(this); + m_sourceBadge->setVisible(false); + m_sourcePathLabel = new QLabel(this); m_sourcePathLabel->setFont(monospaceFont(11)); QPalette spp = m_sourcePathLabel->palette(); @@ -66,11 +69,10 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent) setEditing(false); populate(m_current, m_currentHasStoredKey); }); - connect(m_saveBtn, &QPushButton::clicked, this, [this] { - emit saveRequested(collectEdits()); + connect(m_saveBtn, &QPushButton::clicked, this, [this] { emit saveRequested(collectEdits()); }); + connect(m_openInEditorBtn, &QPushButton::clicked, this, [this] { + emit openInEditorRequested(m_current.sourcePath); }); - connect(m_openInEditorBtn, &QPushButton::clicked, this, - [this] { emit openInEditorRequested(m_current.sourcePath); }); connect(m_dupBtn, &QPushButton::clicked, this, [this] { emit duplicateRequested(); }); connect(m_deleteBtn, &QPushButton::clicked, this, [this] { emit deleteRequested(); }); @@ -88,6 +90,7 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent) titleRow->setContentsMargins(0, 0, 0, 0); titleRow->setSpacing(8); titleRow->addWidget(m_nameLabel); + titleRow->addWidget(m_sourceBadge, 0, Qt::AlignVCenter); titleRow->addStretch(1); auto *headerLeft = new QVBoxLayout; @@ -111,7 +114,7 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent) m_descriptionLabel->setWordWrap(true); m_descriptionLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); - auto *identitySection = new SectionBox(tr("Identity"), this); + m_identitySection = new SectionBox(tr("Identity"), this); m_nameEdit = new QLineEdit(this); m_descriptionEdit = new QPlainTextEdit(this); m_descriptionEdit->setMaximumHeight(60); @@ -121,29 +124,12 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent) identityGrid->setHorizontalSpacing(8); identityGrid->setVerticalSpacing(4); FormBuilder(identityGrid).row(tr("Name:"), m_nameEdit).row(tr("Description:"), m_descriptionEdit); - identitySection->bodyLayout()->addLayout(identityGrid); + m_identitySection->bodyLayout()->addLayout(identityGrid); + m_identitySection->setVisible(false); - auto *endpointSection = new SectionBox(tr("Endpoint"), this); + auto *configSection = new SectionBox(tr("Configuration"), this); m_urlEdit = new QLineEdit(this); m_urlEdit->setFont(monospaceFont(11)); - auto *endpointGrid = new QGridLayout; - endpointGrid->setContentsMargins(0, 0, 0, 0); - endpointGrid->setHorizontalSpacing(8); - endpointGrid->setVerticalSpacing(4); - 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); - m_samplePreview->setFont(monospaceFont(11)); - m_samplePreview->setTextInteractionFlags(Qt::TextSelectableByMouse); - m_samplePreview->setWordWrap(true); - m_samplePreview->setContentsMargins(6, 4, 6, 4); - m_samplePreview->setAutoFillBackground(true); - endpointSection->bodyLayout()->addWidget(m_samplePreview); - - auto *credSection = new SectionBox(tr("Credentials"), this); m_apiKeyEdit = new QLineEdit(this); m_apiKeyEdit->setEchoMode(QLineEdit::Password); m_apiKeyEdit->setPlaceholderText(tr("Enter API key…")); @@ -182,8 +168,7 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent) emit apiKeySaveRequested(key); m_apiKeyEdit->clear(); }); - connect(m_apiKeyClearBtn, &QPushButton::clicked, this, - [this] { emit apiKeyClearRequested(); }); + connect(m_apiKeyClearBtn, &QPushButton::clicked, this, [this] { emit apiKeyClearRequested(); }); m_keyHint = makeHintLabel(QString{}, this); auto *keyRow = new QHBoxLayout; @@ -194,15 +179,24 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent) keyRow->addWidget(m_apiKeySaveBtn); keyRow->addWidget(m_apiKeyClearBtn); - auto *credGrid = new QGridLayout; - credGrid->setContentsMargins(0, 0, 0, 0); - credGrid->setHorizontalSpacing(8); - credGrid->setVerticalSpacing(4); - FormBuilder credForm(credGrid); - credForm.row(tr("API key:"), keyRow); - credGrid->addWidget(m_legacyKeyBtn, credForm.currentRow(), 1, Qt::AlignLeft); - credGrid->addWidget(m_keyHint, credForm.currentRow() + 1, 1); - credSection->bodyLayout()->addLayout(credGrid); + auto *configGrid = new QGridLayout; + configGrid->setContentsMargins(0, 0, 0, 0); + configGrid->setHorizontalSpacing(8); + configGrid->setVerticalSpacing(4); + FormBuilder configForm(configGrid); + configForm.row(tr("API key:"), keyRow); + { + int row = configForm.currentRow(); + configGrid->addWidget(m_legacyKeyBtn, row, 1, Qt::AlignLeft); + configGrid->addWidget(m_keyHint, row + 1, 1); + configForm = FormBuilder(configGrid, row + 2); + } + configForm.row( + tr("URL:"), + m_urlEdit, + tr("Base URL. Agents append their endpoint path " + "(e.g. /chat/completions) to this. Editable via Edit…")); + configSection->bodyLayout()->addLayout(configGrid); m_launchSection = new SectionBox(tr("Launch"), this); m_launchEmptyHint = new QLabel(this); @@ -218,12 +212,15 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent) m_startBtn = new QPushButton(tr("Start"), this); m_stopBtn = new QPushButton(tr("Stop"), this); m_restartBtn = new QPushButton(tr("Restart"), this); - connect(m_startBtn, &QPushButton::clicked, this, - [this] { emit launchStartRequested(m_current.name); }); - connect(m_stopBtn, &QPushButton::clicked, this, - [this] { emit launchStopRequested(m_current.name); }); - connect(m_restartBtn, &QPushButton::clicked, this, - [this] { emit launchRestartRequested(m_current.name); }); + connect(m_startBtn, &QPushButton::clicked, this, [this] { + emit launchStartRequested(m_current.name); + }); + connect(m_stopBtn, &QPushButton::clicked, this, [this] { + emit launchStopRequested(m_current.name); + }); + connect(m_restartBtn, &QPushButton::clicked, this, [this] { + emit launchRestartRequested(m_current.name); + }); auto *launchBtnRow = new QHBoxLayout; launchBtnRow->setContentsMargins(0, 0, 0, 0); launchBtnRow->setSpacing(6); @@ -283,9 +280,8 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent) root->addLayout(headerRow); root->addWidget(headerSep); root->addWidget(m_descriptionLabel); - root->addWidget(identitySection); - root->addWidget(endpointSection); - root->addWidget(credSection); + root->addWidget(m_identitySection); + root->addWidget(configSection); root->addWidget(m_launchSection); root->addWidget(m_rawToggle, 0, Qt::AlignLeft); root->addWidget(m_rawToml); @@ -302,6 +298,9 @@ void ProviderDetailPane::populate(const Providers::ProviderInstance &inst, bool const bool needsKey = !inst.apiKeyRef.isEmpty(); m_nameLabel->setText(inst.name); + m_sourceBadge->setText(isUser ? tr("User") : tr("Bundled")); + m_sourceBadge->setVisible(true); + styleSourceBadge(m_sourceBadge, isUser); m_sourcePathLabel->setText(inst.sourcePath); m_descriptionLabel->setText( @@ -323,8 +322,9 @@ void ProviderDetailPane::populate(const Providers::ProviderInstance &inst, bool m_keyHint->setText(tr("This provider type does not use a key.")); } else if (hasStoredKey) { m_apiKeyEdit->setPlaceholderText(tr("Stored — enter a new key to replace it.")); - m_keyHint->setText(tr("A key is stored. Type a new key and press Save key to " - "replace it, or Clear to erase it.")); + m_keyHint->setText( + tr("A key is stored. Type a new key and press Save key to " + "replace it, or Clear to erase it.")); } else { m_apiKeyEdit->setPlaceholderText(tr("Enter API key…")); m_keyHint->setText(tr("No key stored yet. Type a key and press Save key.")); @@ -341,10 +341,6 @@ void ProviderDetailPane::populate(const Providers::ProviderInstance &inst, bool m_legacyKeyBtn->setVisible(false); } - m_samplePreview->setText( - QStringLiteral("# sample request line\nPOST %1/").arg(inst.url)); - applyPreviewPalette(); - m_deleteBtn->setEnabled(isUser); m_dupBtn->setEnabled(true); m_editBtn->setVisible(isUser); @@ -360,6 +356,7 @@ void ProviderDetailPane::clear() { m_current = {}; m_nameLabel->setText(tr("Select a provider")); + m_sourceBadge->setVisible(false); m_sourcePathLabel->clear(); m_descriptionLabel->clear(); m_nameEdit->clear(); @@ -373,7 +370,6 @@ void ProviderDetailPane::clear() m_revealKeyBtn->setEnabled(false); m_legacyKeyValue.clear(); m_legacyKeyBtn->setVisible(false); - m_samplePreview->clear(); m_rawToml->clear(); m_editBtn->setVisible(false); m_dupBtn->setEnabled(false); @@ -390,8 +386,9 @@ void ProviderDetailPane::refreshKeyStatus(bool hasStoredKey) return; if (hasStoredKey) { m_apiKeyEdit->setPlaceholderText(tr("Stored — enter a new key to replace it.")); - m_keyHint->setText(tr("A key is stored. Type a new key and press Save key to " - "replace it, or Clear to erase it.")); + m_keyHint->setText( + tr("A key is stored. Type a new key and press Save key to " + "replace it, or Clear to erase it.")); } else { m_apiKeyEdit->setPlaceholderText(tr("Enter API key…")); m_keyHint->setText(tr("No key stored yet. Type a key and press Save key.")); @@ -412,11 +409,11 @@ void ProviderDetailPane::setLaunchState( m_launchTerminalToggle->setVisible(hasLaunch); if (!hasLaunch) { - m_launchEmptyHint->setText(tr( - "No [launch] block. This provider is treated as external — " - "the plugin will not spawn or supervise any process. " - "Add a [launch] block to the TOML to have the plugin manage " - "a local server here.")); + m_launchEmptyHint->setText( + tr("No [launch] block. This provider is treated as external — " + "the plugin will not spawn or supervise any process. " + "Add a [launch] block to the TOML to have the plugin manage " + "a local server here.")); m_launchCmdLabel->clear(); m_launchTerminal->clearContents(); return; @@ -429,22 +426,31 @@ void ProviderDetailPane::setLaunchState( Utils::creatorColor(Utils::Theme::PanelTextColorMid).name(), tr("(detached — survives Qt Creator restart)")) : QString(); - m_launchCmdLabel->setText( - QStringLiteral("%1 %2%3") - .arg(m_current.launch.command.toHtmlEscaped(), - m_current.launch.args.join(QLatin1Char(' ')).toHtmlEscaped(), - detachedNote)); + m_launchCmdLabel->setText(QStringLiteral("%1 %2%3") + .arg( + m_current.launch.command.toHtmlEscaped(), + m_current.launch.args.join(QLatin1Char(' ')).toHtmlEscaped(), + detachedNote)); QString statusText; switch (st) { - case Providers::ProviderLauncher::Idle: statusText = tr("idle"); break; - case Providers::ProviderLauncher::Starting: statusText = tr("starting…"); break; - case Providers::ProviderLauncher::Probing: statusText = tr("probing…"); break; - case Providers::ProviderLauncher::Ready: statusText = tr("ready"); break; - case Providers::ProviderLauncher::Stopping: statusText = tr("stopping…"); break; + case Providers::ProviderLauncher::Idle: + statusText = tr("idle"); + break; + case Providers::ProviderLauncher::Starting: + statusText = tr("starting…"); + break; + case Providers::ProviderLauncher::Probing: + statusText = tr("probing…"); + break; + case Providers::ProviderLauncher::Ready: + statusText = tr("ready"); + break; + case Providers::ProviderLauncher::Stopping: + statusText = tr("stopping…"); + break; case Providers::ProviderLauncher::Failed: - statusText = lastError.isEmpty() ? tr("failed") - : tr("failed — %1").arg(lastError); + statusText = lastError.isEmpty() ? tr("failed") : tr("failed — %1").arg(lastError); break; } m_launchStatusPill->setText(statusText); @@ -473,8 +479,9 @@ void ProviderDetailPane::changeEvent(QEvent *event) { QWidget::changeEvent(event); if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange) { - applyPreviewPalette(); applyTerminalPalette(); + if (!m_current.name.isEmpty()) + styleSourceBadge(m_sourceBadge, m_current.isUserSource()); } } @@ -491,6 +498,7 @@ void ProviderDetailPane::showRevealedKey(const QString &key) void ProviderDetailPane::setEditing(bool on) { m_editing = on; + m_identitySection->setVisible(on); m_nameEdit->setReadOnly(!on); m_descriptionEdit->setReadOnly(!on); m_urlEdit->setReadOnly(!on); @@ -510,15 +518,6 @@ Providers::ProviderInstance ProviderDetailPane::collectEdits() const return out; } -void ProviderDetailPane::applyPreviewPalette() -{ - m_samplePreview->setStyleSheet( - QStringLiteral("QLabel { background:%1; border:1px solid %2; }") - .arg( - cssColor(Utils::creatorColor(Utils::Theme::BackgroundColorNormal)), - cssColor(Utils::creatorColor(Utils::Theme::SplitterColor)))); -} - void ProviderDetailPane::applyTerminalPalette() { if (!m_launchTerminal) diff --git a/settings/ProviderDetailPane.hpp b/settings/ProviderDetailPane.hpp index c27ae04..b6c9259 100644 --- a/settings/ProviderDetailPane.hpp +++ b/settings/ProviderDetailPane.hpp @@ -57,7 +57,6 @@ protected: private: void setEditing(bool on); Providers::ProviderInstance collectEdits() const; - void applyPreviewPalette(); void applyTerminalPalette(); bool m_editing = false; @@ -65,6 +64,7 @@ private: Providers::ProviderInstance m_current; QLabel *m_nameLabel = nullptr; + QLabel *m_sourceBadge = nullptr; QLabel *m_sourcePathLabel = nullptr; QPushButton *m_editBtn = nullptr; QPushButton *m_openInEditorBtn = nullptr; @@ -75,11 +75,11 @@ private: QLabel *m_descriptionLabel = nullptr; + SectionBox *m_identitySection = nullptr; QLineEdit *m_nameEdit = nullptr; QLabel *m_protocolLabel = nullptr; QPlainTextEdit *m_descriptionEdit = nullptr; QLineEdit *m_urlEdit = nullptr; - QLabel *m_samplePreview = nullptr; QLineEdit *m_apiKeyEdit = nullptr; QToolButton *m_revealKeyBtn = nullptr; diff --git a/settings/ProviderListItem.cpp b/settings/ProviderListItem.cpp index e07cd1d..82c2ce7 100644 --- a/settings/ProviderListItem.cpp +++ b/settings/ProviderListItem.cpp @@ -4,21 +4,22 @@ #include "ProviderListItem.hpp" +#include #include #include #include #include #include -#include +#include #include "ProviderInstance.hpp" #include "SettingsTheme.hpp" +#include "SettingsUiBuilders.hpp" namespace QodeAssist::Settings { -ProviderListItem::ProviderListItem( - const Providers::ProviderInstance &inst, QWidget *parent) +ProviderListItem::ProviderListItem(const Providers::ProviderInstance &inst, QWidget *parent) : QFrame(parent) , m_name(inst.name) { @@ -27,35 +28,45 @@ ProviderListItem::ProviderListItem( setAutoFillBackground(true); setCursor(Qt::PointingHandCursor); - auto *headerRow = new QHBoxLayout; - headerRow->setContentsMargins(0, 0, 0, 0); - headerRow->setSpacing(6); m_statusDot = new QLabel(QStringLiteral("●"), this); QFont df = m_statusDot->font(); df.setPixelSize(11); m_statusDot->setFont(df); m_statusDot->setStyleSheet(QStringLiteral("color: %1;").arg(statusColor(Status::Unknown))); - m_nameLabel = new QLabel(inst.name, this); + + m_nameLabel = new QLabel(this); QFont nf = m_nameLabel->font(); nf.setBold(true); nf.setPixelSize(12); m_nameLabel->setFont(nf); - headerRow->addWidget(m_statusDot, 0, Qt::AlignVCenter); - headerRow->addWidget(m_nameLabel, 1); + m_nameLabel->setTextFormat(Qt::RichText); - m_urlLabel = new QLabel(inst.url, this); + m_urlLabel = new Utils::ElidingLabel(this); + m_urlLabel->setElideMode(Qt::ElideMiddle); m_urlLabel->setFont(monospaceFont(10)); + m_urlLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); QPalette up = m_urlLabel->palette(); up.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); m_urlLabel->setPalette(up); - m_urlLabel->setContentsMargins(17, 0, 0, 0); + m_urlLabel->setText(inst.url); + m_urlLabel->setVisible(!inst.url.isEmpty()); - auto *outer = new QVBoxLayout(this); - outer->setContentsMargins(5, 6, 8, 6); - outer->setSpacing(2); - outer->addLayout(headerRow); - outer->addWidget(m_urlLabel); + auto *row = new QHBoxLayout(this); + row->setContentsMargins(8, 4, 8, 4); + row->setSpacing(6); + row->addWidget(m_statusDot, 0, Qt::AlignVCenter); + row->addWidget(m_nameLabel, 0); + row->addWidget(m_urlLabel, 1); + QStringList tipLines; + if (!inst.description.isEmpty()) + tipLines << inst.description; + tipLines << tr("API: %1").arg(inst.clientApi); + if (!inst.url.isEmpty()) + tipLines << tr("URL: %1").arg(inst.url); + setToolTip(tipLines.join(QStringLiteral("\n"))); + + updateNameText(); applyTheme(); } @@ -73,6 +84,19 @@ void ProviderListItem::setSelected(bool s) applyTheme(); } +void ProviderListItem::setFilterHighlight(const QString &lowerFilter) +{ + if (m_filter == lowerFilter) + return; + m_filter = lowerFilter; + updateNameText(); +} + +void ProviderListItem::updateNameText() +{ + m_nameLabel->setText(filterHighlightedHtml(m_name, m_filter)); +} + void ProviderListItem::mouseReleaseEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) @@ -111,6 +135,7 @@ void ProviderListItem::applyTheme() "#ProvListItem { background:transparent;" " border-top:1px solid %1; border-left:3px solid %2; }") .arg(cssColor(Utils::creatorColor(Utils::Theme::SplitterColor)), accent)); + updateNameText(); } } // namespace QodeAssist::Settings diff --git a/settings/ProviderListItem.hpp b/settings/ProviderListItem.hpp index d7df85b..693d896 100644 --- a/settings/ProviderListItem.hpp +++ b/settings/ProviderListItem.hpp @@ -9,6 +9,10 @@ class QLabel; class QMouseEvent; +namespace Utils { +class ElidingLabel; +} + namespace QodeAssist::Providers { struct ProviderInstance; } @@ -21,11 +25,11 @@ class ProviderListItem : public QFrame public: enum class Status : int { Unknown, Ok, Fail }; - explicit ProviderListItem( - const Providers::ProviderInstance &inst, QWidget *parent = nullptr); + explicit ProviderListItem(const Providers::ProviderInstance &inst, QWidget *parent = nullptr); void setStatus(Status s); void setSelected(bool s); + void setFilterHighlight(const QString &lowerFilter); QString providerName() const { return m_name; } signals: @@ -38,14 +42,16 @@ protected: private: static QString statusColor(Status s); void applyTheme(); + void updateNameText(); QString m_name; + QString m_filter; Status m_status = Status::Unknown; bool m_selected = false; bool m_inApplyTheme = false; QLabel *m_statusDot = nullptr; QLabel *m_nameLabel = nullptr; - QLabel *m_urlLabel = nullptr; + Utils::ElidingLabel *m_urlLabel = nullptr; }; } // namespace QodeAssist::Settings diff --git a/settings/ProvidersSettingsPage.cpp b/settings/ProvidersSettingsPage.cpp index 45c483d..2975f4e 100644 --- a/settings/ProvidersSettingsPage.cpp +++ b/settings/ProvidersSettingsPage.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -103,7 +104,10 @@ public: headerSep->setFrameShadow(QFrame::Sunken); m_filterEdit = new QLineEdit(this); - m_filterEdit->setPlaceholderText(tr("Filter providers…")); + m_filterEdit->setPlaceholderText(tr("Filter by name, API, URL…")); + m_filterEdit->setClearButtonEnabled(true); + m_filterEdit->setToolTip(tr("Type to filter; Up/Down moves through the list.")); + m_filterEdit->installEventFilter(this); m_listScroll = new QScrollArea(this); m_listScroll->setWidgetResizable(true); @@ -127,55 +131,88 @@ public: leftLay->addWidget(m_listScroll, 1); m_detailPane = new ProviderDetailPane(this); - connect(m_detailPane, &ProviderDetailPane::saveRequested, - this, &ProvidersPageWidget::onSaveEdited); - connect(m_detailPane, &ProviderDetailPane::duplicateRequested, - this, &ProvidersPageWidget::onDuplicateClicked); - connect(m_detailPane, &ProviderDetailPane::deleteRequested, - this, &ProvidersPageWidget::onRemoveClicked); - connect(m_detailPane, &ProviderDetailPane::apiKeySaveRequested, - this, &ProvidersPageWidget::onApiKeySave); - connect(m_detailPane, &ProviderDetailPane::apiKeyClearRequested, - this, &ProvidersPageWidget::onApiKeyClear); + connect( + m_detailPane, + &ProviderDetailPane::saveRequested, + this, + &ProvidersPageWidget::onSaveEdited); + connect( + m_detailPane, + &ProviderDetailPane::duplicateRequested, + this, + &ProvidersPageWidget::onDuplicateClicked); + connect( + m_detailPane, + &ProviderDetailPane::deleteRequested, + this, + &ProvidersPageWidget::onRemoveClicked); + connect( + m_detailPane, + &ProviderDetailPane::apiKeySaveRequested, + this, + &ProvidersPageWidget::onApiKeySave); + connect( + m_detailPane, + &ProviderDetailPane::apiKeyClearRequested, + this, + &ProvidersPageWidget::onApiKeyClear); connect( m_detailPane, &ProviderDetailPane::apiKeyRevealRequested, this, &ProvidersPageWidget::onApiKeyReveal); - connect(m_detailPane, &ProviderDetailPane::launchStartRequested, - this, &ProvidersPageWidget::onLaunchStart); - connect(m_detailPane, &ProviderDetailPane::launchStopRequested, - this, &ProvidersPageWidget::onLaunchStop); - connect(m_detailPane, &ProviderDetailPane::launchRestartRequested, - this, &ProvidersPageWidget::onLaunchRestart); - connect(m_detailPane, &ProviderDetailPane::openInEditorRequested, - this, [this](const QString &path) { - if (path.isEmpty() || path.startsWith(QLatin1String(":/"))) { - QMessageBox::information( - this, tr("Open in editor"), - tr("Bundled providers are read-only. " - "Use Duplicate to create an editable user copy first.")); - return; - } - Core::EditorManager::openEditor(Utils::FilePath::fromString(path)); - }); + connect( + m_detailPane, + &ProviderDetailPane::launchStartRequested, + this, + &ProvidersPageWidget::onLaunchStart); + connect( + m_detailPane, + &ProviderDetailPane::launchStopRequested, + this, + &ProvidersPageWidget::onLaunchStop); + connect( + m_detailPane, + &ProviderDetailPane::launchRestartRequested, + this, + &ProvidersPageWidget::onLaunchRestart); + connect( + m_detailPane, + &ProviderDetailPane::openInEditorRequested, + this, + [this](const QString &path) { + if (path.isEmpty() || path.startsWith(QLatin1String(":/"))) { + QMessageBox::information( + this, + tr("Open in editor"), + tr("Bundled providers are read-only. " + "Use Duplicate to create an editable user copy first.")); + return; + } + Core::EditorManager::openEditor(Utils::FilePath::fromString(path)); + }); if (m_launcher) { - connect(m_launcher.data(), &Providers::ProviderLauncher::stateChanged, - this, [this](const QString &name, - Providers::ProviderLauncher::State newState) { - if (name == m_currentName) - refreshDetailLaunch(); - const ProviderListItem::Status status = rowStatusFromState(newState); - for (auto *row : m_rows) { - if (row->providerName() == name) - row->setStatus(status); - } - }); - connect(m_launcher.data(), &Providers::ProviderLauncher::bytesReceived, - this, [this](const QString &name, const QByteArray &chunk) { - if (name == m_currentName) - m_detailPane->appendLaunchBytes(chunk); - }); + connect( + m_launcher.data(), + &Providers::ProviderLauncher::stateChanged, + this, + [this](const QString &name, Providers::ProviderLauncher::State newState) { + if (name == m_currentName) + refreshDetailLaunch(); + const ProviderListItem::Status status = rowStatusFromState(newState); + for (auto *row : m_rows) { + if (row->providerName() == name) + row->setStatus(status); + } + }); + connect( + m_launcher.data(), + &Providers::ProviderLauncher::bytesReceived, + this, + [this](const QString &name, const QByteArray &chunk) { + if (name == m_currentName) + m_detailPane->appendLaunchBytes(chunk); + }); } m_detailScroll = new QScrollArea(this); m_detailScroll->setWidgetResizable(true); @@ -199,26 +236,34 @@ public: m_filterDebounce = new QTimer(this); m_filterDebounce->setSingleShot(true); m_filterDebounce->setInterval(100); - connect(m_filterDebounce, &QTimer::timeout, this, - &ProvidersPageWidget::rebuildList); - connect(m_filterEdit, &QLineEdit::textChanged, this, - [this](const QString &) { m_filterDebounce->start(); }); + connect(m_filterDebounce, &QTimer::timeout, this, &ProvidersPageWidget::rebuildList); + connect(m_filterEdit, &QLineEdit::textChanged, this, [this](const QString &) { + m_filterDebounce->start(); + }); if (m_factory) { - connect(m_factory.data(), - &Providers::ProviderInstanceFactory::instancesReloaded, - this, &ProvidersPageWidget::rebuildList); + connect( + m_factory.data(), + &Providers::ProviderInstanceFactory::instancesReloaded, + this, + &ProvidersPageWidget::rebuildList); } if (m_navigator) { - connect(m_navigator.data(), - &ProvidersPageNavigator::selectInstanceRequested, - this, &ProvidersPageWidget::selectInstance); + connect( + m_navigator.data(), + &ProvidersPageNavigator::selectInstanceRequested, + this, + &ProvidersPageWidget::selectInstance); } rebuildList(); - const QString pending - = m_navigator ? m_navigator->takePendingSelection() : QString{}; + QTimer::singleShot(0, this, [this] { + m_filterEdit->setFocus(Qt::OtherFocusReason); + m_filterEdit->selectAll(); + }); + + const QString pending = m_navigator ? m_navigator->takePendingSelection() : QString{}; if (!pending.isEmpty()) selectInstance(pending); else if (m_factory && !m_factory->instances().empty()) @@ -227,6 +272,23 @@ public: void apply() final {} +protected: + bool eventFilter(QObject *watched, QEvent *event) override + { + if (watched == m_filterEdit && event->type() == QEvent::KeyPress) { + auto *ke = static_cast(event); + if (ke->key() == Qt::Key_Down) { + moveSelection(1); + return true; + } + if (ke->key() == Qt::Key_Up) { + moveSelection(-1); + return true; + } + } + return Core::IOptionsPageWidget::eventFilter(watched, event); + } + private slots: void rebuildList() { @@ -245,9 +307,9 @@ private slots: auto matches = [&](const Providers::ProviderInstance &inst) { if (filter.isEmpty()) return true; - return inst.name.toLower().contains(filter) - || inst.clientApi.toLower().contains(filter) - || inst.url.toLower().contains(filter); + return inst.name.toLower().contains(filter) || inst.clientApi.toLower().contains(filter) + || inst.url.toLower().contains(filter) + || inst.description.toLower().contains(filter); }; auto addSection = [&](const QString &title, bool userSection) { @@ -262,17 +324,18 @@ private slots: continue; sorted.push_back(&inst); } - std::sort(sorted.begin(), sorted.end(), - [](const Providers::ProviderInstance *a, - const Providers::ProviderInstance *b) { - return a->name.compare(b->name, Qt::CaseInsensitive) < 0; - }); + std::sort( + sorted.begin(), + sorted.end(), + [](const Providers::ProviderInstance *a, const Providers::ProviderInstance *b) { + return a->name.compare(b->name, Qt::CaseInsensitive) < 0; + }); int shown = 0; for (const auto *inst : sorted) { auto *row = new ProviderListItem(*inst, m_listContent); - connect(row, &ProviderListItem::clicked, - this, &ProvidersPageWidget::selectInstance); + row->setFilterHighlight(filter); + connect(row, &ProviderListItem::clicked, this, &ProvidersPageWidget::selectInstance); if (m_launcher) row->setStatus(rowStatusFromLauncher(inst->name)); m_rows.append(row); @@ -281,8 +344,7 @@ private slots: } if (shown == 0) { auto *empty = new QLabel( - userSection ? tr("No user instances yet.") - : tr("No bundled instances loaded."), + userSection ? tr("No user instances yet.") : tr("No bundled instances loaded."), m_listContent); empty->setContentsMargins(10, 6, 10, 6); QPalette ep = empty->palette(); @@ -322,21 +384,25 @@ private slots: { if (!m_factory || m_currentName.isEmpty()) return; - const Providers::ProviderInstance *srcPtr - = m_factory->instanceByName(m_currentName); + const Providers::ProviderInstance *srcPtr = m_factory->instanceByName(m_currentName); if (!srcPtr) return; const Providers::ProviderInstance srcCopy = *srcPtr; bool ok = false; const QString name = QInputDialog::getText( - this, tr("Duplicate provider"), - tr("Name for the new provider:"), QLineEdit::Normal, - QStringLiteral("%1 (copy)").arg(srcCopy.name), &ok); + this, + tr("Duplicate provider"), + tr("Name for the new provider:"), + QLineEdit::Normal, + QStringLiteral("%1 (copy)").arg(srcCopy.name), + &ok); if (!ok || name.trimmed().isEmpty()) return; if (m_factory->instanceByName(name.trimmed())) { - QMessageBox::warning(this, tr("Duplicate provider"), - tr("An instance named '%1' already exists.").arg(name.trimmed())); + QMessageBox::warning( + this, + tr("Duplicate provider"), + tr("An instance named '%1' already exists.").arg(name.trimmed())); return; } Providers::ProviderInstance copy = srcCopy; @@ -346,7 +412,8 @@ private slots: copy.overridesBundled = false; QString writeErr; if (Providers::ProviderInstanceWriter::writeToUserDir( - copy, /*previousPath=*/QString{}, &writeErr).isEmpty()) { + copy, /*previousPath=*/QString{}, &writeErr) + .isEmpty()) { QMessageBox::warning(this, tr("Duplicate provider"), writeErr); return; } @@ -370,8 +437,7 @@ private slots: { if (!m_factory || m_currentName.isEmpty()) return; - const Providers::ProviderInstance *instPtr - = m_factory->instanceByName(m_currentName); + const Providers::ProviderInstance *instPtr = m_factory->instanceByName(m_currentName); if (!instPtr || !instPtr->isUserSource()) return; @@ -391,8 +457,8 @@ private slots: if (QMessageBox::question(this, tr("Delete provider"), question) != QMessageBox::Yes) return; if (!QFile::remove(sourcePath)) { - QMessageBox::warning(this, tr("Delete provider"), - tr("Failed to delete file:\n%1").arg(sourcePath)); + QMessageBox::warning( + this, tr("Delete provider"), tr("Failed to delete file:\n%1").arg(sourcePath)); return; } if (m_secrets && !apiKeyRef.isEmpty()) @@ -418,8 +484,8 @@ private slots: if (e.apiKeyRef.isEmpty() || (nameChanged && e.apiKeyRef == priorRef)) e.apiKeyRef = QStringLiteral("qodeassist/providers/%1").arg(e.name); - const QString validation = Providers::ProviderInstance::validate( - e, m_factory->knownClientApis()); + const QString validation + = Providers::ProviderInstance::validate(e, m_factory->knownClientApis()); if (!validation.isEmpty()) { QMessageBox::warning(this, tr("Save"), validation); return; @@ -427,8 +493,8 @@ private slots: if (nameChanged) { const auto *clash = m_factory->instanceByName(e.name); if (clash) { - QMessageBox::warning(this, tr("Save"), - tr("An instance named '%1' already exists.").arg(e.name)); + QMessageBox::warning( + this, tr("Save"), tr("An instance named '%1' already exists.").arg(e.name)); return; } const QStringList referencing = agentsReferencing(priorName); @@ -449,20 +515,21 @@ private slots: } const QString softWarning = Providers::ProviderInstance::warnings(e); if (!softWarning.isEmpty()) { - if (QMessageBox::warning(this, tr("Save"), - softWarning + QStringLiteral("\n\n") - + tr("Save anyway?"), - QMessageBox::Yes | QMessageBox::No, - QMessageBox::No) + if (QMessageBox::warning( + this, + tr("Save"), + softWarning + QStringLiteral("\n\n") + tr("Save anyway?"), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No) != QMessageBox::Yes) return; } - const QString previousPath - = (prior && prior->isUserSource()) ? prior->sourcePath : QString{}; + const QString previousPath = (prior && prior->isUserSource()) ? prior->sourcePath + : QString{}; QString writeErr; - const QString writtenPath = Providers::ProviderInstanceWriter::writeToUserDir( - e, previousPath, &writeErr); + const QString writtenPath + = Providers::ProviderInstanceWriter::writeToUserDir(e, previousPath, &writeErr); if (writtenPath.isEmpty()) { QMessageBox::warning(this, tr("Save"), writeErr); return; @@ -472,7 +539,8 @@ private slots: != QFileInfo(previousPath).absoluteFilePath()) { if (!QFile::remove(previousPath)) { QMessageBox::warning( - this, tr("Save"), + this, + tr("Save"), tr("Saved to:\n%1\n\nbut could not remove the old file:\n%2\n\n" "Two provider files now describe this instance — delete the " "old file manually to avoid a duplicate-name error.") @@ -515,15 +583,13 @@ private slots: { if (!m_factory || !m_secrets || m_currentName.isEmpty()) return; - const Providers::ProviderInstance *instPtr - = m_factory->instanceByName(m_currentName); + const Providers::ProviderInstance *instPtr = m_factory->instanceByName(m_currentName); if (!instPtr || instPtr->apiKeyRef.isEmpty()) return; const QString instName = instPtr->name; const QString apiKeyRef = instPtr->apiKeyRef; if (QMessageBox::question( - this, tr("Clear API key"), - tr("Erase the stored API key for '%1'?").arg(instName)) + this, tr("Clear API key"), tr("Erase the stored API key for '%1'?").arg(instName)) != QMessageBox::Yes) return; m_secrets->eraseKey(apiKeyRef); @@ -558,6 +624,25 @@ private slots: } private: + void moveSelection(int delta) + { + if (m_rows.isEmpty()) + return; + int cur = -1; + for (int i = 0; i < m_rows.size(); ++i) { + if (m_rows.at(i)->providerName() == m_currentName) { + cur = i; + break; + } + } + int next = cur < 0 ? (delta > 0 ? 0 : int(m_rows.size()) - 1) : cur + delta; + next = qBound(0, next, int(m_rows.size()) - 1); + if (next == cur) + return; + selectInstance(m_rows.at(next)->providerName()); + m_listScroll->ensureWidgetVisible(m_rows.at(next), 0, 40); + } + void populateDetail(const QString &name) { if (!m_factory) @@ -567,14 +652,13 @@ private: m_detailPane->clear(); return; } - const bool hasStoredKey - = m_secrets && !inst->apiKeyRef.isEmpty() && m_secrets->hasKey(inst->apiKeyRef); + const bool hasStoredKey = m_secrets && !inst->apiKeyRef.isEmpty() + && m_secrets->hasKey(inst->apiKeyRef); m_detailPane->populate(*inst, hasStoredKey); if (m_launcher) { - m_detailPane->setLaunchState( - m_launcher->state(inst->name), - m_launcher->lastError(inst->name)); + m_detailPane + ->setLaunchState(m_launcher->state(inst->name), m_launcher->lastError(inst->name)); m_detailPane->resetLaunchTerminal(m_launcher->scrollback(inst->name)); } else { m_detailPane->setLaunchState(Providers::ProviderLauncher::Idle, {}); @@ -607,13 +691,11 @@ private: { if (!m_launcher || m_currentName.isEmpty()) return; - m_detailPane->setLaunchState( - m_launcher->state(m_currentName), - m_launcher->lastError(m_currentName)); + m_detailPane + ->setLaunchState(m_launcher->state(m_currentName), m_launcher->lastError(m_currentName)); } - static ProviderListItem::Status rowStatusFromState( - Providers::ProviderLauncher::State state) + static ProviderListItem::Status rowStatusFromState(Providers::ProviderLauncher::State state) { switch (state) { case Providers::ProviderLauncher::Ready: diff --git a/settings/SettingsUiBuilders.cpp b/settings/SettingsUiBuilders.cpp index fa6225f..ca20ce7 100644 --- a/settings/SettingsUiBuilders.cpp +++ b/settings/SettingsUiBuilders.cpp @@ -28,6 +28,49 @@ void applyMutedSmallCaps(QLabel *label) label->setPalette(p); } +QString filterHighlightedHtml(const QString &text, const QString &lowerFilter) +{ + if (lowerFilter.isEmpty()) + return text.toHtmlEscaped(); + QColor mark = Utils::creatorColor(Utils::Theme::TextColorLink); + mark.setAlphaF(0.30); + const QString markCss + = QStringLiteral("background-color:%1;border-radius:2px;").arg(cssColor(mark)); + const QString lowerText = text.toLower(); + QString out; + int pos = 0; + while (true) { + const int hit = lowerText.indexOf(lowerFilter, pos); + if (hit < 0) { + out += text.mid(pos).toHtmlEscaped(); + break; + } + out += text.mid(pos, hit - pos).toHtmlEscaped(); + out += QStringLiteral("%2") + .arg(markCss, text.mid(hit, lowerFilter.size()).toHtmlEscaped()); + pos = hit + int(lowerFilter.size()); + } + return out; +} + +void styleSourceBadge(QLabel *label, bool user) +{ + QFont f = label->font(); + f.setBold(true); + f.setPixelSize(10); + label->setFont(f); + const QColor accent = Utils::creatorColor( + user ? Utils::Theme::TextColorLink : Utils::Theme::PanelTextColorMid); + QColor bg = accent; + bg.setAlphaF(0.12); + QColor border = accent; + border.setAlphaF(0.45); + label->setStyleSheet(QStringLiteral( + "QLabel { color:%1; background:%2; border:1px solid %3;" + " border-radius:8px; padding:1px 8px; }") + .arg(cssColor(accent), cssColor(bg), cssColor(border))); +} + QLabel *makeSectionHeader(const QString &title, QWidget *parent) { auto *header = new QLabel(title.toUpper(), parent); diff --git a/settings/SettingsUiBuilders.hpp b/settings/SettingsUiBuilders.hpp index 286b4bf..84a9920 100644 --- a/settings/SettingsUiBuilders.hpp +++ b/settings/SettingsUiBuilders.hpp @@ -16,6 +16,10 @@ namespace QodeAssist::Settings { void applyMutedSmallCaps(QLabel *label); +void styleSourceBadge(QLabel *label, bool user); + +QString filterHighlightedHtml(const QString &text, const QString &lowerFilter); + QLabel *makeSectionHeader(const QString &title, QWidget *parent); QLabel *makeHintLabel(const QString &text, QWidget *parent = nullptr); diff --git a/settings/TagFilterStrip.cpp b/settings/TagFilterStrip.cpp index 070febf..16f67d6 100644 --- a/settings/TagFilterStrip.cpp +++ b/settings/TagFilterStrip.cpp @@ -58,6 +58,15 @@ void TagFilterStrip::setAvailableTags( emit activeTagsChanged(m_activeTags); } +void TagFilterStrip::setMaxColumns(int columns) +{ + const int clamped = qMax(1, columns); + if (clamped == m_maxColumns) + return; + m_maxColumns = clamped; + rebuild(); +} + void TagFilterStrip::setVisibleCounts(const QMap &countsByTag) { for (auto it = m_chipByTag.cbegin(); it != m_chipByTag.cend(); ++it) @@ -161,14 +170,12 @@ void TagFilterStrip::rebuild() 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; - }); + 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; + }); - constexpr int kMaxColumns = 3; auto *gridHost = new QWidget(this); auto *grid = new QGridLayout(gridHost); grid->setContentsMargins(0, 0, 0, 0); @@ -181,7 +188,7 @@ void TagFilterStrip::rebuild() connect(chip, &TagChip::clicked, this, &TagFilterStrip::toggleTag); grid->addWidget(chip, gridRow, col, Qt::AlignLeft); m_chipByTag.insert(tag, chip); - if (++col >= kMaxColumns) { + if (++col >= m_maxColumns) { col = 0; ++gridRow; } diff --git a/settings/TagFilterStrip.hpp b/settings/TagFilterStrip.hpp index 120d8ea..d9cbe38 100644 --- a/settings/TagFilterStrip.hpp +++ b/settings/TagFilterStrip.hpp @@ -26,6 +26,7 @@ public: void setAvailableTags(const QMap &countsByTag); void setAvailableTags(const QMap &countsByTag, const QSet &activeTags); void setVisibleCounts(const QMap &countsByTag); + void setMaxColumns(int columns); const QSet &activeTags() const { return m_activeTags; } void toggleTag(const QString &tag); @@ -42,6 +43,7 @@ private: QMap m_counts; QSet m_activeTags; + int m_maxColumns = 3; QVBoxLayout *m_layout = nullptr; QHash m_chipByTag; QLabel *m_clearLink = nullptr; diff --git a/sources/settings/AgentSelectionDialog.cpp b/sources/settings/AgentSelectionDialog.cpp index df31452..705fd6d 100644 --- a/sources/settings/AgentSelectionDialog.cpp +++ b/sources/settings/AgentSelectionDialog.cpp @@ -7,6 +7,7 @@ #include "Pill.hpp" #include "PipelinesConfig.hpp" #include "SettingsTr.hpp" +#include "SettingsUiBuilders.hpp" #include "TagFilterStrip.hpp" #include @@ -19,9 +20,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -64,6 +67,11 @@ bool ListRowCard::hasAllTags(const QSet &activeTags) const return true; } +void ListRowCard::setFilterHighlight(const QString &lowerNeedle) +{ + Q_UNUSED(lowerNeedle) +} + void ListRowCard::buildSearchHaystack(const QStringList &parts) { m_searchHaystack = parts.join(QLatin1Char(' ')).toLower(); @@ -121,9 +129,8 @@ void ListRowCard::applyTheme() } else if (m_hover) { bg = t.hoverBg; } - setStyleSheet(QStringLiteral( - "#ListRowCard { background-color: %1; border: 1px solid %2; }") - .arg(bg, bd)); + setStyleSheet( + QStringLiteral("#ListRowCard { background-color: %1; border: 1px solid %2; }").arg(bg, bd)); } AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent) @@ -136,19 +143,25 @@ AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent) haystack += cfg.tags; buildSearchHaystack(haystack); - auto *name = new QLabel(cfg.name, this); + auto *name = new QLabel(cfg.name.toHtmlEscaped(), this); QFont nameFont = name->font(); nameFont.setBold(true); nameFont.setPixelSize(13); name->setFont(nameFont); name->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Preferred); + name->setTextFormat(Qt::RichText); + m_nameLabel = name; QLabel *model = nullptr; if (!cfg.model.isEmpty()) { - model = new QLabel(QStringLiteral("· %1").arg(cfg.model), this); + model = new QLabel(cfg.model.toHtmlEscaped(), this); model->setFont(CardStyle::monoFont(11)); model->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); model->setMinimumWidth(0); + model->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + model->setTextFormat(Qt::RichText); + m_modelLabel = model; + m_model = cfg.model; } Pill *sourcePill = nullptr; @@ -156,47 +169,21 @@ AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent) sourcePill = new Pill(Pill::User, Tr::tr("User"), this); } - auto *description = new QLabel(this); - description->setWordWrap(false); - QFont descFont = description->font(); - descFont.setItalic(true); - description->setFont(descFont); - description->setText(cfg.description.isEmpty() - ? Tr::tr("No description provided.") - : cfg.description); - description->setMinimumWidth(0); - description->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - description->setTextInteractionFlags(Qt::NoTextInteraction); - - QStringList endpointParts; - if (!cfg.endpoint.isEmpty()) - endpointParts << cfg.endpoint; - endpointParts << (cfg.enableThinking ? Tr::tr("thinking") : Tr::tr("no-thinking")); - endpointParts << (cfg.enableTools ? Tr::tr("tools") : Tr::tr("no-tools")); - - auto *endpoint = new QLabel(endpointParts.join(QStringLiteral(" · ")), this); - endpoint->setFont(CardStyle::monoFont(11)); - endpoint->setMinimumWidth(0); - endpoint->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - endpoint->setTextInteractionFlags(Qt::NoTextInteraction); - auto *headerRow = new QHBoxLayout; headerRow->setContentsMargins(0, 0, 0, 0); headerRow->setSpacing(6); headerRow->addWidget(name); + if (sourcePill) + headerRow->addWidget(sourcePill); if (model) headerRow->addWidget(model, 1); else headerRow->addStretch(1); - if (sourcePill) - headerRow->addWidget(sourcePill); auto *outer = new QVBoxLayout(this); - outer->setContentsMargins(10, 8, 10, 8); - outer->setSpacing(3); + outer->setContentsMargins(8, 5, 8, 5); + outer->setSpacing(2); outer->addLayout(headerRow); - outer->addWidget(description); - outer->addWidget(endpoint); if (!cfg.tags.isEmpty()) { constexpr int kMaxTagPills = 4; @@ -207,10 +194,8 @@ AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent) for (int i = 0; i < std::min(tagCount, kMaxTagPills); ++i) tagsRow->addWidget(new Pill(Pill::Tag, cfg.tags.at(i), this)); if (tagCount > kMaxTagPills) { - auto *more = new Pill( - Pill::Tag, - QStringLiteral("+%1").arg(tagCount - kMaxTagPills), - this); + auto *more + = new Pill(Pill::Tag, QStringLiteral("+%1").arg(tagCount - kMaxTagPills), this); more->setToolTip(cfg.tags.mid(kMaxTagPills).join(QStringLiteral(", "))); tagsRow->addWidget(more); } @@ -218,32 +203,45 @@ AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent) outer->addLayout(tagsRow); } - const auto t = CardStyle::toneFor(CardStyle::isDark(palette())); - QPalette descPal = description->palette(); - descPal.setColor(QPalette::WindowText, - QColor(cfg.description.isEmpty() ? t.textFaint : t.textSoft)); - description->setPalette(descPal); - QPalette endPal = endpoint->palette(); - endPal.setColor(QPalette::WindowText, QColor(t.textFaint)); - endpoint->setPalette(endPal); if (model) { + const auto t = CardStyle::toneFor(CardStyle::isDark(palette())); QPalette mp = model->palette(); mp.setColor(QPalette::WindowText, QColor(t.textMute)); model->setPalette(mp); } + QStringList capabilityParts; + if (!cfg.endpoint.isEmpty()) + capabilityParts << cfg.endpoint; + capabilityParts << (cfg.enableThinking ? Tr::tr("thinking") : Tr::tr("no-thinking")); + capabilityParts << (cfg.enableTools ? Tr::tr("tools") : Tr::tr("no-tools")); + QString tooltip; if (!cfg.description.isEmpty()) tooltip += cfg.description + QStringLiteral("\n\n"); + if (!cfg.model.isEmpty()) + tooltip += Tr::tr("Model: %1\n").arg(cfg.model); if (!cfg.providerInstance.isEmpty()) tooltip += Tr::tr("Provider instance: %1\n").arg(cfg.providerInstance); 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); + tooltip += capabilityParts.join(QStringLiteral(" · ")); + if (!cfg.tags.isEmpty()) + tooltip += QStringLiteral("\n") + + Tr::tr("Tags: %1").arg(cfg.tags.join(QStringLiteral(", "))); setToolTip(tooltip.trimmed()); } +void AgentRowCard::setFilterHighlight(const QString &lowerNeedle) +{ + if (m_lowerNeedle == lowerNeedle) + return; + m_lowerNeedle = lowerNeedle; + m_nameLabel->setText(filterHighlightedHtml(itemName(), lowerNeedle)); + if (m_modelLabel) + m_modelLabel->setText(filterHighlightedHtml(m_model, lowerNeedle)); +} + ProviderSection::ProviderSection(const QString &name, QWidget *parent) : QWidget(parent) { @@ -297,10 +295,12 @@ void ProviderSection::addCard(ListRowCard *card) int ProviderSection::applyFilter(const QString &needle, const QSet &activeTags) { + const QString lowerNeedle = needle.toLower(); int visible = 0; for (auto *card : m_cards) { const bool show = card->matches(needle) && card->hasAllTags(activeTags); card->setVisible(show); + card->setFilterHighlight(lowerNeedle); if (show) ++visible; } @@ -350,9 +350,10 @@ AgentSelectionDialog::AgentSelectionDialog( m_localConfigs = configs; m_filter = new QLineEdit(this); - m_filter->setPlaceholderText( - Tr::tr("Filter by name, provider, model, template, description…")); + m_filter->setPlaceholderText(Tr::tr("Filter by name, provider, model, template, description…")); m_filter->setClearButtonEnabled(true); + m_filter->setToolTip(Tr::tr("Type to filter; Up/Down moves through the list.")); + m_filter->installEventFilter(this); auto *topRow = new QHBoxLayout; topRow->setContentsMargins(0, 0, 0, 0); @@ -360,6 +361,7 @@ AgentSelectionDialog::AgentSelectionDialog( topRow->addWidget(m_filter, 1); m_tagStrip = new TagFilterStrip(this); + m_tagStrip->setMaxColumns(6); const bool dark = CardStyle::isDark(palette()); const auto tone = CardStyle::toneFor(dark); @@ -389,8 +391,7 @@ AgentSelectionDialog::AgentSelectionDialog( m_scroll->setFrameShape(QFrame::StyledPanel); m_scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - auto *buttons - = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); m_okButton = buttons->button(QDialogButtonBox::Ok); m_okButton->setText(isChange ? Tr::tr("Change") : Tr::tr("Add")); m_okButton->setEnabled(false); @@ -442,6 +443,59 @@ void AgentSelectionDialog::setAllExpanded(bool expanded) section->setExpanded(expanded); } +bool AgentSelectionDialog::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == m_filter && event->type() == QEvent::KeyPress) { + auto *ke = static_cast(event); + if (ke->key() == Qt::Key_Down) { + moveSelection(1); + return true; + } + if (ke->key() == Qt::Key_Up) { + moveSelection(-1); + return true; + } + } + return QDialog::eventFilter(watched, event); +} + +QList AgentSelectionDialog::filteredCards() const +{ + const QString needle = m_filter ? m_filter->text().trimmed() : QString(); + const QSet activeTags = m_tagStrip ? m_tagStrip->activeTags() : QSet(); + QList out; + for (auto *section : m_sections) + for (auto *card : section->cards()) + if (card->matches(needle) && card->hasAllTags(activeTags)) + out.append(card); + return out; +} + +void AgentSelectionDialog::moveSelection(int delta) +{ + const QList cards = filteredCards(); + if (cards.isEmpty()) + return; + const int cur = m_currentCard ? int(cards.indexOf(m_currentCard)) : -1; + int next = cur < 0 ? (delta > 0 ? 0 : int(cards.size()) - 1) : cur + delta; + next = qBound(0, next, int(cards.size()) - 1); + if (next == cur) + return; + ListRowCard *card = cards.at(next); + for (auto *section : m_sections) { + if (section->cards().contains(card)) { + section->setExpanded(true); + break; + } + } + selectCard(card); + QPointer guarded(card); + QTimer::singleShot(0, this, [this, guarded] { + if (guarded) + m_scroll->ensureWidgetVisible(guarded, 0, 40); + }); +} + void AgentSelectionDialog::selectCard(ListRowCard *card) { if (m_currentCard == card) @@ -467,8 +521,7 @@ void AgentSelectionDialog::rebuild(const QString ¤tName) if (m_okButton) m_okButton->setEnabled(false); - const auto &configs - = m_agentFactory ? m_agentFactory->configs() : m_localConfigs; + const auto &configs = m_agentFactory ? m_agentFactory->configs() : m_localConfigs; auto *content = new QWidget; auto *contentLayout = new QVBoxLayout(content); @@ -479,9 +532,8 @@ void AgentSelectionDialog::rebuild(const QString ¤tName) for (const auto &cfg : configs) { if (cfg.hidden) continue; - const QString key = cfg.providerInstance.isEmpty() - ? Tr::tr("(Unknown provider instance)") - : cfg.providerInstance; + const QString key = cfg.providerInstance.isEmpty() ? Tr::tr("(Unknown provider instance)") + : cfg.providerInstance; byProvider[key].push_back(&cfg); } @@ -491,12 +543,13 @@ void AgentSelectionDialog::rebuild(const QString ¤tName) for (auto it = byProvider.cbegin(); it != byProvider.cend(); ++it) { auto *section = new ProviderSection(it.key()); auto sortedConfigs = it.value(); - std::sort(sortedConfigs.begin(), sortedConfigs.end(), - [](const AgentConfig *a, const AgentConfig *b) { return a->name < b->name; }); + std::sort( + sortedConfigs.begin(), + sortedConfigs.end(), + [](const AgentConfig *a, const AgentConfig *b) { return a->name < b->name; }); for (const AgentConfig *cfg : sortedConfigs) { auto *card = new AgentRowCard(*cfg); - connect(card, &ListRowCard::clicked, this, - [this, card]() { selectCard(card); }); + connect(card, &ListRowCard::clicked, this, [this, card]() { selectCard(card); }); connect(card, &ListRowCard::activated, this, [this, card]() { selectCard(card); accept(); @@ -554,6 +607,8 @@ void AgentSelectionDialog::applyFilters() section->setExpanded(visible > 0); total += visible; } + if (m_currentCard && (!m_currentCard->matches(needle) || !m_currentCard->hasAllTags(activeTags))) + selectCard(nullptr); if (m_emptyLabel) m_emptyLabel->setVisible(total == 0); if (m_resultCount) diff --git a/sources/settings/AgentSelectionDialog.hpp b/sources/settings/AgentSelectionDialog.hpp index 7ad7d12..84c1a33 100644 --- a/sources/settings/AgentSelectionDialog.hpp +++ b/sources/settings/AgentSelectionDialog.hpp @@ -50,21 +50,28 @@ inline bool isDark(const QPalette &p) inline Tone toneFor(bool dark) { return dark - ? Tone{"#333333", "#3a3a3a", "#2a4566", "#3a6fb7", "#4a4a4a", - "#c2c2c2", "#9a9a9a", "#7a7a7a"} - : Tone{"#f6f6f6", "#ececec", "#cfe1f7", "#3a6fb7", "#bdbdbd", - "#3a3a3a", "#5a5a5a", "#8a8a8a"}; + ? Tone{"#333333", "#3a3a3a", "#2a4566", "#3a6fb7", "#4a4a4a", "#c2c2c2", "#9a9a9a", "#7a7a7a"} + : Tone{ + "#f6f6f6", + "#ececec", + "#cfe1f7", + "#3a6fb7", + "#bdbdbd", + "#3a3a3a", + "#5a5a5a", + "#8a8a8a"}; } inline QFont monoFont(int pixelSize) { QFont f; - f.setFamilies({QStringLiteral("SF Mono"), - QStringLiteral("Cascadia Code"), - QStringLiteral("Consolas"), - QStringLiteral("Liberation Mono"), - QStringLiteral("Menlo"), - QStringLiteral("Courier New")}); + f.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; @@ -94,6 +101,7 @@ public: bool matches(const QString &needle) const; bool hasAllTags(const QSet &activeTags) const; void setSelected(bool selected); + virtual void setFilterHighlight(const QString &lowerNeedle); signals: void clicked(); @@ -130,6 +138,13 @@ public: explicit AgentRowCard(const AgentConfig &cfg, QWidget *parent = nullptr); QString agentName() const { return itemName(); } + void setFilterHighlight(const QString &lowerNeedle) override; + +private: + QLabel *m_nameLabel = nullptr; + QLabel *m_modelLabel = nullptr; + QString m_model; + QString m_lowerNeedle; }; class ProviderSection : public QWidget @@ -171,11 +186,16 @@ public: QString selectedName() const { return m_selectedName; } +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + private: void rebuild(const QString ¤tName); void selectCard(ListRowCard *card); void applyFilters(); void setAllExpanded(bool expanded); + void moveSelection(int delta); + QList filteredCards() const; QLineEdit *m_filter = nullptr; TagFilterStrip *m_tagStrip = nullptr;