Skip to content

Commit f99b776

Browse files
feat(acp): add agent brand icons and detection
Identify agents by mark so the picker and settings list are scannable without reading every name or command. - Detect brand from id/name/command/args when icon is unset - Tint monochrome SVGs from the palette; cache per path/tint - Support reorder and clone so users can manage custom agents
1 parent 5157b05 commit f99b776

20 files changed

Lines changed: 1484 additions & 158 deletions

‎src/AcpAgentBrand.h‎

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*
2+
* This file is part of Notepad Next.
3+
* Copyright 2026 NotepadAI contributors
4+
*
5+
* Notepad Next is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* Notepad Next is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with Notepad Next. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
19+
#ifndef ACP_AGENT_BRAND_H
20+
#define ACP_AGENT_BRAND_H
21+
22+
#include <QLatin1String>
23+
#include <QString>
24+
#include <QStringView>
25+
26+
#include <array>
27+
#include <utility>
28+
29+
#include "AcpAgentDefinition.h"
30+
31+
// Bundled agent marks. `AcpAgentDefinition::icon` holds one of these keys, a
32+
// path to an image file, or nothing (detect the brand from the definition).
33+
// Pure Qt Core so the detection is unit-testable without a GUI.
34+
struct AcpAgentBrandInfo
35+
{
36+
const char *key;
37+
const char *displayName;
38+
};
39+
40+
// Order is the picker order and the detection priority: "oh-my-pi" must be
41+
// tested before "pi", and "claude"/"codex" win over everything else.
42+
inline constexpr std::array<AcpAgentBrandInfo, 5> kAcpAgentBrands{{
43+
{"claude-code", "Claude Code"},
44+
{"codex", "Codex"},
45+
{"opencode", "OpenCode"},
46+
{"oh-my-pi", "Oh My Pi"},
47+
{"pi", "Pi"},
48+
}};
49+
50+
inline const std::array<AcpAgentBrandInfo, 5> &acpAgentBrands()
51+
{
52+
return kAcpAgentBrands;
53+
}
54+
55+
inline bool isAcpAgentBrandKey(const QString &key)
56+
{
57+
for (const AcpAgentBrandInfo &info : kAcpAgentBrands) {
58+
if (key == QLatin1String(info.key)) {
59+
return true;
60+
}
61+
}
62+
return false;
63+
}
64+
65+
namespace acpagentbrand_detail {
66+
67+
// True when `token` appears in `hay` as a whole alphanumeric run, so "pi"
68+
// matches "pi-acp" and "C:\tools\pi.exe" but not "pipx" or "spinner".
69+
inline bool hasToken(QStringView hay, QLatin1String token)
70+
{
71+
const qsizetype n = hay.size();
72+
qsizetype i = 0;
73+
while (i < n) {
74+
while (i < n && !hay.at(i).isLetterOrNumber()) {
75+
++i;
76+
}
77+
const qsizetype start = i;
78+
while (i < n && hay.at(i).isLetterOrNumber()) {
79+
++i;
80+
}
81+
if (i - start == token.size() && hay.mid(start, i - start) == token) {
82+
return true;
83+
}
84+
}
85+
return false;
86+
}
87+
88+
// Substring search that ignores '-', '_' and whitespace in `hay`, so
89+
// "open code", "open-code" and "opencode" all match "opencode".
90+
inline bool containsSquashed(QStringView hay, QLatin1String needle)
91+
{
92+
const qsizetype n = hay.size();
93+
const qsizetype m = needle.size();
94+
for (qsizetype start = 0; start < n; ++start) {
95+
qsizetype j = 0;
96+
for (qsizetype i = start; i < n && j < m; ++i) {
97+
const QChar c = hay.at(i);
98+
if (c == QLatin1Char('-') || c == QLatin1Char('_') || c.isSpace()) {
99+
if (j == 0) {
100+
break;
101+
}
102+
continue;
103+
}
104+
if (c != needle.at(j)) {
105+
break;
106+
}
107+
++j;
108+
}
109+
if (j == m) {
110+
return true;
111+
}
112+
}
113+
return false;
114+
}
115+
116+
} // namespace acpagentbrand_detail
117+
118+
// Brand guessed from id, name, command and args. Empty when nothing matches.
119+
inline QString detectAcpAgentBrand(const AcpAgentDefinition &def)
120+
{
121+
using namespace acpagentbrand_detail;
122+
123+
qsizetype len = def.id.size() + def.name.size() + def.command.size() + 3;
124+
for (const QString &arg : def.args) {
125+
len += arg.size() + 1;
126+
}
127+
QString hay;
128+
hay.reserve(len);
129+
hay += def.id;
130+
hay += QLatin1Char(' ');
131+
hay += def.name;
132+
hay += QLatin1Char(' ');
133+
hay += def.command;
134+
for (const QString &arg : def.args) {
135+
hay += QLatin1Char(' ');
136+
hay += arg;
137+
}
138+
hay = std::move(hay).toLower();
139+
140+
if (hay.contains(QLatin1String("claude"))) {
141+
return QStringLiteral("claude-code");
142+
}
143+
if (hay.contains(QLatin1String("codex"))) {
144+
return QStringLiteral("codex");
145+
}
146+
if (containsSquashed(hay, QLatin1String("opencode"))) {
147+
return QStringLiteral("opencode");
148+
}
149+
if (containsSquashed(hay, QLatin1String("ohmypi")) || hasToken(hay, QLatin1String("omp"))) {
150+
return QStringLiteral("oh-my-pi");
151+
}
152+
if (hasToken(hay, QLatin1String("pi"))) {
153+
return QStringLiteral("pi");
154+
}
155+
return QString();
156+
}
157+
158+
// Effective bundled mark: an explicit brand key wins, an empty icon falls back
159+
// to detection, and a custom image path has no brand.
160+
inline QString acpAgentBrandKey(const AcpAgentDefinition &def)
161+
{
162+
const QString icon = def.icon.trimmed();
163+
if (icon.isEmpty()) {
164+
return detectAcpAgentBrand(def);
165+
}
166+
return isAcpAgentBrandKey(icon) ? icon : QString();
167+
}
168+
169+
#endif // ACP_AGENT_BRAND_H

