Merge pull request #50 from cesarafonseca/feat/subagent-type-color-badges

feat: color badges for subagent types with .claude/agents/ config support
This commit is contained in:
matt 2026-02-22 02:07:42 +09:00 committed by GitHub
commit 39d88e22a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 400 additions and 13 deletions

View file

@ -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 {};
}
});
}

View file

@ -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<Record<string, AgentConfig>> {
try {
return await readAgentConfigs(projectRoot);
} catch (error) {
logger.error('Error in read-agent-configs:', error);
return {};
}
}

View file

@ -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<string, string> {
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
if (!match) return {};
const result: Record<string, string> = {};
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<Record<string, AgentConfig>> {
const agentsDir = path.join(projectRoot, '.claude', 'agents');
const result: Record<string, AgentConfig> = {};
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;
}

View file

@ -8,6 +8,7 @@
* - GitIdentityResolver: Resolves git identities from sessions
*/
export * from './AgentConfigReader';
export * from './ClaudeMdReader';
export * from './GitIdentityResolver';
export * from './MessageClassifier';

View file

@ -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 }) =>

View file

@ -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<Record<string, AgentConfig>> =>
this.post<Record<string, AgentConfig>>('/api/read-agent-configs', { projectRoot });
// ---------------------------------------------------------------------------
// Notifications (nested API)
// ---------------------------------------------------------------------------

View file

@ -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<SubagentItemProps> = ({
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<SubagentItemProps> = ({
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 ? (
<span
className="size-3.5 shrink-0 rounded-full"
style={{ backgroundColor: teamColors.border }}
style={{ backgroundColor: (teamColors ?? typeColors)!.border }}
/>
) : (
<Bot
@ -298,7 +300,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
/>
)}
{/* Type badge - team member name or generic type */}
{/* Type badge - team member name or typed subagent */}
{teamColors && subagent.team ? (
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
@ -314,9 +316,9 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide"
style={{
backgroundColor: TAG_BG,
color: TAG_TEXT,
border: `1px solid ${TAG_BORDER}`,
backgroundColor: typeColors!.badge,
color: typeColors!.text,
border: `1px solid ${typeColors!.border}40`,
}}
>
{subagentType}

View file

@ -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<string, { color?: string }>
): 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;

View file

@ -25,6 +25,7 @@ const sessionRefreshGeneration = new Map<string, number>();
const sessionRefreshInFlight = new Set<string>();
const sessionRefreshQueued = new Set<string>();
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<string, AgentConfig>;
// Visible AI Group
visibleAIGroupId: string | null;
selectedAIGroup: AIGroup | null;
@ -133,6 +138,8 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
// Context phase info (compaction boundaries)
sessionPhaseInfo: null,
agentConfigs: {},
visibleAIGroupId: null,
selectedAIGroup: null,
@ -190,6 +197,18 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
let claudeMdStats: Map<string, ClaudeMdStats> | null = null;
let contextStats: Map<string, ContextStats> | 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<string, ClaudeMdFileInfo> = {};

View file

@ -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<ClaudeMdFileInfo | null>;
// Agent config reading
readAgentConfigs: (projectRoot: string) => Promise<Record<string, AgentConfig>>;
// Notifications API
notifications: NotificationsAPI;

View file

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

View file

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