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'); + }); +});