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