build the Config sidebar tab

The Config sub-tab grows from a static settings table into a browser of
the Claude environment: the pinned settings table stays, and below it
expandable accordions list the full (never-truncated) sets of skills,
agents, commands, hooks, permissions (grouped + colour-coded by allow/
ask/deny), and MCP servers. File-backed entries are clickable and open
their .md via editor.open. ClaudeConfig gains agents, hooks, and
mcpServers loaders plus path fields on skills/commands, kept live by the
existing .claude file watcher.

T-183.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-31 11:30:02 +02:00
co-authored by Claude
parent a0636c2e5a
commit 494a6f13a5
7 changed files with 829 additions and 12 deletions
+118 -3
View File
@@ -34,17 +34,50 @@ enum ConfigScope { global, local }
@immutable
class ClaudeSkill {
const ClaudeSkill({required this.name, this.description, required this.scope});
const ClaudeSkill({required this.name, this.description, required this.scope, this.path});
final String name;
final String? description;
final ConfigScope scope;
/// Absolute path to the SKILL.md file, or null if the skill isn't file-backed
/// (e.g. sourced from the probe cache only).
final String? path;
}
@immutable
class ClaudeCommand {
const ClaudeCommand({required this.name, required this.scope});
const ClaudeCommand({required this.name, required this.scope, this.path});
final String name;
final ConfigScope scope;
/// Absolute path to the command's .md file, used for click-to-open (T-183).
final String? path;
}
/// A Claude agent — a sub-agent definition from `<scope>/agents/*.md`.
@immutable
class ClaudeAgent {
const ClaudeAgent({required this.name, required this.scope, this.path});
final String name;
final ConfigScope scope;
/// Absolute path to the agent's .md file, used for click-to-open (T-183).
final String? path;
}
/// One hook entry — a named group of commands from `settings['hooks']`.
@immutable
class ClaudeHook {
const ClaudeHook({required this.event, required this.commands});
final String event;
final List<String> commands;
}
/// A named MCP server entry from `settings['mcpServers']`.
@immutable
class ClaudeMcpServer {
const ClaudeMcpServer({required this.name});
final String name;
}
@immutable
@@ -172,6 +205,9 @@ class ClaudeConfig extends ChangeNotifier {
bool _probing = false;
List<ClaudeSkill> _skills = const [];
List<ClaudeCommand> _commands = const [];
List<ClaudeAgent> _agents = const [];
List<ClaudeHook> _hooks = const [];
List<ClaudeMcpServer> _mcpServers = const [];
Map<String, Object?> _settings = const {};
ClaudePermissions _permissions = const ClaudePermissions();
String? _error;
@@ -199,6 +235,16 @@ class ClaudeConfig extends ChangeNotifier {
List<ClaudeSkill> get skills => _skills;
List<ClaudeCommand> get commands => _commands;
/// Agent definitions scanned from `<scope>/agents/*.md` (T-183, D-76).
List<ClaudeAgent> get agents => _agents;
/// Hook entries parsed from `settings['hooks']` (T-183).
List<ClaudeHook> get hooks => _hooks;
/// MCP server names from `settings['mcpServers']` (T-183).
List<ClaudeMcpServer> get mcpServers => _mcpServers;
Map<String, Object?> get settings => Map.unmodifiable(_settings);
ClaudePermissions get permissions => _permissions;
@@ -326,12 +372,14 @@ class ClaudeConfig extends ChangeNotifier {
Future<void> _loadDiskConfig() async {
final skills = <ClaudeSkill>[];
final commands = <ClaudeCommand>[];
final agents = <ClaudeAgent>[];
final settings = <String, Object?>{};
final allow = <String>[], deny = <String>[], ask = <String>[];
for (final (scope, dir) in _scopeDirs()) {
skills.addAll(await _loadSkills(dir, scope));
commands.addAll(await _loadCommands(dir, scope));
agents.addAll(await _loadAgents(dir, scope));
final s = await _loadSettings(dir);
settings.addAll(s); // local overrides global per top-level key
final p = _permissionsOf(s);
@@ -342,8 +390,11 @@ class ClaudeConfig extends ChangeNotifier {
_skills = _dedupeByName(skills, (s) => s.name);
_commands = _dedupeByName(commands, (c) => c.name);
_agents = _dedupeByName(agents, (a) => a.name);
_settings = settings;
_permissions = ClaudePermissions(allow: _uniq(allow), deny: _uniq(deny), ask: _uniq(ask));
_hooks = _parseHooks(settings['hooks']);
_mcpServers = _parseMcpServers(settings['mcpServers']);
}
/// Global first so that local entries, added later, win on collisions.
@@ -368,6 +419,7 @@ class ClaudeConfig extends ChangeNotifier {
name: fm.name ?? _basename(entry.path),
description: fm.description,
scope: scope,
path: manifest.path,
));
}
return out;
@@ -380,7 +432,28 @@ class ClaudeConfig extends ChangeNotifier {
await for (final entry in dir.list()) {
if (entry is! File || !entry.path.endsWith('.md')) continue;
final base = _basename(entry.path);
out.add(ClaudeCommand(name: base.substring(0, base.length - 3), scope: scope));
out.add(ClaudeCommand(
name: base.substring(0, base.length - 3),
scope: scope,
path: entry.path,
));
}
return out;
}
/// Scans `<scopeDir>/agents/*.md` for agent definitions (T-183, D-76).
Future<List<ClaudeAgent>> _loadAgents(Directory scopeDir, ConfigScope scope) async {
final dir = Directory('${scopeDir.path}/agents');
if (!await dir.exists()) return const [];
final out = <ClaudeAgent>[];
await for (final entry in dir.list()) {
if (entry is! File || !entry.path.endsWith('.md')) continue;
final base = _basename(entry.path);
out.add(ClaudeAgent(
name: base.substring(0, base.length - 3),
scope: scope,
path: entry.path,
));
}
return out;
}
@@ -463,6 +536,48 @@ class ClaudeConfig extends ChangeNotifier {
return (name: null, description: null);
}
/// Parse `settings['hooks']` into typed [ClaudeHook] entries.
///
/// Claude's hooks are a `Map<String, List<{hooks: [{command: String}]}>>`
/// keyed on event name (e.g. `"PreToolUse"`). We flatten each event's
/// command list into a single [ClaudeHook].
static List<ClaudeHook> _parseHooks(Object? raw) {
if (raw is! Map) return const [];
final out = <ClaudeHook>[];
for (final entry in raw.entries) {
final event = '${entry.key}';
final cmds = <String>[];
// Each value is a list of hook groups; each group has a `hooks` list.
if (entry.value is List) {
for (final group in entry.value as List) {
if (group is Map) {
final hooksList = group['hooks'];
if (hooksList is List) {
for (final h in hooksList) {
if (h is Map) {
final cmd = h['command'];
if (cmd is String && cmd.isNotEmpty) cmds.add(cmd);
}
}
}
}
}
}
if (cmds.isNotEmpty) out.add(ClaudeHook(event: event, commands: cmds));
}
return out;
}
/// Parse `settings['mcpServers']` into [ClaudeMcpServer] entries.
///
/// Claude's `mcpServers` is a `Map<String, {...config}>` keyed on server name.
static List<ClaudeMcpServer> _parseMcpServers(Object? raw) {
if (raw is! Map) return const [];
return [
for (final key in raw.keys) ClaudeMcpServer(name: '$key'),
];
}
static List<T> _dedupeByName<T>(List<T> all, String Function(T) nameOf) {
final byName = <String, T>{};
for (final item in all) {
+237 -9
View File
@@ -96,6 +96,9 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
ClaudeSessionOrchestrator? _orchestrator;
SessionStatus? _primaryStatus;
// T-183: Config accordion expansion state — each section starts collapsed.
final Set<_ConfigSection> _expanded = {};
/// agentId currently in "inject message" mode (shows the text field).
String? _injectingAgentId;
final _injectCtl = TextEditingController();
@@ -383,7 +386,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
ClideKernel.of(context).panels.activateTab(Slots.workspace, 'claude.team-chat');
}
// --- Config ---------------------------------------------------------------
// --- Config (T-183) -------------------------------------------------------
Widget _configBody(SurfaceTokens tokens) {
final config = _config;
@@ -394,14 +397,233 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
final model = config.probe?.model ?? settings['model']?.toString() ?? '—';
final outputStyle = settings['outputStyle']?.toString() ?? 'default';
final mode = config.probe?.permissionMode ?? settings['permissionMode']?.toString() ?? 'default';
return _metaTable(tokens, [
_MetaSection('SETTINGS', [
_MetaRow('model', model, valueColor: tokens.globalFocus),
_MetaRow('output style', outputStyle),
_MetaRow('permission mode', permissionModeLabel(mode)),
_MetaRow('source', '~/.claude + .claude'),
]),
]);
final children = <Widget>[
// Pinned SETTINGS table — not collapsible.
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: ClideText('SETTINGS', fontSize: clideFontSmall, color: tokens.globalTextMuted),
),
_configRow(tokens, 'model', model, valueColor: tokens.globalFocus),
_configRow(tokens, 'output style', outputStyle),
_configRow(tokens, 'permission mode', permissionModeLabel(mode)),
_configRow(tokens, 'source', '~/.claude + .claude'),
// ---- Accordion sections ----
_configAccordion(tokens, config, _ConfigSection.skills),
_configAccordion(tokens, config, _ConfigSection.agents),
_configAccordion(tokens, config, _ConfigSection.commands),
_configAccordion(tokens, config, _ConfigSection.hooks),
_configAccordion(tokens, config, _ConfigSection.permissions),
_configAccordion(tokens, config, _ConfigSection.mcpServers),
// Footer hint.
Padding(
padding: const EdgeInsets.only(top: 12),
child: ClideText(
'expand a list to see all · click a skill/agent/command → opens its .md',
muted: true,
fontSize: clideFontSmall,
),
),
];
return ListView(
padding: const EdgeInsets.all(12),
children: children,
);
}
/// One key→value row in the pinned SETTINGS table.
Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: _rowPitch),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: _labelColumnWidth,
child: ClideText(label, muted: true, fontSize: clideFontSmall),
),
Expanded(
child: ClideText(value, fontSize: clideFontSmall, color: valueColor ?? tokens.globalForeground),
),
],
),
);
}
String _configSectionLabel(_ConfigSection section) => switch (section) {
_ConfigSection.skills => 'SKILLS',
_ConfigSection.agents => 'AGENTS',
_ConfigSection.commands => 'COMMANDS',
_ConfigSection.hooks => 'HOOKS',
_ConfigSection.permissions => 'PERMISSIONS',
_ConfigSection.mcpServers => 'MCP SERVERS',
};
int _configSectionCount(ClaudeConfig config, _ConfigSection section) => switch (section) {
_ConfigSection.skills => config.skills.length,
_ConfigSection.agents => config.agents.length,
_ConfigSection.commands => config.commands.length,
_ConfigSection.hooks => config.hooks.length,
_ConfigSection.permissions => config.permissions.allow.length + config.permissions.deny.length + config.permissions.ask.length,
_ConfigSection.mcpServers => config.mcpServers.length,
};
Widget _configAccordion(SurfaceTokens tokens, ClaudeConfig config, _ConfigSection section) {
final expanded = _expanded.contains(section);
final children = expanded ? _configSectionChildren(tokens, config, section) : const <Widget>[];
return ClideAccordion(
label: _configSectionLabel(section),
count: _configSectionCount(config, section),
expanded: expanded,
onToggle: () => setState(() {
if (expanded) {
_expanded.remove(section);
} else {
_expanded.add(section);
}
}),
children: children,
);
}
List<Widget> _configSectionChildren(SurfaceTokens tokens, ClaudeConfig config, _ConfigSection section) {
switch (section) {
case _ConfigSection.skills:
return [
for (final skill in config.skills) _configFileRow(tokens, skill.name, skill.path),
];
case _ConfigSection.agents:
return [
for (final agent in config.agents) _configFileRow(tokens, agent.name, agent.path),
];
case _ConfigSection.commands:
return [
for (final cmd in config.commands) _configFileRow(tokens, cmd.name, cmd.path),
];
case _ConfigSection.hooks:
return [
for (final hook in config.hooks)
Padding(
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(hook.event, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
for (final cmd in hook.commands)
Padding(
padding: const EdgeInsets.only(left: 8, top: 1),
child: ClideText(cmd, fontSize: clideFontSmall, muted: true),
),
],
),
),
];
case _ConfigSection.permissions:
return _permissionRows(tokens, config.permissions);
case _ConfigSection.mcpServers:
return [
for (final srv in config.mcpServers)
Padding(
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
child: ClideText(srv.name, fontSize: clideFontSmall, color: tokens.globalForeground),
),
];
}
}
/// A tappable row for file-backed items (skills, agents, commands).
/// Fires `editor.open` with the path when tapped (D-6, T-183).
Widget _configFileRow(SurfaceTokens tokens, String name, String? path) {
final row = Padding(
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
child: ClideText(
name,
fontSize: clideFontSmall,
color: path != null ? tokens.globalFocus : tokens.globalForeground,
),
);
if (path == null) return row;
return Semantics(
button: true,
label: name,
excludeSemantics: true,
onTap: () => unawaited(ClideKernel.of(context).ipc.request('editor.open', args: {'path': path})),
child: ClideTappable(
tooltip: path,
onTap: () => unawaited(ClideKernel.of(context).ipc.request('editor.open', args: {'path': path})),
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
child: ClideText(
name,
fontSize: clideFontSmall,
color: hovered ? tokens.globalForeground : tokens.globalFocus,
),
),
),
);
}
/// Renders grouped allow/ask/deny permission rows, colour-coded by kind.
List<Widget> _permissionRows(SurfaceTokens tokens, ClaudePermissions perms) {
const kindAllow = 'allow';
const kindAsk = 'ask';
const kindDeny = 'deny';
// allow → statusSuccess, ask → statusWarning, deny → statusError
Color kindColor(_ConfigPermKind k) => switch (k) {
_ConfigPermKind.allow => tokens.statusSuccess,
_ConfigPermKind.ask => tokens.statusWarning,
_ConfigPermKind.deny => tokens.statusError,
};
String kindLabel(_ConfigPermKind k) => switch (k) {
_ConfigPermKind.allow => kindAllow,
_ConfigPermKind.ask => kindAsk,
_ConfigPermKind.deny => kindDeny,
};
final groups = [
(_ConfigPermKind.allow, perms.allow),
(_ConfigPermKind.ask, perms.ask),
(_ConfigPermKind.deny, perms.deny),
];
final rows = <Widget>[];
for (final (kind, rules) in groups) {
if (rules.isEmpty) continue;
final color = kindColor(kind);
rows.add(
Padding(
padding: const EdgeInsets.only(left: 16, top: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 36,
child: ClideText(kindLabel(kind), fontSize: clideFontSmall, color: color),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final rule in rules)
Padding(
padding: const EdgeInsets.only(bottom: 1),
child: ClideText(rule, fontSize: clideFontSmall, color: tokens.globalForeground),
),
],
),
),
],
),
),
);
}
return rows;
}
// --- Shared rendering -----------------------------------------------------
@@ -448,6 +670,12 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
}
}
// T-183: accordion sections for the Config tab.
enum _ConfigSection { skills, agents, commands, hooks, permissions, mcpServers }
/// Permission kind for colour-coding in the Config tab (T-183).
enum _ConfigPermKind { allow, ask, deny }
class _MetaSection {
const _MetaSection(this.header, this.rows);
final String header;