‎src/AcpAgentIcons.cpp‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
* This file is part of Notepad Next.
3+
* Copyright 2026 NotepadAI contributors
4+
*
5+
* Notepad Next is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* Notepad Next is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with Notepad Next. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
19+
#include "AcpAgentIcons.h"
20+
21+
#include <QColor>
22+
#include <QFileInfo>
23+
#include <QHash>
24+
#include <QPainter>
25+
#include <QPalette>
26+
#include <QPixmap>
27+
28+
#include "AcpAgentBrand.h"
29+
#include "AcpAgentDefinition.h"
30+
31+
32+
namespace {
33+
34+
// Renders the SVG once per size and composites `color` over it with SourceIn.
35+
// Qt's icon engine resolves currentColor to opaque black and never follows
36+
// theme switches, so monochrome marks are re-tinted per palette instead.
37+
QIcon tintedIcon(const QString &svgPath, const QColor &color)
38+
{
39+
const QIcon source(svgPath);
40+
QIcon dst;
41+
for (const int sz : {16, 20, 24, 32, 40, 48, 64}) {
42+
QPixmap pm = source.pixmap(sz, sz);
43+
if (pm.isNull()) {
44+
continue;
45+
}
46+
QPainter p(&pm);
47+
p.setCompositionMode(QPainter::CompositionMode_SourceIn);
48+
p.fillRect(pm.rect(), color);
49+
p.end();
50+
dst.addPixmap(pm);
51+
}
52+
return dst;
53+
}
54+
55+
// Process-wide cache keyed by path (+ tint). A handful of brands times the
56+
// themes the user switches through, plus custom images, so it stays tiny.
57+
QHash<QString, QIcon> &iconCache()
58+
{
59+
static QHash<QString, QIcon> cache;
60+
return cache;
61+
}
62+
63+
QIcon cachedIcon(const QString &path, const QColor &tint = QColor())
64+
{
65+
QString key = path;
66+
if (tint.isValid()) {
67+
key += QLatin1Char('|');
68+
key += QString::number(tint.rgba(), 16);
69+
}
70+
QHash<QString, QIcon> &cache = iconCache();
71+
const auto it = cache.constFind(key);
72+
if (it != cache.constEnd()) {
73+
return it.value();
74+
}
75+
const QIcon icon = tint.isValid() ? tintedIcon(path, tint) : QIcon(path);
76+
cache.insert(key, icon);
77+
return icon;
78+
}
79+
80+
// Null when the file is missing so the caller can fall back to the brand.
81+
QIcon customFileIcon(const QString &path)
82+
{
83+
QHash<QString, QIcon> &cache = iconCache();
84+
const QString key = QStringLiteral("file|") + path;
85+
const auto it = cache.constFind(key);
86+
if (it != cache.constEnd()) {
87+
return it.value();
88+
}
89+
const QIcon icon = QFileInfo(path).isFile() ? QIcon(path) : QIcon();
90+
cache.insert(key, icon);
91+
return icon;
92+
}
93+
94+
bool isDarkPalette(const QPalette &palette)
95+
{
96+
return palette.color(QPalette::Window).lightness() < 128;
97+
}
98+
99+
} // namespace
100+
101+
QIcon acpAgentBrandIcon(const QString &brandKey, const QPalette &palette)
102+
{
103+
const QColor fg = palette.color(QPalette::ButtonText);
104+
if (brandKey == QLatin1String("claude-code")) {
105+
return cachedIcon(QStringLiteral(":/icons/claude.svg"));
106+
}
107+
if (brandKey == QLatin1String("codex")) {
108+
return cachedIcon(QStringLiteral(":/icons/agents/codex.svg"), fg);
109+
}
110+
if (brandKey == QLatin1String("opencode")) {
111+
return cachedIcon(isDarkPalette(palette)
112+
? QStringLiteral(":/icons/agents/opencode-dark.svg")
113+
: QStringLiteral(":/icons/agents/opencode-light.svg"));
114+
}
115+
if (brandKey == QLatin1String("oh-my-pi")) {
116+
return cachedIcon(QStringLiteral(":/icons/agents/oh-my-pi.svg"));
117+
}
118+
if (brandKey == QLatin1String("pi")) {
119+
return cachedIcon(QStringLiteral(":/icons/agents/pi.svg"), fg);
120+
}
121+
return cachedIcon(QStringLiteral(":/icons/agents/agent.svg"), fg);
122+
}
123+
124+
QIcon acpAgentIcon(const AcpAgentDefinition &def, const QPalette &palette)
125+
{
126+
const QString icon = def.icon.trimmed();
127+
if (!icon.isEmpty() && !isAcpAgentBrandKey(icon)) {
128+
const QIcon custom = customFileIcon(icon);
129+
if (!custom.isNull()) {
130+
return custom;
131+
}
132+
// Missing file: still show the agent's own mark rather than a blank.
133+
return acpAgentBrandIcon(detectAcpAgentBrand(def), palette);
134+
}
135+
return acpAgentBrandIcon(acpAgentBrandKey(def), palette);
136+
}

