From f05bf9fac4e9102058137e9a3d4dc2fccff2a7f5 Mon Sep 17 00:00:00 2001 From: Cesar Augusto Fonseca Date: Sat, 21 Feb 2026 13:15:47 -0300 Subject: [PATCH] feat: color badges for subagent types with .claude/agents/ config support Subagent badges now show distinct colors instead of generic gray. Colors are resolved from the project's .claude/agents/*.md frontmatter (color field), with deterministic hash-based fallback for unconfigured types. New AgentConfigReader service reads agent definitions via IPC, cached per project root to avoid redundant disk reads on session refreshes. Team member colors remain unaffected (team branch has priority). --- src/main/http/utility.ts | 13 +- src/main/ipc/utility.ts | 22 +++- .../services/parsing/AgentConfigReader.ts | 75 +++++++++++ src/main/services/parsing/index.ts | 1 + src/preload/index.ts | 4 + src/renderer/api/httpClient.ts | 8 ++ .../components/chat/items/SubagentItem.tsx | 24 ++-- src/renderer/constants/teamColors.ts | 24 ++++ .../store/slices/sessionDetailSlice.ts | 19 +++ src/shared/types/api.ts | 12 ++ .../parsing/AgentConfigReader.test.ts | 95 ++++++++++++++ test/renderer/constants/teamColors.test.ts | 116 ++++++++++++++++++ 12 files changed, 400 insertions(+), 13 deletions(-) create mode 100644 src/main/services/parsing/AgentConfigReader.ts create mode 100644 test/main/services/parsing/AgentConfigReader.test.ts create mode 100644 test/renderer/constants/teamColors.test.ts diff --git a/src/main/http/utility.ts b/src/main/http/utility.ts index 2c7f4504..ae86bb90 100644 --- a/src/main/http/utility.ts +++ b/src/main/http/utility.ts @@ -14,7 +14,7 @@ import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; -import { type ClaudeMdFileInfo, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services'; +import { type ClaudeMdFileInfo, readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services'; import { validateFilePath } from '../utils/pathValidation'; import { countTokens } from '../utils/tokenizer'; @@ -123,4 +123,15 @@ export function registerUtilityRoutes(app: FastifyInstance): void { app.post<{ Body: { url: string } }>('/api/open-external', async () => { return { success: false, error: 'Not available in browser mode' }; }); + + // Read agent configs + app.post<{ Body: { projectRoot: string } }>('/api/read-agent-configs', async (request) => { + try { + const { projectRoot } = request.body; + return await readAgentConfigs(projectRoot); + } catch (error) { + logger.error('Error in POST /api/read-agent-configs:', error); + return {}; + } + }); } diff --git a/src/main/ipc/utility.ts b/src/main/ipc/utility.ts index c6e69a6f..83d1b6db 100644 --- a/src/main/ipc/utility.ts +++ b/src/main/ipc/utility.ts @@ -12,7 +12,9 @@ import { createLogger } from '@shared/utils/logger'; import { app, type IpcMain, type IpcMainInvokeEvent, shell } from 'electron'; import * as fs from 'fs'; -import { type ClaudeMdFileInfo, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services'; +import { type ClaudeMdFileInfo, readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services'; + +import type { AgentConfig } from '@shared/types/api'; const logger = createLogger('IPC:utility'); import { validateFilePath, validateOpenPath } from '../utils/pathValidation'; @@ -28,6 +30,7 @@ export function registerUtilityHandlers(ipcMain: IpcMain): void { ipcMain.handle('read-claude-md-files', handleReadClaudeMdFiles); ipcMain.handle('read-directory-claude-md', handleReadDirectoryClaudeMd); ipcMain.handle('read-mentioned-file', handleReadMentionedFile); + ipcMain.handle('read-agent-configs', handleReadAgentConfigs); logger.info('Utility handlers registered'); } @@ -42,6 +45,7 @@ export function removeUtilityHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler('read-claude-md-files'); ipcMain.removeHandler('read-directory-claude-md'); ipcMain.removeHandler('read-mentioned-file'); + ipcMain.removeHandler('read-agent-configs'); logger.info('Utility handlers removed'); } @@ -228,3 +232,19 @@ async function handleReadMentionedFile( return null; } } + +/** + * Handler for 'read-agent-configs' IPC call. + * Reads agent definitions from project's .claude/agents/ directory. + */ +async function handleReadAgentConfigs( + _event: IpcMainInvokeEvent, + projectRoot: string +): Promise> { + try { + return await readAgentConfigs(projectRoot); + } catch (error) { + logger.error('Error in read-agent-configs:', error); + return {}; + } +} diff --git a/src/main/services/parsing/AgentConfigReader.ts b/src/main/services/parsing/AgentConfigReader.ts new file mode 100644 index 00000000..a6e23ea5 --- /dev/null +++ b/src/main/services/parsing/AgentConfigReader.ts @@ -0,0 +1,75 @@ +/** + * Agent Config Reader + * + * Reads `.claude/agents/*.md` files from a project directory and extracts + * frontmatter metadata (name, color) for use in subagent visualization. + */ + +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +import type { AgentConfig } from '@shared/types/api'; + +const logger = createLogger('AgentConfigReader'); + +/** + * Parse simple YAML frontmatter from markdown content. + * Only extracts top-level scalar key: value pairs between --- delimiters. + */ +function parseFrontmatter(content: string): Record { + const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content); + if (!match) return {}; + + const result: Record = {}; + for (const line of match[1].split('\n')) { + const colonIdx = line.indexOf(':'); + if (colonIdx === -1) continue; + const key = line.slice(0, colonIdx).trim(); + let value = line.slice(colonIdx + 1).trim(); + // Strip surrounding quotes + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (key) result[key] = value; + } + return result; +} + +/** + * Read agent config files from a project's `.claude/agents/` directory. + * Returns a map of agent name → config (with optional color). + */ +export async function readAgentConfigs( + projectRoot: string +): Promise> { + const agentsDir = path.join(projectRoot, '.claude', 'agents'); + const result: Record = {}; + + try { + const entries = await fs.promises.readdir(agentsDir); + const mdFiles = entries.filter((f) => f.endsWith('.md')); + + await Promise.all( + mdFiles.map(async (filename) => { + try { + const content = await fs.promises.readFile(path.join(agentsDir, filename), 'utf8'); + const frontmatter = parseFrontmatter(content); + const name = frontmatter.name || filename.replace(/\.md$/, ''); + const config: AgentConfig = { name }; + if (frontmatter.color) { + config.color = frontmatter.color; + } + result[name] = config; + } catch { + // Skip unreadable files + } + }) + ); + } catch { + // Directory doesn't exist or unreadable — normal for projects without custom agents + logger.debug(`No agents directory at ${agentsDir}`); + } + + return result; +} diff --git a/src/main/services/parsing/index.ts b/src/main/services/parsing/index.ts index 998fcdf8..98610610 100644 --- a/src/main/services/parsing/index.ts +++ b/src/main/services/parsing/index.ts @@ -8,6 +8,7 @@ * - GitIdentityResolver: Resolves git identities from sessions */ +export * from './AgentConfigReader'; export * from './ClaudeMdReader'; export * from './GitIdentityResolver'; export * from './MessageClassifier'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 88882804..09f16c5c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -169,6 +169,10 @@ const electronAPI: ElectronAPI = { readMentionedFile: (absolutePath: string, projectRoot: string, maxTokens?: number) => ipcRenderer.invoke('read-mentioned-file', absolutePath, projectRoot, maxTokens), + // Agent config reading + readAgentConfigs: (projectRoot: string) => + ipcRenderer.invoke('read-agent-configs', projectRoot), + // Notifications API notifications: { get: (options?: { limit?: number; offset?: number }) => diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 50672309..0b2d851f 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -41,6 +41,7 @@ import type { WaterfallData, WslClaudeRootCandidate, } from '@shared/types'; +import type { AgentConfig } from '@shared/types/api'; export class HttpAPIClient implements ElectronAPI { private baseUrl: string; @@ -309,6 +310,13 @@ export class HttpAPIClient implements ElectronAPI { maxTokens, }); + // --------------------------------------------------------------------------- + // Agent config reading + // --------------------------------------------------------------------------- + + readAgentConfigs = (projectRoot: string): Promise> => + this.post>('/api/read-agent-configs', { projectRoot }); + // --------------------------------------------------------------------------- // Notifications (nested API) // --------------------------------------------------------------------------- diff --git a/src/renderer/components/chat/items/SubagentItem.tsx b/src/renderer/components/chat/items/SubagentItem.tsx index bbd8cb82..7f653642 100644 --- a/src/renderer/components/chat/items/SubagentItem.tsx +++ b/src/renderer/components/chat/items/SubagentItem.tsx @@ -11,11 +11,8 @@ import { CARD_TEXT_LIGHTER, COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY, - TAG_BG, - TAG_BORDER, - TAG_TEXT, } from '@renderer/constants/cssVariables'; -import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { getSubagentTypeColorSet, getTeamColorSet } from '@renderer/constants/teamColors'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useStore } from '@renderer/store'; import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer'; @@ -78,8 +75,13 @@ export const SubagentItem: React.FC = ({ const subagentType = subagent.subagentType ?? 'Task'; const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description; + // Agent configs from .claude/agents/ for color lookup + const agentConfigs = useStore((s) => s.agentConfigs); + // Team member colors (when this subagent is a team member) const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null; + // Type-based colors for non-team subagents (from agent config or deterministic hash) + const typeColors = !teamColors ? getSubagentTypeColorSet(subagentType, agentConfigs) : null; // Detect shutdown-only team activations (trivial: just a shutdown_response) const isShutdownOnly = useMemo(() => { @@ -285,11 +287,11 @@ export const SubagentItem: React.FC = ({ style={{ color: CARD_ICON_MUTED }} /> - {/* Icon - colored dot for team members, Bot icon for regular subagents */} - {teamColors ? ( + {/* Icon - colored dot for team members/typed subagents, Bot icon for generic */} + {teamColors || typeColors ? ( ) : ( = ({ /> )} - {/* Type badge - team member name or generic type */} + {/* Type badge - team member name or typed subagent */} {teamColors && subagent.team ? ( = ({ {subagentType} diff --git a/src/renderer/constants/teamColors.ts b/src/renderer/constants/teamColors.ts index 96c4177c..e2ce3627 100644 --- a/src/renderer/constants/teamColors.ts +++ b/src/renderer/constants/teamColors.ts @@ -31,6 +31,30 @@ const DEFAULT_COLOR: TeamColorSet = TEAMMATE_COLORS.blue; * Get a TeamColorSet from a color name or hex string. * Falls back to blue if unrecognized. */ +const COLOR_NAMES = Object.keys(TEAMMATE_COLORS); + +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash * 31 + str.charCodeAt(i)) | 0; + } + return Math.abs(hash); +} + +export function getSubagentTypeColorSet( + subagentType: string, + agentConfigs?: Record +): TeamColorSet { + // Use color from agent config if available + const configColor = agentConfigs?.[subagentType]?.color; + if (configColor) { + return getTeamColorSet(configColor); + } + // Fallback: deterministic hash-based color + const index = hashString(subagentType) % COLOR_NAMES.length; + return TEAMMATE_COLORS[COLOR_NAMES[index]]; +} + export function getTeamColorSet(colorName: string): TeamColorSet { if (!colorName) return DEFAULT_COLOR; diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts index 344e93c3..7eb2dd8c 100644 --- a/src/renderer/store/slices/sessionDetailSlice.ts +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -25,6 +25,7 @@ const sessionRefreshGeneration = new Map(); const sessionRefreshInFlight = new Set(); const sessionRefreshQueued = new Set(); let sessionDetailFetchGeneration = 0; +let agentConfigsCachedForProject = ''; import { getAllTabs } from '../utils/paneHelpers'; @@ -37,6 +38,7 @@ import type { } from '@renderer/types/contextInjection'; import type { ClaudeMdFileInfo, SessionDetail } from '@renderer/types/data'; import type { AIGroup, SessionConversation } from '@renderer/types/groups'; +import type { AgentConfig } from '@shared/types/api'; import type { StateCreator } from 'zustand'; // ============================================================================= @@ -92,6 +94,9 @@ export interface SessionDetailSlice { // Context phase info (compaction boundaries) sessionPhaseInfo: ContextPhaseInfo | null; + // Agent configs from .claude/agents/ (keyed by agent name) + agentConfigs: Record; + // Visible AI Group visibleAIGroupId: string | null; selectedAIGroup: AIGroup | null; @@ -133,6 +138,8 @@ export const createSessionDetailSlice: StateCreator | null = null; let contextStats: Map | null = null; let phaseInfo: ContextPhaseInfo | null = null; + // Fetch agent configs from .claude/agents/ (only when project changes) + if (connectionMode !== 'ssh' && projectRoot && projectRoot !== agentConfigsCachedForProject) { + try { + const configs = await api.readAgentConfigs(projectRoot); + if (requestGeneration !== sessionDetailFetchGeneration) return; + agentConfigsCachedForProject = projectRoot; + set({ agentConfigs: configs }); + } catch (err) { + logger.error('Failed to read agent configs:', err); + } + } + if (connectionMode !== 'ssh' && conversation?.items) { // Fetch real CLAUDE.md token data let claudeMdTokenData: Record = {}; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 9c8847ba..c81dbd8b 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -29,6 +29,15 @@ import type { SubagentDetail, } from '@main/types'; +// ============================================================================= +// Agent Config +// ============================================================================= + +export interface AgentConfig { + name: string; + color?: string; +} + // ============================================================================= // Notifications API // ============================================================================= @@ -366,6 +375,9 @@ export interface ElectronAPI { maxTokens?: number ) => Promise; + // Agent config reading + readAgentConfigs: (projectRoot: string) => Promise>; + // Notifications API notifications: NotificationsAPI; diff --git a/test/main/services/parsing/AgentConfigReader.test.ts b/test/main/services/parsing/AgentConfigReader.test.ts new file mode 100644 index 00000000..114480d4 --- /dev/null +++ b/test/main/services/parsing/AgentConfigReader.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +import { readAgentConfigs } from '@main/services/parsing/AgentConfigReader'; + +describe('readAgentConfigs', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-config-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeAgent(filename: string, content: string): void { + const agentsDir = path.join(tmpDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, filename), content); + } + + it('returns empty object when .claude/agents/ does not exist', async () => { + const result = await readAgentConfigs(tmpDir); + expect(result).toEqual({}); + }); + + it('parses agent with name and color from frontmatter', async () => { + writeAgent('test-agent.md', `--- +name: test-agent +color: red +--- +# Test agent +`); + const result = await readAgentConfigs(tmpDir); + expect(result).toEqual({ + 'test-agent': { name: 'test-agent', color: 'red' }, + }); + }); + + it('uses filename as name when frontmatter has no name field', async () => { + writeAgent('my-agent.md', `--- +color: blue +--- +# My Agent +`); + const result = await readAgentConfigs(tmpDir); + expect(result['my-agent']).toBeDefined(); + expect(result['my-agent'].color).toBe('blue'); + }); + + it('handles agents without color field', async () => { + writeAgent('plain.md', `--- +name: plain +description: "A plain agent" +--- +Content +`); + const result = await readAgentConfigs(tmpDir); + expect(result.plain).toEqual({ name: 'plain' }); + expect(result.plain.color).toBeUndefined(); + }); + + it('handles agents without frontmatter', async () => { + writeAgent('no-front.md', '# Just markdown\nNo frontmatter here.'); + const result = await readAgentConfigs(tmpDir); + expect(result['no-front']).toEqual({ name: 'no-front' }); + }); + + it('reads multiple agents', async () => { + writeAgent('a.md', `---\nname: a\ncolor: green\n---\n`); + writeAgent('b.md', `---\nname: b\ncolor: purple\n---\n`); + const result = await readAgentConfigs(tmpDir); + expect(Object.keys(result)).toHaveLength(2); + expect(result.a.color).toBe('green'); + expect(result.b.color).toBe('purple'); + }); + + it('strips quotes from frontmatter values', async () => { + writeAgent('quoted.md', `---\nname: "quoted-agent"\ncolor: 'cyan'\n---\n`); + const result = await readAgentConfigs(tmpDir); + expect(result['quoted-agent']).toEqual({ name: 'quoted-agent', color: 'cyan' }); + }); + + it('ignores non-md files', async () => { + const agentsDir = path.join(tmpDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'readme.txt'), 'not an agent'); + writeAgent('real.md', `---\nname: real\ncolor: red\n---\n`); + const result = await readAgentConfigs(tmpDir); + expect(Object.keys(result)).toEqual(['real']); + }); +}); diff --git a/test/renderer/constants/teamColors.test.ts b/test/renderer/constants/teamColors.test.ts new file mode 100644 index 00000000..00844991 --- /dev/null +++ b/test/renderer/constants/teamColors.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; + +import { getSubagentTypeColorSet, getTeamColorSet, TeamColorSet } from '@renderer/constants/teamColors'; + +function isValidColorSet(cs: TeamColorSet): boolean { + return typeof cs.border === 'string' && typeof cs.badge === 'string' && typeof cs.text === 'string'; +} + +// ============================================================================= +// getTeamColorSet +// ============================================================================= + +describe('getTeamColorSet', () => { + it('returns blue (default) for empty string', () => { + const result = getTeamColorSet(''); + expect(result.border).toBe('#3b82f6'); + }); + + it('resolves named colors', () => { + expect(getTeamColorSet('green').border).toBe('#22c55e'); + expect(getTeamColorSet('red').border).toBe('#ef4444'); + expect(getTeamColorSet('purple').border).toBe('#a855f7'); + }); + + it('is case-insensitive for named colors', () => { + expect(getTeamColorSet('Green')).toEqual(getTeamColorSet('green')); + expect(getTeamColorSet('BLUE')).toEqual(getTeamColorSet('blue')); + }); + + it('generates a color set from hex strings', () => { + const result = getTeamColorSet('#ff5500'); + expect(result.border).toBe('#ff5500'); + expect(result.badge).toBe('#ff550026'); + expect(result.text).toBe('#ff5500'); + }); + + it('falls back to blue for unknown non-hex strings', () => { + const result = getTeamColorSet('nonexistent'); + expect(result.border).toBe('#3b82f6'); + }); +}); + +// ============================================================================= +// getSubagentTypeColorSet +// ============================================================================= + +describe('getSubagentTypeColorSet', () => { + it('always returns a valid TeamColorSet without agent configs', () => { + const types = ['test-agent', 'quality-fixer', 'Explore', 'Plan', 'my-custom-agent', 'anything']; + for (const t of types) { + const result = getSubagentTypeColorSet(t); + expect(isValidColorSet(result)).toBe(true); + } + }); + + it('is deterministic — same input always returns same color', () => { + const a = getSubagentTypeColorSet('my-custom-agent'); + const b = getSubagentTypeColorSet('my-custom-agent'); + expect(a).toEqual(b); + }); + + it('different types can produce different colors', () => { + const results = new Set( + ['Explore', 'Plan', 'test-agent', 'quality-fixer', 'claude-md-auditor', 'Bash', 'general-purpose', 'statusline-setup'] + .map((t) => getSubagentTypeColorSet(t).border) + ); + expect(results.size).toBeGreaterThan(1); + }); + + it('uses color from agent config when available', () => { + const configs = { + 'test-agent': { name: 'test-agent', color: 'red' }, + }; + const result = getSubagentTypeColorSet('test-agent', configs); + // Should use the named "red" color from getTeamColorSet + expect(result.border).toBe('#ef4444'); + expect(result.text).toBe('#f87171'); + }); + + it('uses hex color from agent config', () => { + const configs = { + 'my-agent': { name: 'my-agent', color: '#ff00ff' }, + }; + const result = getSubagentTypeColorSet('my-agent', configs); + expect(result.border).toBe('#ff00ff'); + }); + + it('falls back to hash when agent config has no color', () => { + const configs = { + 'my-agent': { name: 'my-agent' }, + }; + const withConfig = getSubagentTypeColorSet('my-agent', configs); + const withoutConfig = getSubagentTypeColorSet('my-agent'); + // Should be the same — both use hash fallback + expect(withConfig).toEqual(withoutConfig); + }); + + it('falls back to hash when agent type not in configs', () => { + const configs = { + 'other-agent': { name: 'other-agent', color: 'green' }, + }; + const withConfig = getSubagentTypeColorSet('unknown-agent', configs); + const withoutConfig = getSubagentTypeColorSet('unknown-agent'); + expect(withConfig).toEqual(withoutConfig); + }); + + it('does not interfere with getTeamColorSet', () => { + const teamGreen = getTeamColorSet('green'); + expect(teamGreen.border).toBe('#22c55e'); + + const configs = { green: { name: 'green', color: 'purple' } }; + getSubagentTypeColorSet('green', configs); + // Team API remains unaffected + expect(getTeamColorSet('green').border).toBe('#22c55e'); + }); +});