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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user