diff --git a/.claude/skills/ui-design/references/phosphor-glyphs.md b/.claude/skills/ui-design/references/phosphor-glyphs.md index edc42cfa..0d861891 100644 --- a/.claude/skills/ui-design/references/phosphor-glyphs.md +++ b/.claude/skills/ui-design/references/phosphor-glyphs.md @@ -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., 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., 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 | | diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 4cd53765..02120d5f 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 1e90e7b2..b0e30bc2 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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); diff --git a/CHANGELOG.md b/CHANGELOG.md index d3587667..629c3129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-` diff --git a/lib/builtin/claude/src/claude_composer.dart b/lib/builtin/claude/src/claude_composer.dart index ba2d51d6..31308959 100644 --- a/lib/builtin/claude/src/claude_composer.dart +++ b/lib/builtin/claude/src/claude_composer.dart @@ -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? 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 { 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( - 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( - 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( + onInvoke: (_) { + _submit(); + return null; + }, + ), + PasteTextIntent: CallbackAction( + 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!, + ), + ), + ], + ], ), ], ), diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 8f40fd1c..3a2d1cba 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -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 { } 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 { _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 { 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 { } } -/// 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, ), ); } diff --git a/lib/builtin/claude/src/permission_mode_control.dart b/lib/builtin/claude/src/permission_mode_control.dart new file mode 100644 index 00000000..31175396 --- /dev/null +++ b/lib/builtin/claude/src/permission_mode_control.dart @@ -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 onSelect; + + @override + State createState() => _PermissionModeControlState(); +} + +class _PermissionModeControlState extends State { + final ClideOverlayController _overlay = ClideOverlayController(); + + @override + void dispose() { + _overlay.dispose(); + super.dispose(); + } + + List _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)), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/src/icons/phosphor.dart b/lib/widgets/src/icons/phosphor.dart index f4e53157..a10b5863 100644 --- a/lib/widgets/src/icons/phosphor.dart +++ b/lib/widgets/src/icons/phosphor.dart @@ -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); diff --git a/test/builtin/claude/permission_mode_control_test.dart b/test/builtin/claude/permission_mode_control_test.dart new file mode 100644 index 00000000..0e84ab7b --- /dev/null +++ b/test/builtin/claude/permission_mode_control_test.dart @@ -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 pump(WidgetTester tester, String mode, ValueChanged 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); + }); + }); +}