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