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
@@ -2294,3 +2294,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-172', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 21:25:12', '2026-05-30 21:25:12', '2026-05-30 21:25:12', NULL, 'e42556ec9d67731533b85e6da2a1bc9c', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-172', 'status', 'in_progress', 'done', NULL, '2026-05-30 21:34:56', '2026-05-30 21:34:56', '2026-05-30 21:34:56', NULL, 'f97c3bb01ea2bc5f969b34d4de09617a', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-180', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 21:34:57', '2026-05-30 21:34:57', '2026-05-30 21:34:57', NULL, 'd73e085a28c3c4b2a7ce515d33012de4', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-180', 'status', 'in_progress', 'done', NULL, '2026-05-31 08:49:07', '2026-05-31 08:49:07', '2026-05-31 08:49:07', NULL, '7a3ab42f4a4571c00dbb55b2079e1b9b', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-183', 'status', 'backlog', 'in_progress', NULL, '2026-05-31 08:49:08', '2026-05-31 08:49:08', '2026-05-31 08:49:08', NULL, 'e7e9fd59ba8adf45431b759b693d4022', 1) ON CONFLICT(hash) DO NOTHING;
+2
View File
@@ -2872,3 +2872,5 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
---
Refinement (2026-05-30, /whats-next): --fork-session CONFIRMED against the live binary (claude --help: "When resuming, create a new session ID instead of reusing the original (use with --resume or --continue)"). Fork argv = [''--resume'', <sourceSessionId>, ''--fork-session''] (no --session-id; the new branch gets its own id from the init event). Add forkSourceSessionId to SpawnSpec (session_orchestrator.dart); build argv via a forkSessionArgs helper in session_naming.dart; add ''fork'' to kClideOwnedCommands + a case in claude_pane._send; add a fork button to _memberRow in claude_meta_sidebar.dart. SEQUENCE AFTER T-171 (shares _memberRow + orchestrator).', 'done', 'low', NULL, NULL, 'D-77', '2026-05-24 16:27:16', '2026-05-30 21:34:56', NULL, '8e8e43c735d6376e3eb1531a4f682b52', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-180', 'task', 'T-164', 'Team message inbox — broker chat (sidebar widget + poppable pane)', 'A team chat / message inbox: render the clide broker traffic as a chat-client timeline and make the user a first-class participant. (1) Timeline: every inter-agent message (send_message/broadcast) shown as chat rows with a colour-coded sender chip (tyre/gestalt/lead/you/...). (2) User as a broker participant: extend the T-170 broker so send_message(to: user) delivers into the inbox, and the team-awareness roster injected into each agent lists "you" as addressable; the user can post into the channel. (3) @-routing from the user: a recipient picker / @name parses to a broker route, @team (or no tag) broadcasts; reuse the slash-typeahead machinery for @-completion. (4) Interrupt tickbox on the composer: unchecked queues the message into the target next turn (normal broker delivery); checked sends an interrupt control_request to the target session (cancels its current turn) then delivers; default OFF. (5) One chat model, two surfaces: a compact sidebar widget in the cockpit (live feed + quick @-post) that pops out into a full chat pane. Wireframes: docs/design/wireframes/claude-prompts/05-team-cockpit-sidebar.png (widget) + 06-team-chat-pane.png (pane). Acceptance: broker messages render as chat; user posts route by @tag (one agent) or broadcast; interrupt tickbox cancels+delivers vs queues; widget and pane share one model; tests for routing + interrupt + render. Resolves the user-participation gaps from the team-mode screen analysis. Blocked by T-170 (broker).', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-25 09:15:58', '2026-05-30 21:34:57', NULL, 'caa8f38a5ae02650d2b3552a00ab796e', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-180', 'task', 'T-164', 'Team message inbox — broker chat (sidebar widget + poppable pane)', 'A team chat / message inbox: render the clide broker traffic as a chat-client timeline and make the user a first-class participant. (1) Timeline: every inter-agent message (send_message/broadcast) shown as chat rows with a colour-coded sender chip (tyre/gestalt/lead/you/...). (2) User as a broker participant: extend the T-170 broker so send_message(to: user) delivers into the inbox, and the team-awareness roster injected into each agent lists "you" as addressable; the user can post into the channel. (3) @-routing from the user: a recipient picker / @name parses to a broker route, @team (or no tag) broadcasts; reuse the slash-typeahead machinery for @-completion. (4) Interrupt tickbox on the composer: unchecked queues the message into the target next turn (normal broker delivery); checked sends an interrupt control_request to the target session (cancels its current turn) then delivers; default OFF. (5) One chat model, two surfaces: a compact sidebar widget in the cockpit (live feed + quick @-post) that pops out into a full chat pane. Wireframes: docs/design/wireframes/claude-prompts/05-team-cockpit-sidebar.png (widget) + 06-team-chat-pane.png (pane). Acceptance: broker messages render as chat; user posts route by @tag (one agent) or broadcast; interrupt tickbox cancels+delivers vs queues; widget and pane share one model; tests for routing + interrupt + render. Resolves the user-participation gaps from the team-mode screen analysis. Blocked by T-170 (broker).', 'done', 'medium', NULL, NULL, 'D-77', '2026-05-25 09:15:58', '2026-05-31 08:49:07', NULL, 'b81c7bf1be527e38ef37925f2debd1c5', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-183', 'task', 'T-164', 'Claude Config sidebar tab — skills/agents/commands/settings/permissions/hooks/MCP', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered, local wins) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Layout: SETTINGS is a FIXED key->value table pinned at the top (not collapsible) — model, output style, permission mode, source. Everything variable-length is an EXPANDABLE accordion section showing the FULL list when expanded, never truncated (truncating to first-N + ellipsis falsely prioritises the first entries and makes the list pointless): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS (grouped allow/ask/deny, colour-coded, each rule on its own line), MCP SERVERS — each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what it says. Watched/live on .claude changes. Acceptance: settings render as a top table; every variable section expands to its complete list (no ellipsis); clicking a file-backed item opens its .md in the reader; permissions colour-coded by kind; widget tests for table + expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', 'in_progress', 'medium', NULL, NULL, 'D-76', '2026-05-25 09:26:40', '2026-05-31 08:49:08', NULL, '4c86cfc6e81700b9b94df5420705d849', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+4
View File
@@ -79,6 +79,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
sender chips), in a compact cockpit widget that pops out to a full pane. The
user is a first-class participant: post with `@name` routing (or broadcast),
with an interrupt tickbox that cancels the target's turn before delivery. (T-180, D-77)
- Config sidebar tab — a pinned settings table plus expandable, never-truncated
sections for skills / agents / commands / hooks / permissions (colour-coded by
kind) / MCP servers; file-backed entries open their `.md` in the reader. ClaudeConfig
now also surfaces agents, hooks, MCP servers, and file paths. (T-183, D-76)
- Team coordination broker (T-170, D-77) — clide hosts an in-process MCP
server (`clide-team`) for managed sessions over the stream-json control
channel, giving agents tools to message each other, broadcast, see the
+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;
@@ -294,4 +294,91 @@ void main() {
expect(versionCalls, 1, reason: 'refresh is disk-only');
c.dispose();
});
// T-183: path fields, agents, hooks, MCP servers ------------------------------
Future<void> writeAgent(Directory scope, String fileName) async {
final d = Directory('${scope.path}/agents')..createSync(recursive: true);
await File('${d.path}/$fileName').writeAsString('# agent');
}
test('skill and command entries carry their absolute file path (T-183)', () async {
await writeSkill(globalDir, 'my-skill', name: 'my-skill');
await writeCommand(globalDir, 'deploy.md');
final c = build();
await c.load();
final skill = c.skills.first;
expect(skill.path, isNotNull);
expect(skill.path, endsWith('my-skill/SKILL.md'));
final cmd = c.commands.first;
expect(cmd.path, isNotNull);
expect(cmd.path, endsWith('deploy.md'));
c.dispose();
});
test('agents scanned from <scope>/agents/*.md (T-183)', () async {
await writeAgent(globalDir, 'planner.md');
await writeAgent(localDir, 'coder.md');
final c = build();
await c.load();
expect(c.agents.map((a) => a.name), containsAll(['planner', 'coder']));
final planner = c.agents.firstWhere((a) => a.name == 'planner');
expect(planner.scope, ConfigScope.global);
expect(planner.path, isNotNull);
expect(planner.path, endsWith('planner.md'));
c.dispose();
});
test('agents deduplicated local-over-global same as skills (T-183)', () async {
await writeAgent(globalDir, 'shared.md');
await writeAgent(localDir, 'shared.md');
final c = build();
await c.load();
expect(c.agents.where((a) => a.name == 'shared'), hasLength(1));
final shared = c.agents.firstWhere((a) => a.name == 'shared');
expect(shared.scope, ConfigScope.local, reason: 'local wins on collision');
c.dispose();
});
test('hooks parsed from settings.json hooks key (T-183)', () async {
await writeSettings(globalDir, {
'hooks': {
'PreToolUse': [
{
'hooks': [
{'command': 'echo pre-tool'},
],
},
],
},
});
final c = build();
await c.load();
expect(c.hooks, hasLength(1));
expect(c.hooks.first.event, 'PreToolUse');
expect(c.hooks.first.commands, ['echo pre-tool']);
c.dispose();
});
test('mcpServers parsed from settings.json mcpServers key (T-183)', () async {
await writeSettings(globalDir, {
'mcpServers': {'brave': {}, 'clide': {}},
});
final c = build();
await c.load();
expect(c.mcpServers.map((s) => s.name), containsAll(['brave', 'clide']));
c.dispose();
});
test('empty settings produce empty hooks and mcpServers (T-183)', () async {
final c = build();
await c.load();
expect(c.agents, isEmpty);
expect(c.hooks, isEmpty);
expect(c.mcpServers, isEmpty);
c.dispose();
});
}
@@ -9,6 +9,7 @@ import 'package:clide/builtin/claude/src/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/clide.dart' show IpcResponse;
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/services.dart' show LogicalKeyboardKey;
import 'package:flutter/widgets.dart' show EditableText, SizedBox, Semantics;
@@ -705,4 +706,382 @@ void main() {
orch.dispose();
});
});
// T-183: Config tab accordion browser ----------------------------------------
group('T-183 Config accordion browser', () {
/// Build a fully-loaded [ClaudeConfig] via [tester.runAsync] so real I/O
/// and async work run outside FakeAsync. Returns a config whose load() has
/// completed; caller must call dispose() (via addTearDown).
Future<ClaudeConfig> loadedConfig(
WidgetTester tester,
Directory root, {
List<({String name, String dir})> skills = const [],
List<String> commands = const [],
List<String> agents = const [],
Map<String, Object?> settings = const {},
}) async {
final result = await tester.runAsync(() async {
final globalDir = Directory('${root.path}/global')..createSync(recursive: true);
final cacheDir = Directory('${root.path}/cache')..createSync(recursive: true);
for (final s in skills) {
final d = Directory('${globalDir.path}/skills/${s.dir}')..createSync(recursive: true);
await File('${d.path}/SKILL.md').writeAsString('---\nname: ${s.name}\n---\nbody');
}
for (final cmd in commands) {
final d = Directory('${globalDir.path}/commands')..createSync(recursive: true);
await File('${d.path}/$cmd.md').writeAsString('# $cmd');
}
for (final agent in agents) {
final d = Directory('${globalDir.path}/agents')..createSync(recursive: true);
await File('${d.path}/$agent.md').writeAsString('# $agent');
}
if (settings.isNotEmpty) {
await File('${globalDir.path}/settings.json').writeAsString(jsonEncode(settings));
}
final c = ClaudeConfig(
globalDir: globalDir,
cacheDir: cacheDir,
versionRunner: () async => '2.1.0 (Claude Code)\n',
initProbe: () async => null,
watch: (_) => const Stream<void>.empty(),
debounce: Duration.zero,
);
await c.load();
return c;
});
// tester.runAsync returns T? — the result is non-null here because the
// body always returns successfully.
return result!;
}
testWidgets('pinned SETTINGS table renders with model / output style / permission mode / source', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_settings');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(tester, dir);
addTearDown(config.dispose);
await tester.pumpWidget(harness(f, sidebar(config: config, initialTab: SidebarTab.config)));
await tester.pump();
await tester.pump();
expect(find.text('SETTINGS'), findsOneWidget);
expect(find.text('model'), findsOneWidget);
expect(find.text('output style'), findsOneWidget);
expect(find.text('permission mode'), findsOneWidget);
expect(find.text('source'), findsOneWidget);
expect(find.text('~/.claude + .claude'), findsOneWidget);
});
testWidgets('accordion sections appear for each kind with correct counts', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_counts');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(
tester,
dir,
skills: [
(name: 'git-commit', dir: 'git-commit'),
(name: 'pql', dir: 'pql'),
],
commands: ['deploy', 'test'],
agents: ['planner'],
settings: {
'mcpServers': {'brave': {}, 'clide': {}},
'permissions': {
'allow': ['Bash(git *)'],
'deny': ['Bash(rm -rf *)'],
},
},
);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pump();
await tester.pump();
// Section headers with counts are rendered by ClideAccordion as "$label · $count".
expect(find.text('SKILLS · 2'), findsOneWidget);
expect(find.text('AGENTS · 1'), findsOneWidget);
expect(find.text('COMMANDS · 2'), findsOneWidget);
expect(find.text('PERMISSIONS · 2'), findsOneWidget);
expect(find.text('MCP SERVERS · 2'), findsOneWidget);
});
testWidgets('expanding SKILLS section shows all skill names (no ellipsis)', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_skills');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(
tester,
dir,
skills: [
(name: 'git-commit', dir: 'git-commit'),
(name: 'pql', dir: 'pql'),
(name: 'deep-research', dir: 'deep-research'),
],
);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pump();
await tester.pump();
// Skills are hidden until expanded.
expect(find.text('git-commit'), findsNothing);
// Tap the SKILLS accordion header.
await tester.tap(find.text('SKILLS · 3'));
await tester.pump();
// All three skills render — no truncation.
expect(find.text('git-commit'), findsOneWidget);
expect(find.text('pql'), findsOneWidget);
expect(find.text('deep-research'), findsOneWidget);
});
testWidgets('expanding AGENTS section shows all agent names', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_agents');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(tester, dir, agents: ['planner', 'coder']);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pump();
await tester.pump();
await tester.tap(find.text('AGENTS · 2'));
await tester.pump();
expect(find.text('planner'), findsOneWidget);
expect(find.text('coder'), findsOneWidget);
});
testWidgets('expanding COMMANDS section shows all command names', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_cmds');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(tester, dir, commands: ['deploy', 'test', 'lint']);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pump();
await tester.pump();
await tester.tap(find.text('COMMANDS · 3'));
await tester.pump();
expect(find.text('deploy'), findsOneWidget);
expect(find.text('test'), findsOneWidget);
expect(find.text('lint'), findsOneWidget);
});
testWidgets('expanding PERMISSIONS shows rules grouped by allow / ask / deny', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_perms');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(
tester,
dir,
settings: {
'permissions': {
'allow': ['Bash(git *)'],
'ask': ['Write'],
'deny': ['Bash(rm -rf *)'],
},
},
);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pump();
await tester.pump();
await tester.tap(find.text('PERMISSIONS · 3'));
await tester.pump();
// Group labels and rules should all appear.
expect(find.text('allow'), findsOneWidget);
expect(find.text('ask'), findsOneWidget);
expect(find.text('deny'), findsOneWidget);
expect(find.text('Bash(git *)'), findsOneWidget);
expect(find.text('Write'), findsOneWidget);
expect(find.text('Bash(rm -rf *)'), findsOneWidget);
});
testWidgets('expanding MCP SERVERS shows all server names', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_mcp');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(
tester,
dir,
settings: {
'mcpServers': {'brave': {}, 'clide': {}, 'github': {}},
},
);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pump();
await tester.pump();
await tester.tap(find.text('MCP SERVERS · 3'));
await tester.pump();
expect(find.text('brave'), findsOneWidget);
expect(find.text('clide'), findsOneWidget);
expect(find.text('github'), findsOneWidget);
});
testWidgets('tapping a file-backed skill fires editor.open with its path', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_click');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(tester, dir, skills: [(name: 'my-skill', dir: 'my-skill')]);
addTearDown(config.dispose);
// Capture editor.open calls through the fake IPC.
final opened = <String>[];
f.ipc.stub('editor.open', (args) async {
opened.add(args['path'] as String? ?? '');
return IpcResponse.ok(id: '', data: const {});
});
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pump();
await tester.pump();
// Expand SKILLS, then tap the skill row.
await tester.tap(find.text('SKILLS · 1'));
await tester.pump();
// The skill name is the tappable label.
await tester.tap(find.text('my-skill'));
await tester.pump();
expect(opened, hasLength(1));
expect(opened.first, endsWith('my-skill/SKILL.md'));
});
testWidgets('tapping a file-backed command fires editor.open with its path', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_cmd_click');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(tester, dir, commands: ['deploy']);
addTearDown(config.dispose);
final opened = <String>[];
f.ipc.stub('editor.open', (args) async {
opened.add(args['path'] as String? ?? '');
return IpcResponse.ok(id: '', data: const {});
});
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pump();
await tester.pump();
await tester.tap(find.text('COMMANDS · 1'));
await tester.pump();
await tester.tap(find.text('deploy'));
await tester.pump();
expect(opened, hasLength(1));
expect(opened.first, endsWith('deploy.md'));
});
testWidgets('tapping a file-backed agent fires editor.open with its path', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_agent_click');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(tester, dir, agents: ['planner']);
addTearDown(config.dispose);
final opened = <String>[];
f.ipc.stub('editor.open', (args) async {
opened.add(args['path'] as String? ?? '');
return IpcResponse.ok(id: '', data: const {});
});
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pump();
await tester.pump();
await tester.tap(find.text('AGENTS · 1'));
await tester.pump();
await tester.tap(find.text('planner'));
await tester.pump();
expect(opened, hasLength(1));
expect(opened.first, endsWith('planner.md'));
});
testWidgets('accordion collapses when toggled a second time', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_collapse');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(tester, dir, skills: [(name: 'git-commit', dir: 'git-commit')]);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pump();
await tester.pump();
// Expand.
await tester.tap(find.text('SKILLS · 1'));
await tester.pump();
expect(find.text('git-commit'), findsOneWidget);
// Collapse.
await tester.tap(find.text('SKILLS · 1'));
await tester.pump();
expect(find.text('git-commit'), findsNothing);
});
testWidgets('Config tab with empty sections shows zero counts on all headers', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_empty');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(tester, dir);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pump();
await tester.pump();
expect(find.text('SKILLS · 0'), findsOneWidget);
expect(find.text('AGENTS · 0'), findsOneWidget);
expect(find.text('COMMANDS · 0'), findsOneWidget);
expect(find.text('HOOKS · 0'), findsOneWidget);
expect(find.text('PERMISSIONS · 0'), findsOneWidget);
expect(find.text('MCP SERVERS · 0'), findsOneWidget);
});
});
}