‎src/AcpAgentIcons.h‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* This file is part of Notepad Next.
3+
* Copyright 2026 NotepadAI contributors
4+
*
5+
* Notepad Next is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* Notepad Next is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with Notepad Next. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
19+
#ifndef ACP_AGENT_ICONS_H
20+
#define ACP_AGENT_ICONS_H
21+
22+
#include <QIcon>
23+
#include <QString>
24+
25+
class QPalette;
26+
struct AcpAgentDefinition;
27+
28+
// Icon for a bundled brand key (see AcpAgentBrand.h). Monochrome marks are
29+
// tinted with the palette's ButtonText and OpenCode picks its light or dark
30+
// variant, so call again after a PaletteChange. An empty or unknown key gives
31+
// the generic agent glyph. GUI thread only (results are cached).
32+
QIcon acpAgentBrandIcon(const QString &brandKey, const QPalette &palette);
33+
34+
// Icon for an agent: its custom image file when `icon` is a readable path,
35+
// otherwise the explicit or detected brand mark, otherwise the generic glyph.
36+
QIcon acpAgentIcon(const AcpAgentDefinition &def, const QPalette &palette);
37+
38+
#endif // ACP_AGENT_ICONS_H

‎src/AcpAgentPicker.h‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
#define ACP_AGENT_PICKER_H
2121

2222
#include "AcpAgentDefinition.h"
23+
#include "AcpAgentIcons.h"
2324
#include "AcpAgentRegistry.h"
2425

2526
#include <QAction>
@@ -58,7 +59,7 @@ inline void fillAcpAgentPickerMenu(QMenu *menu,
5859
if (!enabled || !hasAgents || !onPicked)
5960
return;
6061
for (const AcpAgentDefinition &agent : agents) {
61-
QAction *action = menu->addAction(agent.name);
62+
QAction *action = menu->addAction(acpAgentIcon(agent, menu->palette()), agent.name);
6263
if (agent.id == defaultId) {
6364
QFont f = action->font();
6465
f.setBold(true);

0 commit comments

Comments
 (0)