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 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 23:25:07 +02:00
co-authored by Claude
parent 4220d97a49
commit 2888efbe5f
8 changed files with 451 additions and 20 deletions
@@ -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'', <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).', 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;
+7
View File
@@ -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);
+4
View File
@@ -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
+202 -19
View File
@@ -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<ClaudeMetaSidebar> {
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({
+22
View File
@@ -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 <sessionId> <mode>
// <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 <taskId> <toSessionId>
CommandContribution(
id: 'claude.task.reassign',
@@ -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<void> dispose() async {
await _sub?.cancel();
await _proc.kill();
@@ -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<String> 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<String> get lines => _ctl.stream;
@override
void writeLine(String line) => writes.add(line);
void writeLine(String line) {
writes.add(line);
onWrite?.call(line);
}
@override
Future<void> 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<String>) orchCapturing() {
final writes = <String>[];
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<String>)> 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<String, dynamic>;
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<String, dynamic>;
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();
});
});
}
@@ -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<String, dynamic>;
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 = <bool>[];
session.busyStream.listen(busy.add);