diff --git a/desktop/src/__tests__/agentsSettings.test.tsx b/desktop/src/__tests__/agentsSettings.test.tsx
new file mode 100644
index 00000000..c353c789
--- /dev/null
+++ b/desktop/src/__tests__/agentsSettings.test.tsx
@@ -0,0 +1,207 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+
+import { Settings } from '../pages/Settings'
+import { useAgentStore } from '../stores/agentStore'
+
+// Mock the API module so no real HTTP calls are made
+vi.mock('../api/agents', () => ({
+ agentsApi: {
+ list: vi.fn().mockResolvedValue({ agents: [] }),
+ },
+}))
+
+/** Override fetchAgents to a no-op so useEffect doesn't clobber manually set state */
+const noopFetch = vi.fn()
+
+// Mock MarkdownRenderer to avoid pulling in `marked` and CodeViewer deps
+vi.mock('../components/markdown/MarkdownRenderer', () => ({
+ MarkdownRenderer: ({ content }: { content: string }) => (
+
{content}
+ ),
+}))
+
+// Mock the provider store so ProviderSettings doesn't crash
+vi.mock('../stores/providerStore', () => ({
+ useProviderStore: () => ({
+ providers: [],
+ activeId: null,
+ isLoading: false,
+ fetchProviders: vi.fn(),
+ deleteProvider: vi.fn(),
+ activateProvider: vi.fn(),
+ activateOfficial: vi.fn(),
+ testProvider: vi.fn(),
+ createProvider: vi.fn(),
+ updateProvider: vi.fn(),
+ testConfig: vi.fn(),
+ }),
+}))
+
+// Mock the adapter settings to avoid its side effects
+vi.mock('../pages/AdapterSettings', () => ({
+ AdapterSettings: () => Adapter Settings Mock
,
+}))
+
+const MOCK_AGENTS = [
+ {
+ name: 'code-reviewer',
+ description: 'Reviews code for quality and security',
+ model: 'claude-sonnet-4-6',
+ tools: ['Read', 'Grep', 'Glob'],
+ systemPrompt: '# Code Reviewer\n\nYou are an expert code reviewer.',
+ color: 'blue',
+ },
+ {
+ name: 'doc-writer',
+ description: 'Writes technical documentation',
+ model: 'claude-haiku-4-5-20251001',
+ systemPrompt: 'You write clear and concise docs.',
+ color: 'green',
+ },
+ {
+ name: 'plain-agent',
+ description: undefined,
+ model: undefined,
+ systemPrompt: undefined,
+ color: undefined,
+ },
+]
+
+function switchToAgentsTab() {
+ // The Agents tab button has text "Agents"
+ const agentsTab = screen.getByText('Agents')
+ fireEvent.click(agentsTab)
+}
+
+describe('Settings > Agents tab', () => {
+ beforeEach(() => {
+ // Reset store to default state before each test
+ useAgentStore.setState({
+ agents: [],
+ isLoading: false,
+ error: null,
+ selectedAgent: null,
+ })
+ })
+
+ it('renders the Agents tab button in sidebar', () => {
+ render()
+ expect(screen.getByText('Agents')).toBeInTheDocument()
+ })
+
+ it('shows loading spinner when fetching agents', () => {
+ useAgentStore.setState({ isLoading: true, agents: [], fetchAgents: noopFetch })
+ render()
+ switchToAgentsTab()
+
+ expect(screen.getByText('Installed Agents')).toBeInTheDocument()
+ const spinner = document.querySelector('.animate-spin')
+ expect(spinner).toBeInTheDocument()
+ })
+
+ it('shows empty state when no agents installed', () => {
+ useAgentStore.setState({ agents: [], isLoading: false, fetchAgents: noopFetch })
+ render()
+ switchToAgentsTab()
+
+ expect(screen.getByText('No agents installed yet.')).toBeInTheDocument()
+ expect(screen.getByText(/Create .md or .yaml files/)).toBeInTheDocument()
+ })
+
+ it('shows error state with retry button when API fails', () => {
+ useAgentStore.setState({ agents: [], isLoading: false, error: 'Network error', fetchAgents: noopFetch })
+ render()
+ switchToAgentsTab()
+
+ expect(screen.getByText('Network error')).toBeInTheDocument()
+ expect(screen.getByText('Retry')).toBeInTheDocument()
+ })
+
+ it('renders agent list with names and descriptions', () => {
+ useAgentStore.setState({ agents: MOCK_AGENTS, isLoading: false, fetchAgents: noopFetch })
+ render()
+ switchToAgentsTab()
+
+ expect(screen.getByText('code-reviewer')).toBeInTheDocument()
+ expect(screen.getByText('Reviews code for quality and security')).toBeInTheDocument()
+ expect(screen.getByText('doc-writer')).toBeInTheDocument()
+ expect(screen.getByText('Writes technical documentation')).toBeInTheDocument()
+ // Agent count badge
+ expect(screen.getByText('3 agents')).toBeInTheDocument()
+ })
+
+ it('shows model badge for agents with model defined', () => {
+ useAgentStore.setState({ agents: MOCK_AGENTS, isLoading: false, fetchAgents: noopFetch })
+ render()
+ switchToAgentsTab()
+
+ expect(screen.getByText('claude-sonnet-4-6')).toBeInTheDocument()
+ expect(screen.getByText('claude-haiku-4-5-20251001')).toBeInTheDocument()
+ })
+
+ it('shows "No description" for agents without description', () => {
+ useAgentStore.setState({ agents: MOCK_AGENTS, isLoading: false, fetchAgents: noopFetch })
+ render()
+ switchToAgentsTab()
+
+ expect(screen.getByText('No description')).toBeInTheDocument()
+ })
+
+ it('navigates to agent detail view when clicking an agent', () => {
+ useAgentStore.setState({ agents: MOCK_AGENTS, isLoading: false, fetchAgents: noopFetch })
+ render()
+ switchToAgentsTab()
+
+ fireEvent.click(screen.getByText('code-reviewer'))
+
+ // Detail view should show
+ expect(screen.getByText('Back to list')).toBeInTheDocument()
+ expect(screen.getByText('Reviews code for quality and security')).toBeInTheDocument()
+ // Model meta
+ expect(screen.getByText(/claude-sonnet-4-6/)).toBeInTheDocument()
+ // Tools meta
+ expect(screen.getByText(/Read, Grep, Glob/)).toBeInTheDocument()
+ })
+
+ it('renders system prompt as Markdown in detail view', () => {
+ useAgentStore.setState({ agents: MOCK_AGENTS, isLoading: false, fetchAgents: noopFetch })
+ render()
+ switchToAgentsTab()
+
+ fireEvent.click(screen.getByText('code-reviewer'))
+
+ const mdRenderer = screen.getByTestId('markdown-renderer')
+ expect(mdRenderer).toBeInTheDocument()
+ expect(mdRenderer.textContent).toContain('Code Reviewer')
+ })
+
+ it('shows "no system prompt" message when agent has no prompt', () => {
+ useAgentStore.setState({ agents: MOCK_AGENTS, isLoading: false, fetchAgents: noopFetch })
+ render()
+ switchToAgentsTab()
+
+ fireEvent.click(screen.getByText('plain-agent'))
+
+ expect(screen.getByText('No system prompt defined.')).toBeInTheDocument()
+ })
+
+ it('navigates back to list from detail view', () => {
+ useAgentStore.setState({ agents: MOCK_AGENTS, isLoading: false, fetchAgents: noopFetch })
+ render()
+ switchToAgentsTab()
+
+ // Go to detail
+ fireEvent.click(screen.getByText('code-reviewer'))
+ expect(screen.getByText('Back to list')).toBeInTheDocument()
+
+ // Go back
+ fireEvent.click(screen.getByText('Back to list'))
+
+ // Should see the list again
+ expect(screen.getByText('code-reviewer')).toBeInTheDocument()
+ expect(screen.getByText('doc-writer')).toBeInTheDocument()
+ expect(screen.getByText('plain-agent')).toBeInTheDocument()
+ })
+})
diff --git a/desktop/src/api/agents.ts b/desktop/src/api/agents.ts
new file mode 100644
index 00000000..b123c825
--- /dev/null
+++ b/desktop/src/api/agents.ts
@@ -0,0 +1,14 @@
+import { api } from './client'
+
+export type AgentDefinition = {
+ name: string
+ description?: string
+ model?: string
+ tools?: string[]
+ systemPrompt?: string
+ color?: string
+}
+
+export const agentsApi = {
+ list: () => api.get<{ agents: AgentDefinition[] }>('/api/agents'),
+}
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index b669712b..cc763340 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -139,6 +139,20 @@ export const en = {
'settings.adapters.platform.telegram': 'Telegram',
'settings.adapters.platform.feishu': 'Feishu',
+ // Settings > Agents
+ 'settings.tab.agents': 'Agents',
+ 'settings.agents.title': 'Installed Agents',
+ 'settings.agents.description': 'Agents discovered from ~/.claude/agents/. Use /agents in TUI to manage them.',
+ 'settings.agents.empty': 'No agents installed yet.',
+ 'settings.agents.emptyHint': 'Create .md or .yaml files in ~/.claude/agents/ to add custom agents.',
+ 'settings.agents.model': 'Model',
+ 'settings.agents.tools': 'Tools',
+ 'settings.agents.systemPrompt': 'System Prompt',
+ 'settings.agents.noDescription': 'No description',
+ 'settings.agents.noSystemPrompt': 'No system prompt defined.',
+ 'settings.agents.backToList': 'Back to list',
+ 'settings.agents.agentCount': '{count} agents',
+
// Settings > General
'settings.general.languageTitle': 'Language',
'settings.general.languageDescription': 'Choose the display language for the application.',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index e9be3840..dac1b53f 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -141,6 +141,20 @@ export const zh: Record = {
'settings.adapters.platform.telegram': 'Telegram',
'settings.adapters.platform.feishu': '飞书',
+ // Settings > Agents
+ 'settings.tab.agents': 'Agents',
+ 'settings.agents.title': '已安装的 Agents',
+ 'settings.agents.description': '从 ~/.claude/agents/ 发现的 Agent。可在 TUI 中使用 /agents 管理。',
+ 'settings.agents.empty': '暂无已安装的 Agent。',
+ 'settings.agents.emptyHint': '在 ~/.claude/agents/ 中创建 .md 或 .yaml 文件来添加自定义 Agent。',
+ 'settings.agents.model': '模型',
+ 'settings.agents.tools': '工具',
+ 'settings.agents.systemPrompt': '系统提示词',
+ 'settings.agents.noDescription': '暂无描述',
+ 'settings.agents.noSystemPrompt': '未定义系统提示词。',
+ 'settings.agents.backToList': '返回列表',
+ 'settings.agents.agentCount': '{count} 个 Agent',
+
// Settings > General
'settings.general.languageTitle': '语言',
'settings.general.languageDescription': '选择应用程序的显示语言。',
diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx
index 2d225b68..fca8ecd4 100644
--- a/desktop/src/pages/Settings.tsx
+++ b/desktop/src/pages/Settings.tsx
@@ -11,8 +11,11 @@ import { PROVIDER_PRESETS } from '../config/providerPresets'
import type { ProviderPreset } from '../config/providerPresets'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider'
import { AdapterSettings } from './AdapterSettings'
+import { useAgentStore } from '../stores/agentStore'
+import type { AgentDefinition } from '../api/agents'
+import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
-type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters'
+type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'agents'
export function Settings() {
const [activeTab, setActiveTab] = useState('providers')
@@ -27,6 +30,7 @@ export function Settings() {
setActiveTab('permissions')} />
setActiveTab('general')} />
setActiveTab('adapters')} />
+ setActiveTab('agents')} />
{/* Tab content */}
@@ -35,6 +39,7 @@ export function Settings() {
{activeTab === 'permissions' && }
{activeTab === 'general' && }
{activeTab === 'adapters' && }
+ {activeTab === 'agents' && }
@@ -603,3 +608,150 @@ function GeneralSettings() {
)
}
+
+// ─── Agents Settings ──────────────────────────────────────
+
+const AGENT_COLORS: Record = {
+ red: '#ef4444',
+ orange: '#f97316',
+ yellow: '#eab308',
+ green: '#22c55e',
+ blue: '#3b82f6',
+ purple: '#a855f7',
+ pink: '#ec4899',
+ cyan: '#06b6d4',
+}
+
+function AgentsSettings() {
+ const { agents, isLoading, error, selectedAgent, fetchAgents, selectAgent } = useAgentStore()
+ const t = useTranslation()
+
+ useEffect(() => { fetchAgents() }, [fetchAgents])
+
+ if (selectedAgent) {
+ return selectAgent(null)} />
+ }
+
+ return (
+
+
+
+
{t('settings.agents.title')}
+
{t('settings.agents.description')}
+
+ {agents.length > 0 && (
+
{t('settings.agents.agentCount', { count: String(agents.length) })}
+ )}
+
+
+ {isLoading && agents.length === 0 ? (
+
+ ) : error ? (
+
+
error_outline
+
{error}
+
+
+ ) : agents.length === 0 ? (
+
+
smart_toy
+
{t('settings.agents.empty')}
+
{t('settings.agents.emptyHint')}
+
+ ) : (
+
+ {agents.map((agent) => {
+ const dotColor = agent.color && AGENT_COLORS[agent.color] ? AGENT_COLORS[agent.color] : 'var(--color-text-tertiary)'
+ return (
+
+ )
+ })}
+
+ )}
+
+ )
+}
+
+function AgentDetailView({ agent, onBack }: { agent: AgentDefinition; onBack: () => void }) {
+ const t = useTranslation()
+ const dotColor = agent.color && AGENT_COLORS[agent.color] ? AGENT_COLORS[agent.color] : 'var(--color-text-tertiary)'
+
+ return (
+
+ {/* Back button */}
+
+
+ {/* Agent header */}
+
+
+
{agent.name}
+
+
+ {/* Description */}
+ {agent.description && (
+
{agent.description}
+ )}
+
+ {/* Meta info */}
+
+ {agent.model && (
+
+ psychology
+ {t('settings.agents.model')}: {agent.model}
+
+ )}
+ {agent.tools && agent.tools.length > 0 && (
+
+ build
+ {t('settings.agents.tools')}: {agent.tools.join(', ')}
+
+ )}
+
+
+ {/* System Prompt */}
+
+
{t('settings.agents.systemPrompt')}
+ {agent.systemPrompt ? (
+
+
+
+ ) : (
+
{t('settings.agents.noSystemPrompt')}
+ )}
+
+
+ )
+}
diff --git a/desktop/src/stores/agentStore.ts b/desktop/src/stores/agentStore.ts
new file mode 100644
index 00000000..c3dae35f
--- /dev/null
+++ b/desktop/src/stores/agentStore.ts
@@ -0,0 +1,32 @@
+import { create } from 'zustand'
+import { agentsApi, type AgentDefinition } from '../api/agents'
+
+type AgentStore = {
+ agents: AgentDefinition[]
+ isLoading: boolean
+ error: string | null
+ selectedAgent: AgentDefinition | null
+
+ fetchAgents: () => Promise
+ selectAgent: (agent: AgentDefinition | null) => void
+}
+
+export const useAgentStore = create((set) => ({
+ agents: [],
+ isLoading: false,
+ error: null,
+ selectedAgent: null,
+
+ fetchAgents: async () => {
+ set({ isLoading: true, error: null })
+ try {
+ const { agents } = await agentsApi.list()
+ set({ agents, isLoading: false })
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to load agents'
+ set({ isLoading: false, error: message })
+ }
+ },
+
+ selectAgent: (agent) => set({ selectedAgent: agent }),
+}))