diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 34a3c175..0a272489 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index c61e19a5..d6fce986 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -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'', , ''--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); diff --git a/CHANGELOG.md b/CHANGELOG.md index b8a4f183..7a693d0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/builtin/claude/src/claude_config.dart b/lib/builtin/claude/src/claude_config.dart index 47dc2893..7e2d9218 100644 --- a/lib/builtin/claude/src/claude_config.dart +++ b/lib/builtin/claude/src/claude_config.dart @@ -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 `/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 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 _skills = const []; List _commands = const []; + List _agents = const []; + List _hooks = const []; + List _mcpServers = const []; Map _settings = const {}; ClaudePermissions _permissions = const ClaudePermissions(); String? _error; @@ -199,6 +235,16 @@ class ClaudeConfig extends ChangeNotifier { List get skills => _skills; List get commands => _commands; + + /// Agent definitions scanned from `/agents/*.md` (T-183, D-76). + List get agents => _agents; + + /// Hook entries parsed from `settings['hooks']` (T-183). + List get hooks => _hooks; + + /// MCP server names from `settings['mcpServers']` (T-183). + List get mcpServers => _mcpServers; + Map get settings => Map.unmodifiable(_settings); ClaudePermissions get permissions => _permissions; @@ -326,12 +372,14 @@ class ClaudeConfig extends ChangeNotifier { Future _loadDiskConfig() async { final skills = []; final commands = []; + final agents = []; final settings = {}; final allow = [], deny = [], ask = []; 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 `/agents/*.md` for agent definitions (T-183, D-76). + Future> _loadAgents(Directory scopeDir, ConfigScope scope) async { + final dir = Directory('${scopeDir.path}/agents'); + if (!await dir.exists()) return const []; + final out = []; + 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>` + /// keyed on event name (e.g. `"PreToolUse"`). We flatten each event's + /// command list into a single [ClaudeHook]. + static List _parseHooks(Object? raw) { + if (raw is! Map) return const []; + final out = []; + for (final entry in raw.entries) { + final event = '${entry.key}'; + final cmds = []; + // 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` keyed on server name. + static List _parseMcpServers(Object? raw) { + if (raw is! Map) return const []; + return [ + for (final key in raw.keys) ClaudeMcpServer(name: '$key'), + ]; + } + static List _dedupeByName(List all, String Function(T) nameOf) { final byName = {}; for (final item in all) { diff --git a/lib/builtin/claude/src/claude_meta_sidebar.dart b/lib/builtin/claude/src/claude_meta_sidebar.dart index b48da93a..c4a112c8 100644 --- a/lib/builtin/claude/src/claude_meta_sidebar.dart +++ b/lib/builtin/claude/src/claude_meta_sidebar.dart @@ -96,6 +96,9 @@ class _ClaudeMetaSidebarState extends State { 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 { 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 { 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 = [ + // 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 []; + 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 _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 _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 = []; + 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 { } } +// 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; diff --git a/test/builtin/claude/claude_config_test.dart b/test/builtin/claude/claude_config_test.dart index 12dc1e0c..926ff9ca 100644 --- a/test/builtin/claude/claude_config_test.dart +++ b/test/builtin/claude/claude_config_test.dart @@ -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 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 /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(); + }); } diff --git a/test/builtin/claude/claude_meta_sidebar_test.dart b/test/builtin/claude/claude_meta_sidebar_test.dart index f24b3c7f..7eb7421b 100644 --- a/test/builtin/claude/claude_meta_sidebar_test.dart +++ b/test/builtin/claude/claude_meta_sidebar_test.dart @@ -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 loadedConfig( + WidgetTester tester, + Directory root, { + List<({String name, String dir})> skills = const [], + List commands = const [], + List agents = const [], + Map 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.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 = []; + 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 = []; + 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 = []; + 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); + }); + }); }