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
@@ -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);