permission-mode control beside the Claude composer (T-275)

Build the T-275 picker on the new popover primitive (D-88): an icon-only,
per-mode-coloured button trailing the composer text box opens a ClideMenu of
the safe trio (default/acceptEdits/plan, active marked) plus a divided, disabled
bypass row (the footgun stays behind the cockpit guard, T-181). The label lives
in the tooltip, the menu, and the status bar — the resting button is the glyph
alone. Coexists with the composer's Stop row when busy.

- new permission_mode_control.dart (PermissionModeControl + per-mode
  icon/colour helpers); shieldCheck/shieldWarning glyphs added to PhosphorIcons.
- claude_composer.dart: permissionMode + onSetPermissionMode props; control
  trails the text box (bottom-aligned), shown only when wired.
- claude_pane.dart: pass the current mode + a setter; demote the status-bar
  _ModeBadge to a passive, per-mode-coloured text indicator (no click). Ctrl/Cmd+M
  still cycles (onCycleMode unchanged).

Regenerated the phosphor-glyphs reference (47 defined). Tests: menu opens with
the trio + disabled bypass, select sets the mode, helpers map colours/icons,
control coexists with Stop, hidden when no mode.

Note: claude_pane.dart also carries the earlier T-274 resume diagnostic log line
(uncommitted in the working tree, reviewed as benign) — it rides along here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 18:52:29 +02:00
co-authored by Claude Opus 4.8
parent 3550f10dfc
commit a95b332925
9 changed files with 347 additions and 67 deletions
@@ -8,7 +8,7 @@ tags: [icons, phosphor, ui-design]
All **1512** glyphs bundled in clide's Phosphor font (`assets/fonts/phosphor/`, MIT). Generated from `assets/fonts/phosphor/codepoints.csv`**do not hand-edit**; regenerate with `python3 .claude/skills/ui-design/scripts/gen-phosphor-glyphs.py`.
The **In clide** column flags the **45** glyphs already wired into `PhosphorIcons` (`lib/widgets/src/icons/phosphor.dart`) — reach for those first. To use any other glyph, add a one-line `static const` to that class with the codepoint below, then `ClideIcon(PhosphorIcons.<name>, size: 13)`. Keep additions to icons we actually use — don't bulk-import.
The **In clide** column flags the **47** glyphs already wired into `PhosphorIcons` (`lib/widgets/src/icons/phosphor.dart`) — reach for those first. To use any other glyph, add a one-line `static const` to that class with the codepoint below, then `ClideIcon(PhosphorIcons.<name>, size: 13)`. Keep additions to icons we actually use — don't bulk-import.
| Codepoint | Name (kebab) | Pascal | In clide |
|---|---|---|---|
@@ -518,10 +518,10 @@ The **In clide** column flags the **45** glyphs already wired into `PhosphorIcon
| `0xe406` | share | Share | |
| `0xe408` | share-network | ShareNetwork | |
| `0xe40a` | shield | Shield | |
| `0xe40c` | shield-check | ShieldCheck | |
| `0xe40c` | shield-check | ShieldCheck | `PhosphorIcons.shieldCheck` |
| `0xe40e` | shield-chevron | ShieldChevron | |
| `0xe410` | shield-slash | ShieldSlash | |
| `0xe412` | shield-warning | ShieldWarning | |
| `0xe412` | shield-warning | ShieldWarning | `PhosphorIcons.shieldWarning` |
| `0xe416` | shopping-bag | ShoppingBag | |
| `0xe418` | shopping-bag-open | ShoppingBagOpen | |
| `0xe41e` | shopping-cart | ShoppingCart | |
@@ -878,3 +878,4 @@ 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-282', 'status', 'in_progress', 'done', NULL, '2026-06-08 15:42:01', '2026-06-08 15:42:01', '2026-06-08 15:42:01', NULL, '3ebc8c4a480cb16b403e562a4829912a', 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-275', 'status', 'ready', 'in_progress', NULL, '2026-06-08 15:45:13', '2026-06-08 15:45:13', '2026-06-08 15:45:13', NULL, '2745334e049523152af749102abe31e5', 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-275', 'parent_id', 'T-276', 'T-286', NULL, '2026-06-08 16:30:47', '2026-06-08 16:30:47', '2026-06-08 16:30:47', NULL, 'e0d912feca8121cdeb7a6e39b17e5eb0', 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-275', 'status', 'in_progress', 'done', NULL, '2026-06-08 16:52:13', '2026-06-08 16:52:13', '2026-06-08 16:52:13', NULL, '6ac483536876a274c0f76a4f4bb5fc69', 1) ON CONFLICT(hash) DO NOTHING;
+11
View File
@@ -2284,3 +2284,14 @@ Design to wireframe (frame0-wireframe skill):
- Token/geometry per the ui-design skill (no hardcoded hex; trailing-control geometry like the composer''s existing buttons).
Deliverable: a Frame0 wireframe (resting + open states, per-mode variants) exported, plus a short note on the chosen prompt-mode interaction, ready to implement. Relevant code: lib/builtin/claude/src/claude_composer.dart, claude_pane.dart (_ModeBadge / _statusWidget / _cycleMode), claude_status.dart (nextSafePermissionMode / permissionModeLabel).', 'in_progress', 'medium', NULL, NULL, 'D-78', '2026-06-08 07:28:34', '2026-06-08 16:30:47', NULL, 'c5616a1e54caf9d9bf91c02f036617c4', 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-275', 'task', 'T-286', 'Frame0 design: permission-mode dropdown beside the Claude composer', 'Produce a Frame0 wireframe for a persistent permission-mode control next to the Claude conversation composer, then use it to drive implementation.
Goal: surface the current permission mode (default · accept-edits · plan; bypass behind the cockpit guard) AT ALL TIMES, not only in the bottom status bar. Move/duplicate the mode affordance to an icon button sitting to the RIGHT of the composer text box (ClaudeComposer), showing the current mode via icon (+ short label), and opening a dropdown/popover menu to switch mode on click.
Design to wireframe (frame0-wireframe skill):
- Composer row with the mode icon-button anchored at its right edge (trailing), aligned with the send/interrupt control; show resting state for each mode (distinct icon/color per mode) and the open-dropdown state listing selectable modes with the active one marked.
- Account for the interaction zone (D-78): when a permission/AskUserQuestion prompt replaces the composer, define what happens to the mode button (hidden? disabled? persists above?).
- Keep it consistent with the existing status-bar mode badge (T-226: click or Ctrl/Cmd+M cycles the safe trio) decide whether the status-bar badge stays, is removed, or mirrors the new control.
- Token/geometry per the ui-design skill (no hardcoded hex; trailing-control geometry like the composer''s existing buttons).
Deliverable: a Frame0 wireframe (resting + open states, per-mode variants) exported, plus a short note on the chosen prompt-mode interaction, ready to implement. Relevant code: lib/builtin/claude/src/claude_composer.dart, claude_pane.dart (_ModeBadge / _statusWidget / _cycleMode), claude_status.dart (nextSafePermissionMode / permissionModeLabel).', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-08 07:28:34', '2026-06-08 16:52:13', NULL, '1c72f135bee789826ba7aa17a8e4569f', 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);
+7
View File
@@ -18,6 +18,13 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- **Permission-mode control beside the Claude composer.** An icon-only,
per-mode-coloured button trails the composer text box; clicking it opens a
menu of the safe modes (default · accept-edits · plan, active one marked) with
`bypass` shown but disabled (it stays behind the cockpit guard). The label
appears on hover, in the menu, and in the status bar. The status-bar mode is
now a passive, colour-coded indicator (no click) — switching lives in the new
control and `Ctrl/Cmd+M`. Built on the new popover primitive (D-88). (T-275)
- **Clickable T/D/Q/R cross-refs in the Claude conversation.** Bare ticket and
governance references (`T-281`, `D-77`, `Q-5`, `R-2`) in rendered messages are
now links: clicking one opens the record in its context-pane reader — `T-`
+69 -41
View File
@@ -13,6 +13,7 @@ import 'dart:io';
import 'package:clide/builtin/claude/src/claude_config.dart';
import 'package:clide/builtin/claude/src/clipboard_paste.dart';
import 'package:clide/builtin/claude/src/permission_mode_control.dart';
import 'package:clide/builtin/claude/src/running_indicator.dart';
import 'package:clide/builtin/claude/src/slash_commands.dart';
import 'package:clide/kernel/src/theme/controller.dart';
@@ -50,6 +51,8 @@ class ClaudeComposer extends StatefulWidget {
this.onInterrupt,
this.busy = false,
this.onCycleMode,
this.permissionMode,
this.onSetPermissionMode,
this.initialValue,
this.onDraftChanged,
this.history = const [],
@@ -85,6 +88,14 @@ class ClaudeComposer extends StatefulWidget {
/// Whether a turn is in flight; shows the Stop affordance.
final bool busy;
/// Current permission mode (T-275). When set together with
/// [onSetPermissionMode], an icon-only mode control trails the text box,
/// opening a menu to switch mode. Null hides the control.
final String? permissionMode;
/// Set a specific permission mode from the trailing control's menu (T-275).
final ValueChanged<String>? onSetPermissionMode;
/// Seed value (text + selection) the composer mounts with — the
/// persisted per-session draft (T-228). The composer restores this on
/// init so an in-progress message survives the composer being torn down
@@ -504,52 +515,69 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
children: [for (final a in _attachments) _chip(theme, a)],
),
),
Semantics(
label: widget.hint,
textField: true,
child: Shortcuts(
shortcuts: const {
SingleActivator(LogicalKeyboardKey.enter): SubmitComposerIntent(),
SingleActivator(LogicalKeyboardKey.numpadEnter): SubmitComposerIntent(),
},
child: Actions(
actions: {
SubmitComposerIntent: CallbackAction<SubmitComposerIntent>(
onInvoke: (_) {
_submit();
return null;
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Semantics(
label: widget.hint,
textField: true,
child: Shortcuts(
shortcuts: const {
SingleActivator(LogicalKeyboardKey.enter): SubmitComposerIntent(),
SingleActivator(LogicalKeyboardKey.numpadEnter): SubmitComposerIntent(),
},
),
PasteTextIntent: CallbackAction<PasteTextIntent>(
onInvoke: (_) {
unawaited(_handlePaste());
return null;
},
),
},
child: Stack(
children: [
if (!hasText)
Positioned(
left: 0,
top: 0,
right: 0,
child: ClideText(widget.hint, muted: true, fontSize: clideFontBody),
child: Actions(
actions: {
SubmitComposerIntent: CallbackAction<SubmitComposerIntent>(
onInvoke: (_) {
_submit();
return null;
},
),
PasteTextIntent: CallbackAction<PasteTextIntent>(
onInvoke: (_) {
unawaited(_handlePaste());
return null;
},
),
},
child: Stack(
children: [
if (!hasText)
Positioned(
left: 0,
top: 0,
right: 0,
child: ClideText(widget.hint, muted: true, fontSize: clideFontBody),
),
EditableText(
controller: _controller,
focusNode: _focus,
readOnly: !widget.enabled,
style: TextStyle(fontSize: clideFontBody, color: fg, height: 1.4),
cursorColor: theme.globalFocus,
backgroundCursorColor: theme.globalTextMuted,
maxLines: 8,
minLines: 1,
),
],
),
EditableText(
controller: _controller,
focusNode: _focus,
readOnly: !widget.enabled,
style: TextStyle(fontSize: clideFontBody, color: fg, height: 1.4),
cursorColor: theme.globalFocus,
backgroundCursorColor: theme.globalTextMuted,
maxLines: 8,
minLines: 1,
),
],
),
),
),
),
if (widget.permissionMode != null && widget.onSetPermissionMode != null) ...[
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: PermissionModeControl(
mode: widget.permissionMode!,
onSelect: widget.onSetPermissionMode!,
),
),
],
],
),
],
),
+27 -23
View File
@@ -12,6 +12,7 @@ import 'claude_status.dart';
import 'clipboard_paste.dart';
import 'conversation_controller.dart';
import 'conversation_view.dart';
import 'permission_mode_control.dart';
import 'prompt_card.dart';
import 'session_index.dart';
import 'session_naming.dart';
@@ -117,10 +118,10 @@ class _ClaudePaneState extends State<ClaudePane> {
}
if (seg.leading != null) add(text(seg.leading!));
// The permission-mode segment is an interactive badge — click or
// Enter/Space (when focused) cycles it (T-226).
// The permission-mode segment is a passive, per-mode-coloured indicator now
// (T-275); switching lives in the composer's mode control + Ctrl/Cmd+M.
if (mode != null) {
add(_ModeBadge(label: permissionModeLabel(mode), tokens: tokens, onCycle: _session != null ? _cycleMode : null));
add(_ModeBadge(mode: mode, tokens: tokens));
}
if (seg.trailing != null) add(text(seg.trailing!));
if (skills != null) add(text(skills));
@@ -297,6 +298,15 @@ class _ClaudePaneState extends State<ClaudePane> {
_session = managed.session;
_conversation = managed.conversation;
// Diagnostic (T-274 follow-up): record how this pane bound its session —
// a fresh spawn vs connecting to existing on-disk history (the seed read
// from the transcript/sidecar). Surfaces the resume path in `make run`.
final seeded = _conversation?.items.length ?? 0;
_kernel()?.log.info(
'claude',
'pane $_orchId bound session ${_sessionId ?? '?'} in $repoRoot'
'${seeded > 0 ? 'connected to history ($seeded seeded item(s))' : 'fresh session (no history)'}',
);
_statusSub = managed.session.statusStream.listen((s) {
if (!mounted) return;
setState(() => _status = s);
@@ -510,6 +520,8 @@ class _ClaudePaneState extends State<ClaudePane> {
busy: busySnap.data ?? false,
onInterrupt: _session?.interrupt,
onCycleMode: _cycleMode,
permissionMode: _status.permissionMode,
onSetPermissionMode: _session != null ? (m) => _session!.setPermissionMode(m) : null,
onSubmit: _send,
pasteResolver: () => resolveClipboardAttachment(const NativeClipboard()),
initialValue: _sessionId == null ? null : _drafts[_sessionId],
@@ -545,34 +557,26 @@ class _ClaudePaneState extends State<ClaudePane> {
}
}
/// Interactive permission-mode badge in the status line (T-226). Click, or
/// focus + Enter/Space, cycles the safe trio (ClideTappable handles the
/// ActivateIntent). A null [onCycle] (no live session) renders it inert.
/// Passive permission-mode indicator in the status line (T-275). Mode
/// switching now lives in the composer's mode control + Ctrl/Cmd+M (T-226), so
/// this is a plain, per-mode-coloured text mirror — no click, no border.
class _ModeBadge extends StatelessWidget {
const _ModeBadge({required this.label, required this.tokens, required this.onCycle});
const _ModeBadge({required this.mode, required this.tokens});
final String label;
final String mode;
final SurfaceTokens tokens;
final VoidCallback? onCycle;
@override
Widget build(BuildContext context) {
return Semantics(
button: onCycle != null,
label: 'permission mode: $label. Activate to cycle.',
label: 'permission mode: ${permissionModeLabel(mode)}',
excludeSemantics: true,
child: ClideTappable(
onTap: onCycle,
tooltip: 'Permission mode — click or Ctrl/Cmd+M to cycle (default · accept-edits · plan)',
builder: (context, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: hovered ? tokens.listItemHoverBackground : null,
border: Border.all(color: hovered && onCycle != null ? tokens.globalFocus : tokens.globalBorder),
borderRadius: BorderRadius.circular(4),
),
child: ClideText(label, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusBarForeground, maxLines: 1),
),
child: ClideText(
permissionModeLabel(mode),
fontSize: clideFontSmall,
fontFamily: clideMonoFamily,
color: permissionModeColor(mode, tokens),
maxLines: 1,
),
);
}
@@ -0,0 +1,132 @@
/// Icon-only permission-mode control trailing the Claude composer (T-275),
/// built on the ClideAnchoredOverlay + ClideMenu primitive (D-88).
///
/// Shows the current mode as a per-mode coloured glyph; clicking opens a menu
/// of the safe trio (default / accept-edits / plan) with the active one marked,
/// plus a divided, disabled `bypass` row — that footgun stays behind the
/// cockpit's explicit confirmed path (T-181), never one click away here. The
/// label lives in the tooltip, the menu rows, and the status-bar indicator —
/// the resting button is the glyph alone.
library;
import 'package:clide/builtin/claude/src/claude_status.dart' show kSafePermissionCycle, permissionModeLabel;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
/// Per-mode glyph. `bypass` reuses a warning shield; the safe trio gets a
/// check-shield (default), pencil (accept-edits), and checklist (plan).
ClideIconPainter permissionModeIcon(String mode) {
switch (mode) {
case 'acceptEdits':
return PhosphorIcons.pencilSimple;
case 'plan':
return PhosphorIcons.listChecks;
case 'bypassPermissions':
return PhosphorIcons.shieldWarning;
default:
return PhosphorIcons.shieldCheck;
}
}
/// Per-mode accent — used by the control glyph and the passive status indicator
/// so both read the same colour. All theme tokens (no hardcoded hex).
Color permissionModeColor(String mode, SurfaceTokens tokens) {
switch (mode) {
case 'acceptEdits':
return tokens.statusWarning;
case 'plan':
return tokens.globalFocus;
case 'bypassPermissions':
return tokens.statusError;
default:
return tokens.globalTextMuted;
}
}
class PermissionModeControl extends StatefulWidget {
const PermissionModeControl({super.key, required this.mode, required this.onSelect});
/// Current permission mode (e.g. `default`, `acceptEdits`, `plan`).
final String mode;
/// Set a specific safe mode (the disabled `bypass` row never calls this).
final ValueChanged<String> onSelect;
@override
State<PermissionModeControl> createState() => _PermissionModeControlState();
}
class _PermissionModeControlState extends State<PermissionModeControl> {
final ClideOverlayController _overlay = ClideOverlayController();
@override
void dispose() {
_overlay.dispose();
super.dispose();
}
List<ClideMenuEntry> _entries(SurfaceTokens tokens) => [
for (final m in kSafePermissionCycle)
ClideMenuItem(
leading: permissionModeIcon(m),
color: permissionModeColor(m, tokens),
label: permissionModeLabel(m),
active: m == widget.mode,
onSelect: () => widget.onSelect(m),
),
const ClideMenuSeparator(),
ClideMenuItem(
leading: permissionModeIcon('bypassPermissions'),
color: permissionModeColor('bypassPermissions', tokens),
label: permissionModeLabel('bypassPermissions'),
enabled: false,
active: widget.mode == 'bypassPermissions',
onSelect: () {},
),
];
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return ClideAnchoredOverlay(
controller: _overlay,
side: ClideAnchorSide.above,
align: ClideAnchorAlign.end,
offset: const Offset(0, -6),
overlayBuilder: (ctx, ctrl) => ClideMenu(
onClose: ctrl.close,
minWidth: 180,
entries: _entries(ClideTheme.of(ctx).surface),
),
anchor: ListenableBuilder(
listenable: _overlay,
builder: (ctx, _) {
final open = _overlay.isOpen;
return Semantics(
button: true,
label: 'permission mode: ${permissionModeLabel(widget.mode)}. Activate to change.',
excludeSemantics: true,
child: ClideTappable(
onTap: _overlay.toggle,
tooltip: 'Permission mode: ${permissionModeLabel(widget.mode)} — change (Ctrl/Cmd+M cycles)',
builder: (ctx, hovered, _) => Container(
width: 28,
height: 28,
alignment: Alignment.center,
decoration: BoxDecoration(
color: hovered ? tokens.listItemHoverBackground : null,
border: Border.all(
color: open ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.globalBorder),
),
borderRadius: BorderRadius.circular(4),
),
child: ClideIcon(permissionModeIcon(widget.mode), size: 16, color: permissionModeColor(widget.mode, tokens)),
),
),
);
},
),
);
}
}
+2
View File
@@ -53,6 +53,8 @@ abstract class PhosphorIcons {
static const palette = PhosphorIconPainter(0xe6c8);
static const warning = PhosphorIconPainter(0xe4e0);
static const warningCircle = PhosphorIconPainter(0xe4e2);
static const shieldCheck = PhosphorIconPainter(0xe40c);
static const shieldWarning = PhosphorIconPainter(0xe412);
static const check = PhosphorIconPainter(0xe182);
static const checkCircle = PhosphorIconPainter(0xe184);
static const caretLeft = PhosphorIconPainter(0xe138);
@@ -0,0 +1,95 @@
/// Tests for the composer permission-mode control (T-275): opens a menu of the
/// safe trio + a disabled bypass row, selecting sets the mode, and it coexists
/// with the composer's Stop row while busy.
library;
import 'package:clide/builtin/claude/src/claude_composer.dart';
import 'package:clide/builtin/claude/src/permission_mode_control.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
group('PermissionModeControl', () {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
Future<void> pump(WidgetTester tester, String mode, ValueChanged<String> onSelect) {
return tester.pumpWidget(harness(
f,
Align(alignment: Alignment.center, child: PermissionModeControl(mode: mode, onSelect: onSelect)),
));
}
testWidgets('opens a menu of the safe trio + a bypass row; selecting sets the mode', (tester) async {
var picked = '';
await pump(tester, 'default', (m) => picked = m);
await tester.pump();
expect(find.text('accept-edits'), findsNothing, reason: 'menu starts closed');
await tester.tap(find.byType(PermissionModeControl));
await tester.pump();
expect(find.text('default'), findsOneWidget);
expect(find.text('accept-edits'), findsOneWidget);
expect(find.text('plan'), findsOneWidget);
expect(find.text('bypass'), findsOneWidget);
await tester.tap(find.text('plan'));
await tester.pump();
expect(picked, 'plan');
expect(find.text('accept-edits'), findsNothing, reason: 'menu closes on select');
});
testWidgets('the bypass row is disabled — selecting it does nothing', (tester) async {
var picked = '';
await pump(tester, 'default', (m) => picked = m);
await tester.pump();
await tester.tap(find.byType(PermissionModeControl));
await tester.pump();
await tester.tap(find.text('bypass'));
await tester.pump();
expect(picked, '', reason: 'bypass stays behind the cockpit guard (T-181)');
});
testWidgets('per-mode helpers map labels/colours/icons', (tester) async {
final tokens = f.services.theme.current.surface;
expect(permissionModeColor('acceptEdits', tokens), tokens.statusWarning);
expect(permissionModeColor('plan', tokens), tokens.globalFocus);
expect(permissionModeColor('bypassPermissions', tokens), tokens.statusError);
expect(permissionModeColor('default', tokens), tokens.globalTextMuted);
expect(permissionModeIcon('default'), isNot(equals(permissionModeIcon('plan'))));
});
});
group('ClaudeComposer + mode control', () {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
testWidgets('the mode control and the Stop row coexist while busy', (tester) async {
await tester.pumpWidget(harness(
f,
ClaudeComposer(
onSubmit: (_) {},
busy: true,
onInterrupt: () {},
permissionMode: 'default',
onSetPermissionMode: (_) {},
),
));
await tester.pump();
// Stop affordance (busy row) and the trailing mode control are both present.
expect(find.textContaining('Stop'), findsOneWidget);
expect(find.byType(PermissionModeControl), findsOneWidget);
});
testWidgets('no mode control when permissionMode is null', (tester) async {
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {})));
await tester.pump();
expect(find.byType(PermissionModeControl), findsNothing);
});
});
}