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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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-`
|
||||
|
||||
@@ -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!,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user