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
+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();