From 2888efbe5ff3ad8e7841e0bb37adbe8d97ba98fa Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 30 May 2026 23:25:07 +0200 Subject: [PATCH] add per-agent permission-mode badge to the cockpit roster Each roster row shows a D/A/P mode badge reflecting the session's live permission mode. Click cycles the safe trio default -> acceptEdits -> plan and sends a set_permission_mode control_request to that session (mirrors interrupt(); fire-and-forget). bypassPermissions is a footgun, so it is reachable only on Shift-click and behind an inline confirm. A clide.agent.set-permission-mode command gives the CLI parity. T-181. Co-Authored-By: Claude --- .pql/changelog/ticket_history/2026-05.sql | 3 + .pql/changelog/tickets/2026-05.sql | 7 + CHANGELOG.md | 4 + .../claude/src/claude_meta_sidebar.dart | 221 ++++++++++++++++-- lib/builtin/claude/src/extension.dart | 22 ++ .../claude/src/stream_json_session.dart | 16 ++ .../claude/claude_meta_sidebar_test.dart | 178 +++++++++++++- .../claude/stream_json_session_test.dart | 20 ++ 8 files changed, 451 insertions(+), 20 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 6791ebb7..e9c1863d 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2287,3 +2287,6 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, 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).', NULL, '2026-05-30 17:12:17', '2026-05-30 17:12:17', '2026-05-30 17:12:17', NULL, '901eb461070872a8dcb61f21546382d6', 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-171', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 17:12:46', '2026-05-30 17:12:46', '2026-05-30 17:12:46', NULL, '04314327ba504ef372a338ed737650a8', 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-174', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 17:12:46', '2026-05-30 17:12:46', '2026-05-30 17:12:46', NULL, '940531017064b45bd962a8fe7369ec6d', 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-171', 'status', 'in_progress', 'done', NULL, '2026-05-30 21:11:26', '2026-05-30 21:11:26', '2026-05-30 21:11:26', NULL, '26cb27e63ad95ae94017ada95daef9d2', 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-174', 'status', 'in_progress', 'done', NULL, '2026-05-30 21:11:26', '2026-05-30 21:11:26', '2026-05-30 21:11:26', NULL, '4df20d29d49e7dd95dd9caafbe956cb2', 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-181', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 21:14:56', '2026-05-30 21:14:56', '2026-05-30 21:14:56', NULL, '6426dc0355b68f0fd572e52d53101fc4', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 4d02bff9..fb654069 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2854,3 +2854,10 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, Make the actions always reachable per T-173''s acceptance: keep them in the tree always (reveal visually on hover OR focus via opacity), add Semantics onTap so assistive tech can activate them, and wire keyboard focus traversal + Enter/Space activation through clide''s keyboard-operability framework (the _tap helper uses a bare GestureDetector today, which is mouse/AT-tap only, not focus-traversable). Applies to both the action buttons and the caret. Acceptance: copy + custom actions are reachable and activatable by keyboard alone (Tab to focus, Enter/Space to invoke) with no mouse; Semantics button labels present; widget test drives a copy via focus+activation (no hover); a11y gate green.', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-24 17:11:17', '2026-05-30 17:12:46', NULL, '5294925aae67ed738dd8f2096469f56c', 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-174', 'task', 'T-164', 'ConversationCard actions: keyboard-focusable + always-reachable (not hover-only)', 'Follow-up split out of T-173. The ConversationCard action bar (copy button + custom MessageActions) currently renders only while the card is hovered (lib/builtin/claude/src/conversation_card.dart, _header: ''if (_hover) ..._actions''). The collapse/expand caret is always visible, but the action buttons are not keyboard/AT reachable when not hovering. + +Make the actions always reachable per T-173''s acceptance: keep them in the tree always (reveal visually on hover OR focus via opacity), add Semantics onTap so assistive tech can activate them, and wire keyboard focus traversal + Enter/Space activation through clide''s keyboard-operability framework (the _tap helper uses a bare GestureDetector today, which is mouse/AT-tap only, not focus-traversable). Applies to both the action buttons and the caret. + +Acceptance: copy + custom actions are reachable and activatable by keyboard alone (Tab to focus, Enter/Space to invoke) with no mouse; Semantics button labels present; widget test drives a copy via focus+activation (no hover); a11y gate green.', 'done', 'medium', NULL, NULL, 'D-77', '2026-05-24 17:11:17', '2026-05-30 21:11:26', NULL, '2eb96628ffebd214dca72ffcd5f5da69', 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-171', 'task', 'T-164', 'P2: sidebar cockpit — task list + messages + roster controls', 'Evolve the Claude meta sidebar (T-141/T-157) from a read-only roster into the CONTROL surface for clide-managed agents. Because clide owns the broker, the task list and inter-agent messages are local data — so the sidebar both shows them live and lets the user ACT: spawn / show / hide / mute an agent, reassign a task, inject or redirect a message. Roster rows keep the live per-member status (T-157) but gain controls. Acceptance: from the sidebar the user can spawn/show/hide an agent, watch the shared task list update, see the message flow, and intervene (reassign/inject); widget tests for the controls wired to the orchestrator + broker. Blocked by T-169 (orchestrator) and T-170 (broker).', 'done', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:27:09', '2026-05-30 21:11:26', NULL, '7d186872f15ef055161e51f2ae3f7479', 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-181', 'task', 'T-164', 'Per-agent permission-mode cycle badge in the cockpit roster', 'Per-agent permission-mode control in the cockpit roster (extends T-171). Each roster row shows a mode badge (D default / A acceptEdits / P plan). Click cycles the SAFE trio default -> acceptEdits -> plan and sends set_permission_mode to that agent session (a confirmed stream-json control subtype). bypassPermissions is a footgun, so it is reachable only on SHIFT-click (behind a confirm), and the tooltip documents both behaviours. Acceptance: clicking the badge cycles the safe modes and the session receives set_permission_mode; shift-click can reach bypass behind a confirm; the badge reflects the live mode (T-157 status); tooltip explains click vs shift-click; widget + transport tests. Wireframe: docs/design/wireframes/claude-prompts/05-team-cockpit-sidebar.png. Blocked by T-169 (orchestrator).', 'in_progress', 'low', NULL, NULL, 'D-77', '2026-05-25 09:16:05', '2026-05-30 21:14:56', NULL, '3e4c5116d77bfc451e05eb54d208e83d', 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 c85bebea..c5b8558f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. clide-managed agents: show/hide, mute, close, and inject-a-message per roster row, plus a live shared task list with reassign. Each action has a matching `clide` command (D-6 parity). (T-171, D-77) +- Per-agent permission-mode badge in the cockpit roster — click cycles the + safe trio default → acceptEdits → plan and sends `set_permission_mode` to + that session; Shift-click reaches `bypassPermissions` behind a confirm. + The badge reflects the live mode. (T-181, D-77) - 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_meta_sidebar.dart b/lib/builtin/claude/src/claude_meta_sidebar.dart index 4fc5f053..69d8a837 100644 --- a/lib/builtin/claude/src/claude_meta_sidebar.dart +++ b/lib/builtin/claude/src/claude_meta_sidebar.dart @@ -34,6 +34,7 @@ import 'package:clide/builtin/claude/src/transcript_publisher.dart' show ClaudeC import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus; import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/services.dart' show HardwareKeyboard; import 'package:flutter/widgets.dart'; /// The shared label-column width + row pitch the Activity and Config tables both @@ -313,6 +314,10 @@ class _ClaudeMetaSidebarState extends State { final managed = _orchestrator?.byMemberName(name); if (managed != null) _orchestrator!.close(managed.id); }, + onSetPermissionMode: (name, mode) { + final managed = _orchestrator?.byMemberName(name); + managed?.session.setPermissionMode(mode); + }, ), ]; @@ -440,15 +445,15 @@ class _MetaRow { /// A single agent roster row: color dot + name + status sub-text + controls. /// /// Controls (trailing region): +/// - permission-mode badge (T-181) — D/A/P cycles the safe trio; shift-click +/// reaches bypassPermissions behind a confirm /// - eye / eye-slash — show / hide the session pane /// - speaker / speaker-slash — mute / unmute broker delivery /// - inject (chat icon) — expand the inline message input /// - close (×) — kill the session /// -/// Seam for T-181: add a permission-mode badge between the status sub-text and -/// the trailing controls — it needs no layout changes here. /// Seam for T-172: add a fork button to the _buildControls row. -class _AgentRosterRow extends StatelessWidget { +class _AgentRosterRow extends StatefulWidget { const _AgentRosterRow({ super.key, required this.member, @@ -459,6 +464,7 @@ class _AgentRosterRow extends StatelessWidget { required this.onToggleInject, required this.onInjectSubmit, required this.onClose, + required this.onSetPermissionMode, }); final TeamMemberJoined member; @@ -475,15 +481,28 @@ class _AgentRosterRow extends StatelessWidget { final void Function(String memberName, String text) onInjectSubmit; final void Function(String memberName) onClose; + /// Called when the badge cycles to a new [mode] string for this member. + /// Handles both safe-trio clicks and confirmed bypass. The parent sends + /// the mode to the session via [StreamJsonSession.setPermissionMode]. + final void Function(String memberName, String mode) onSetPermissionMode; + + @override + State<_AgentRosterRow> createState() => _AgentRosterRowState(); +} + +class _AgentRosterRowState extends State<_AgentRosterRow> { + /// Whether the bypass-confirm inline prompt is showing. + bool _confirmingBypass = false; + @override Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; - final managed = orchestrator?.byMemberName(member.name); - final color = teamColor(member.color, fallback: tokens.globalForeground); - final st = status; - final model = st?.model ?? member.model; + final managed = widget.orchestrator?.byMemberName(widget.member.name); + final color = teamColor(widget.member.color, fallback: tokens.globalForeground); + final st = widget.status; + final model = st?.model ?? widget.member.model; final sub = [ - member.agentType, + widget.member.agentType, if (model != null) shortModelLabel(model), if (st?.permissionMode != null) permissionModeLabel(st!.permissionMode!), if (st?.contextTokens != null) '${formatTokenCount(st!.contextTokens!)} ctx', @@ -491,7 +510,8 @@ class _AgentRosterRow extends StatelessWidget { final isVisible = managed?.visible ?? true; final isMuted = managed?.muted ?? false; - final isInjecting = injectingAgentId == member.name; + final isInjecting = widget.injectingAgentId == widget.member.name; + final currentMode = st?.permissionMode ?? 'default'; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), @@ -512,9 +532,19 @@ class _AgentRosterRow extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClideText(member.name, fontSize: clideFontSmall, color: tokens.globalForeground, maxLines: 1, overflow: TextOverflow.ellipsis), + ClideText(widget.member.name, fontSize: clideFontSmall, color: tokens.globalForeground, maxLines: 1, overflow: TextOverflow.ellipsis), if (sub.isNotEmpty) ClideText(sub, muted: true, fontSize: clideFontSmall, maxLines: 1, overflow: TextOverflow.ellipsis), - // T-181 seam: add permission-mode badge here (no layout surgery needed). + // T-181: permission-mode badge (inline below the status sub-text). + if (managed != null) + _PermissionModeBadge( + mode: currentMode, + tokens: tokens, + onCycle: () { + final next = _nextSafeMode(currentMode); + widget.onSetPermissionMode(widget.member.name, next); + }, + onBypass: () => setState(() => _confirmingBypass = true), + ), ], ), ), @@ -524,8 +554,72 @@ class _AgentRosterRow extends StatelessWidget { if (managed != null) _buildControls(context, tokens, managed, isVisible, isMuted, isInjecting), ], ), + // Bypass confirm: replaces inject field area when active. + if (_confirmingBypass) _buildBypassConfirm(tokens), // Inline inject-message field — visible only when toggled. - if (isInjecting) _buildInjectField(context, tokens), + if (isInjecting && !_confirmingBypass) _buildInjectField(context, tokens), + ], + ), + ); + } + + /// Safe-mode cycle: default → acceptEdits → plan → default (T-181). + static String _nextSafeMode(String current) { + const cycle = ['default', 'acceptEdits', 'plan']; + final idx = cycle.indexOf(current); + return cycle[(idx + 1) % cycle.length]; + } + + Widget _buildBypassConfirm(SurfaceTokens tokens) { + return Padding( + padding: const EdgeInsets.only(left: 16, top: 4), + child: Row( + children: [ + Expanded( + child: ClideText( + 'Enable bypassPermissions? All tool calls will be auto-allowed.', + fontSize: clideFontSmall, + color: tokens.globalTextMuted, + ), + ), + const SizedBox(width: 4), + // Confirm + Semantics( + button: true, + label: 'Confirm bypass', + excludeSemantics: true, + onTap: () { + setState(() => _confirmingBypass = false); + widget.onSetPermissionMode(widget.member.name, 'bypassPermissions'); + }, + child: ClideTappable( + tooltip: 'Confirm', + onTap: () { + setState(() => _confirmingBypass = false); + widget.onSetPermissionMode(widget.member.name, 'bypassPermissions'); + }, + builder: (ctx, hovered, _) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2), + child: ClideText('OK', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus), + ), + ), + ), + const SizedBox(width: 4), + // Cancel + Semantics( + button: true, + label: 'Cancel bypass', + excludeSemantics: true, + onTap: () => setState(() => _confirmingBypass = false), + child: ClideTappable( + tooltip: 'Cancel', + onTap: () => setState(() => _confirmingBypass = false), + builder: (ctx, hovered, _) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2), + child: ClideText('Cancel', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalTextMuted), + ), + ), + ), ], ), ); @@ -547,7 +641,7 @@ class _AgentRosterRow extends StatelessWidget { painter: isVisible ? PhosphorIcons.eye : PhosphorIcons.eyeSlash, tooltip: isVisible ? 'Hide pane' : 'Show pane', color: tokens.globalTextMuted, - onTap: () => isVisible ? orchestrator!.hide(managed.id) : orchestrator!.show(managed.id), + onTap: () => isVisible ? widget.orchestrator!.hide(managed.id) : widget.orchestrator!.show(managed.id), ), // Mute / unmute _IconButton( @@ -557,21 +651,21 @@ class _AgentRosterRow extends StatelessWidget { // The semantic tooltip still says mute/unmute so AT users are clear. tooltip: isMuted ? 'Unmute messages' : 'Mute messages', color: isMuted ? tokens.globalFocus : tokens.globalTextMuted, - onTap: () => isMuted ? orchestrator!.unmute(managed.id) : orchestrator!.mute(managed.id), + onTap: () => isMuted ? widget.orchestrator!.unmute(managed.id) : widget.orchestrator!.mute(managed.id), ), // Inject message _IconButton( painter: PhosphorIcons.chatCircle, tooltip: 'Inject message', color: isInjecting ? tokens.globalFocus : tokens.globalTextMuted, - onTap: () => onToggleInject(member.name), + onTap: () => widget.onToggleInject(widget.member.name), ), // Close session _IconButton( painter: PhosphorIcons.xMark, tooltip: 'Close session', color: tokens.globalTextMuted, - onTap: () => onClose(member.name), + onTap: () => widget.onClose(widget.member.name), ), ], ); @@ -584,10 +678,10 @@ class _AgentRosterRow extends StatelessWidget { children: [ Expanded( child: _InjectTextField( - controller: injectController, + controller: widget.injectController, tokens: tokens, onSubmit: (text) { - if (text.trim().isNotEmpty) onInjectSubmit(member.name, text.trim()); + if (text.trim().isNotEmpty) widget.onInjectSubmit(widget.member.name, text.trim()); }, ), ), @@ -596,7 +690,7 @@ class _AgentRosterRow extends StatelessWidget { painter: PhosphorIcons.xMark, tooltip: 'Cancel', color: tokens.globalTextMuted, - onTap: () => onToggleInject(member.name), + onTap: () => widget.onToggleInject(widget.member.name), ), ], ), @@ -604,6 +698,95 @@ class _AgentRosterRow extends StatelessWidget { } } +// --------------------------------------------------------------------------- +// Permission-mode badge (T-181) +// --------------------------------------------------------------------------- + +/// Maps a permission-mode string to a single-letter badge label. +String _permissionModeBadge(String mode) => switch (mode) { + 'acceptEdits' => 'A', + 'plan' => 'P', + 'bypassPermissions' => 'B', + _ => 'D', // default + }; + +/// Clickable permission-mode badge shown in each roster row (T-181). +/// +/// - Plain click → cycles the safe trio: default → acceptEdits → plan → default. +/// - Shift-click → shows the bypass confirm inline in the parent row. +/// +/// The badge reflects the LIVE mode from [SessionStatus.permissionMode] (T-157). +/// It is a custom painted label (no Material), consistent with the rendering +/// stack rules (D-7, CLAUDE.md guardrails). +class _PermissionModeBadge extends StatelessWidget { + const _PermissionModeBadge({ + required this.mode, + required this.tokens, + required this.onCycle, + required this.onBypass, + }); + + final String mode; + final SurfaceTokens tokens; + + /// Called on a plain click — the parent cycles to the next safe mode. + final VoidCallback onCycle; + + /// Called on a shift-click — the parent shows the bypass confirm. + final VoidCallback onBypass; + + @override + Widget build(BuildContext context) { + final label = _permissionModeBadge(mode); + final isBypass = mode == 'bypassPermissions'; + final badgeColor = isBypass ? const Color(0xFFF06C6F) : tokens.globalFocus; + + final tooltip = 'Permission mode: ${permissionModeLabel(mode)}. ' + 'Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions.'; + + return Padding( + padding: const EdgeInsets.only(top: 3), + child: Semantics( + button: true, + label: 'Permission mode: $label', + excludeSemantics: true, + onTap: () { + if (HardwareKeyboard.instance.isShiftPressed) { + onBypass(); + } else { + onCycle(); + } + }, + child: ClideTappable( + tooltip: tooltip, + onTap: () { + if (HardwareKeyboard.instance.isShiftPressed) { + onBypass(); + } else { + onCycle(); + } + }, + builder: (ctx, hovered, _) => Container( + width: 16, + height: 14, + alignment: Alignment.center, + decoration: BoxDecoration( + color: badgeColor.withAlpha(hovered ? 51 : 26), + borderRadius: BorderRadius.circular(2), + border: Border.all(color: badgeColor.withAlpha(hovered ? 180 : 100), width: 1), + ), + child: ClideText( + label, + fontSize: 9, + color: badgeColor, + ), + ), + ), + ), + ); + } +} + /// A single icon-button used in the roster row controls. class _IconButton extends StatelessWidget { const _IconButton({ diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index fd9b8d9c..62dafebf 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -139,6 +139,28 @@ class ClaudeExtension extends ClideExtension { return IpcResponse.ok(id: '', data: {'id': id, 'status': 'injected'}); }, ), + // T-181: set permission mode for an agent session (D-6 CLI/UI parity). + // Usage: clide claude.agent.set-permission-mode + // must be one of: default, acceptEdits, plan, bypassPermissions. + // Note: bypassPermissions is accepted via CLI — the footgun guard is the + // UI's confirm dialog; the CLI caller is responsible for their own safety. + CommandContribution( + id: 'claude.agent.set-permission-mode', + command: 'claude.agent.set-permission-mode', + title: 'Claude: set permission mode for an agent session', + run: (args) async { + final id = args.firstOrNull; + if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'}); + final mode = args.length >= 2 ? args[1] : null; + if (mode == null) return IpcResponse.ok(id: '', data: const {'error': 'missing mode (default|acceptEdits|plan|bypassPermissions)'}); + const valid = {'default', 'acceptEdits', 'plan', 'bypassPermissions'}; + if (!valid.contains(mode)) { + return IpcResponse.ok(id: '', data: {'error': 'unknown mode "$mode"; use one of: ${valid.join(', ')}'}); + } + _orchestrator?.byId(id)?.session.setPermissionMode(mode); + return IpcResponse.ok(id: '', data: {'id': id, 'mode': mode, 'status': 'sent'}); + }, + ), // Usage: clide claude.task.reassign CommandContribution( id: 'claude.task.reassign', diff --git a/lib/builtin/claude/src/stream_json_session.dart b/lib/builtin/claude/src/stream_json_session.dart index 122a6c95..97503513 100644 --- a/lib/builtin/claude/src/stream_json_session.dart +++ b/lib/builtin/claude/src/stream_json_session.dart @@ -592,6 +592,22 @@ class StreamJsonSession { })); } + /// Set the session's permission mode (T-181, D-77). Sends a + /// `set_permission_mode` control_request; fire-and-forget, mirroring + /// [interrupt]. [mode] must be one of the claude-recognised strings: + /// `default`, `acceptEdits`, `plan`, `bypassPermissions`. + /// + /// The safe trio (default → acceptEdits → plan → default) is cycled by the + /// cockpit badge's plain click; bypassPermissions is reachable only via a + /// confirmed shift-click (T-181). + void setPermissionMode(String mode) { + _proc.writeLine(jsonEncode({ + 'type': 'control_request', + 'request_id': 'set-perm-${_localSeq++}', + 'request': {'subtype': 'set_permission_mode', 'mode': mode}, + })); + } + Future dispose() async { await _sub?.cancel(); await _proc.kill(); diff --git a/test/builtin/claude/claude_meta_sidebar_test.dart b/test/builtin/claude/claude_meta_sidebar_test.dart index d254f080..733855d5 100644 --- a/test/builtin/claude/claude_meta_sidebar_test.dart +++ b/test/builtin/claude/claude_meta_sidebar_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:clide/builtin/claude/src/claude_config.dart'; @@ -9,6 +10,7 @@ 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/kernel/kernel.dart'; +import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:flutter/widgets.dart' show EditableText, SizedBox, Semantics; import 'package:flutter_test/flutter_test.dart'; @@ -23,11 +25,18 @@ class _FakeProc implements StreamJsonProcess { final List writes = []; bool killed = false; + /// Optional callback fired on every [writeLine] — used by T-181 tests to + /// mirror writes into a shared list across sessions. + void Function(String)? onWrite; + @override Stream get lines => _ctl.stream; @override - void writeLine(String line) => writes.add(line); + void writeLine(String line) { + writes.add(line); + onWrite?.call(line); + } @override Future kill() async => killed = true; @@ -448,4 +457,171 @@ void main() { orch.dispose(); }); }); + + // T-181: permission-mode badge ----------------------------------------------- + + group('T-181 permission-mode badge', () { + /// Build an orchestrator that captures all writes from the spawned session's + /// stdin. The factory captures the fake proc and mirrors its writeLine calls + /// into [writes] so tests can assert on control_requests sent. + (ClaudeSessionOrchestrator, List) orchCapturing() { + final writes = []; + final orch = ClaudeSessionOrchestrator( + processFactory: ({required sessionArgs, required cwd, env}) async { + final p = _FakeProc(); + p.onWrite = writes.add; + return p; + }, + ); + return (orch, writes); + } + + Future<(ClaudeSessionOrchestrator, List)> spawnAndShow( + WidgetTester tester, { + String name = 'Scout', + String agentId = 'b1', + }) async { + final (orch, writes) = orchCapturing(); + await orch.spawn(SpawnSpec( + id: 'teammate:$name', + role: 'teammate', + sessionId: '$name-uuid', + cwd: '/repo', + team: true, + memberName: name, + )); + await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team))); + f.services.events.emit(TeamMemberJoined( + team: 't', + agentId: agentId, + name: name, + agentType: 'coder', + paneId: '%1', + color: 'blue', + )); + await tester.pump(); + await tester.pump(); + return (orch, writes); + } + + testWidgets('badge renders with label D when permissionMode is null/default', (tester) async { + final semantics = tester.ensureSemantics(); + final (orch, _) = await spawnAndShow(tester); + + // The badge Semantics label is 'Permission mode: D' for the default mode. + expect(find.bySemanticsLabel('Permission mode: D'), findsOneWidget); + + semantics.dispose(); + orch.dispose(); + }); + + testWidgets('badge label reflects live permissionMode from status (A for acceptEdits)', (tester) async { + final semantics = tester.ensureSemantics(); + final (orch, _) = await spawnAndShow(tester); + + // Push a live status update with acceptEdits. + f.services.messages.publish( + ClaudeConversation.publisher, + ClaudeConversation.memberStatusChannel, + ClaudeConversation.memberStatusData( + 'b1', + const SessionStatus(permissionMode: 'acceptEdits'), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.bySemanticsLabel('Permission mode: A'), findsOneWidget); + + semantics.dispose(); + orch.dispose(); + }); + + testWidgets('plain click cycles default → acceptEdits and writes set_permission_mode', (tester) async { + final semantics = tester.ensureSemantics(); + final (orch, writes) = await spawnAndShow(tester); + + final preCount = writes.length; + + await tester.tap(find.bySemanticsLabel('Permission mode: D').first); + await tester.pump(); + + // One new write for the set_permission_mode control_request. + expect(writes.length, preCount + 1); + final sent = jsonDecode(writes.last) as Map; + expect(sent['type'], 'control_request'); + expect((sent['request'] as Map)['subtype'], 'set_permission_mode'); + expect((sent['request'] as Map)['mode'], 'acceptEdits'); + + semantics.dispose(); + orch.dispose(); + }); + + testWidgets('shift-click shows the bypass confirm inline', (tester) async { + final semantics = tester.ensureSemantics(); + final (orch, _) = await spawnAndShow(tester); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.tap(find.bySemanticsLabel('Permission mode: D').first); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + + // The inline confirm prompt should be visible. + expect(find.text('Enable bypassPermissions? All tool calls will be auto-allowed.'), findsOneWidget); + expect(find.bySemanticsLabel('Confirm bypass'), findsOneWidget); + expect(find.bySemanticsLabel('Cancel bypass'), findsOneWidget); + + semantics.dispose(); + orch.dispose(); + }); + + testWidgets('bypass confirm OK sends bypassPermissions and dismisses the prompt', (tester) async { + final semantics = tester.ensureSemantics(); + final (orch, writes) = await spawnAndShow(tester); + + final preCount = writes.length; + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.tap(find.bySemanticsLabel('Permission mode: D').first); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + + await tester.tap(find.bySemanticsLabel('Confirm bypass').first); + await tester.pump(); + + // Prompt is gone. + expect(find.text('Enable bypassPermissions? All tool calls will be auto-allowed.'), findsNothing); + + // bypassPermissions was sent to the session. + expect(writes.length, preCount + 1); + final sent = jsonDecode(writes.last) as Map; + expect((sent['request'] as Map)['subtype'], 'set_permission_mode'); + expect((sent['request'] as Map)['mode'], 'bypassPermissions'); + + semantics.dispose(); + orch.dispose(); + }); + + testWidgets('bypass confirm Cancel dismisses without sending', (tester) async { + final semantics = tester.ensureSemantics(); + final (orch, writes) = await spawnAndShow(tester); + + final preCount = writes.length; + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.tap(find.bySemanticsLabel('Permission mode: D').first); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + + await tester.tap(find.bySemanticsLabel('Cancel bypass').first); + await tester.pump(); + + // Prompt dismissed, no extra write. + expect(find.text('Enable bypassPermissions? All tool calls will be auto-allowed.'), findsNothing); + expect(writes.length, preCount); + + semantics.dispose(); + orch.dispose(); + }); + }); } diff --git a/test/builtin/claude/stream_json_session_test.dart b/test/builtin/claude/stream_json_session_test.dart index 2bfe6718..e8e984c4 100644 --- a/test/builtin/claude/stream_json_session_test.dart +++ b/test/builtin/claude/stream_json_session_test.dart @@ -491,6 +491,26 @@ void main() { expect(sent['request_id'], isNotNull); }); + test('setPermissionMode writes a set_permission_mode control_request (T-181)', () async { + session.setPermissionMode('acceptEdits'); + expect(proc.writes, hasLength(1)); + final sent = jsonDecode(proc.writes.single) as Map; + expect(sent['type'], 'control_request'); + expect(sent['request_id'], isNotNull); + final req = sent['request'] as Map; + expect(req['subtype'], 'set_permission_mode'); + expect(req['mode'], 'acceptEdits'); + }); + + test('setPermissionMode uses a unique request_id each call (T-181)', () async { + session.setPermissionMode('plan'); + session.setPermissionMode('default'); + expect(proc.writes, hasLength(2)); + final id1 = (jsonDecode(proc.writes[0]) as Map)['request_id'] as String; + final id2 = (jsonDecode(proc.writes[1]) as Map)['request_id'] as String; + expect(id1, isNot(equals(id2))); + }); + test('busy goes true on send and false on a result event', () async { final busy = []; session.busyStream.listen(busy.add);