feat(claude): reach bypass via Ctrl/Cmd+Shift+M + shift-click (T-510)

Restores the original intent that T-226's refinement wording lost: the
shift modifier is the explicit opt-in for bypassPermissions, not a
separate confirm flow. Plain Ctrl/Cmd+M keeps cycling the safe trio;
Ctrl/Cmd+Shift+M cycles the full list. The composer menu's bypass row
was permanently disabled, deferring to "the cockpit's confirmed path" —
but that roster is ghost-fed (T-396), so bypass was unreachable from
the primary session's UI entirely. The row now no-ops on a plain click
(menu stays open) and selects on shift-click, with a hint naming the
gesture. Roster badge and /permissions paths unchanged.

lib/test_app.dart is a format-only follow-up to the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 12:54:11 +02:00
co-authored by Claude Fable 5
parent 0f1d15efdd
commit a15b3fd098
14 changed files with 175 additions and 29 deletions
+25 -9
View File
@@ -50,6 +50,7 @@ class ClaudeComposer extends StatefulWidget {
this.onInterrupt,
this.busy = false,
this.onCycleMode,
this.onCycleModeFull,
this.permissionMode,
this.onSetPermissionMode,
this.initialValue,
@@ -83,11 +84,17 @@ class ClaudeComposer extends StatefulWidget {
/// (when the typeahead is closed). The escape hatch for a runaway turn.
final VoidCallback? onInterrupt;
/// Cycle the session's permission mode — fired by Ctrl/Cmd+M while the
/// composer is focused (T-226). Intercepted here (not a global keymap
/// binding) so it targets this pane's session. Null disables the chord.
/// Cycle the session's permission mode through the safe trio — fired by
/// Ctrl/Cmd+M while the composer is focused (T-226). Intercepted here
/// (not a global keymap binding) so it targets this pane's session.
/// Null disables the chord.
final VoidCallback? onCycleMode;
/// Cycle through the FULL mode list including bypassPermissions — fired
/// by Ctrl/Cmd+Shift+M (T-510). Holding shift is the explicit opt-in
/// for the footgun. Null disables the chord.
final VoidCallback? onCycleModeFull;
/// Whether a turn is in flight; shows the Stop affordance.
final bool busy;
@@ -255,13 +262,22 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
// Ctrl/Cmd+M: cycle the session's permission mode (T-226). Intercepted
// here so it targets this pane. (Shift+Tab — the CLI chord — is off the
// table: it's a real a11y focus-traversal binding.)
// Ctrl/Cmd+M: cycle the session's permission mode through the safe trio
// (T-226); with Shift held, through the full list including bypass
// (T-510). Intercepted here so it targets this pane. (Shift+Tab — the
// CLI chord — is off the table: it's a real a11y focus-traversal
// binding.)
final mod = HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed;
if (mod && e.logicalKey == LogicalKeyboardKey.keyM && widget.onCycleMode != null) {
widget.onCycleMode!();
return KeyEventResult.handled;
if (mod && e.logicalKey == LogicalKeyboardKey.keyM) {
final shift = HardwareKeyboard.instance.isShiftPressed;
if (shift && widget.onCycleModeFull != null) {
widget.onCycleModeFull!();
return KeyEventResult.handled;
}
if (!shift && widget.onCycleMode != null) {
widget.onCycleMode!();
return KeyEventResult.handled;
}
}
// Escape: dismiss the typeahead if open, otherwise interrupt the running
// turn — the escape hatch from a runaway (D-78).
+12 -2
View File
@@ -613,14 +613,23 @@ class _ClaudePaneState extends State<ClaudePane> {
/// Cycle this pane's session through the safe permission-mode trio
/// (default → acceptEdits → plan → default), sent over the stream-json
/// control channel (T-226). bypassPermissions is not reachable here — it
/// stays behind the explicit confirmed path in the cockpit roster (T-181).
/// control channel (T-226). bypassPermissions is not in the plain chord —
/// it's in the shift-modified full cycle ([_cycleModeFull], T-510).
void _cycleMode() {
final s = _session;
if (s == null) return;
s.setPermissionMode(nextSafePermissionMode(_status.permissionMode ?? 'default'));
}
/// Cycle through the full mode list including bypassPermissions —
/// Ctrl/Cmd+Shift+M, where holding shift is the explicit opt-in for the
/// footgun (T-510).
void _cycleModeFull() {
final s = _session;
if (s == null) return;
s.setPermissionMode(nextPermissionMode(_status.permissionMode ?? 'default'));
}
/// Focus the composer when the user taps empty conversation area (T-227).
/// No-op while a prompt occupies the interaction zone (D-78) — a
/// background tap must never pull focus from (or resurrect) the composer
@@ -835,6 +844,7 @@ class _ClaudePaneState extends State<ClaudePane> {
busy: busySnap.data ?? false,
onInterrupt: _session?.interrupt,
onCycleMode: _cycleMode,
onCycleModeFull: _cycleModeFull,
permissionMode: _status.permissionMode,
onSetPermissionMode: _session != null ? (m) => _session!.setPermissionMode(m) : null,
onSubmit: _send,
+15 -2
View File
@@ -41,10 +41,16 @@ String shortModelLabel(String model) {
}
/// The safe permission-mode cycle: default → acceptEdits → plan → default
/// (T-226/T-181). `bypassPermissions` is intentionally excluded — it's
/// reachable only via an explicit confirmed path (the footgun guard).
/// (T-226/T-181). `bypassPermissions` is intentionally excluded from the
/// plain chord — it lives in [kFullPermissionCycle] behind the shift
/// modifier (T-510).
const List<String> kSafePermissionCycle = ['default', 'acceptEdits', 'plan'];
/// The full cycle including the bypass footgun. Reachable only through
/// shift-modified gestures (Ctrl/Cmd+Shift+M, shift-click on the popup's
/// bypass row) — holding shift is the explicit opt-in (T-510).
const List<String> kFullPermissionCycle = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
/// The next mode in [kSafePermissionCycle] after [current] (wraps). An
/// unknown or `bypassPermissions` current restarts the cycle at `default`.
String nextSafePermissionMode(String current) {
@@ -52,6 +58,13 @@ String nextSafePermissionMode(String current) {
return kSafePermissionCycle[(i + 1) % kSafePermissionCycle.length];
}
/// The next mode in [kFullPermissionCycle] after [current] (wraps). An
/// unknown current restarts the cycle at `default`.
String nextPermissionMode(String current) {
final i = kFullPermissionCycle.indexOf(current);
return kFullPermissionCycle[(i + 1) % kFullPermissionCycle.length];
}
/// Status-line segments split around the permission-mode badge so the UI can
/// render the mode as an interactive control between them (T-226). `leading`
/// is the model; `trailing` joins context / cost / rate-limit. Either may be
@@ -3,15 +3,18 @@
///
/// 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.
/// plus a divided `bypass` row gated behind shift-click (T-510): a plain click
/// no-ops and keeps the menu open, shift-click selects — the footgun is never
/// a *plain* click away, and holding shift is the explicit opt-in (same
/// convention as the roster badge, T-181). 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/services.dart' show HardwareKeyboard;
import 'package:flutter/widgets.dart';
/// Per-mode glyph. `bypass` reuses a warning shield; the safe trio gets a
@@ -66,7 +69,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
super.dispose();
}
List<ClideMenuEntry> _entries(SurfaceTokens tokens) => [
List<ClideMenuEntry> _entries(BuildContext ctx, SurfaceTokens tokens) => [
for (final m in kSafePermissionCycle)
ClideMenuItem(
leading: permissionModeIcon(m),
@@ -76,13 +79,33 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
onSelect: () => widget.onSelect(m),
),
const ClideMenuSeparator(),
// The footgun row (T-510): a plain click/Enter no-ops and keeps the
// menu open; with shift held it selects and closes. The trailing hint
// names the gesture (suppressed while active — the check mark wins).
ClideMenuItem(
leading: permissionModeIcon('bypassPermissions'),
color: permissionModeColor('bypassPermissions', tokens),
label: permissionModeLabel('bypassPermissions'),
enabled: false,
active: widget.mode == 'bypassPermissions',
onSelect: () {},
keepOpenOnSelect: true,
semanticLabel: ClideSettings.i18n.string(
ctx,
'permissionControl.bypassSemantics',
namespace: 'builtin.claude',
placeholder: 'bypassPermissions — shift-click to enable',
),
trailing: widget.mode == 'bypassPermissions'
? null
: ClideText(
ClideSettings.i18n.string(ctx, 'permissionControl.bypassHint', namespace: 'builtin.claude', placeholder: 'shift-click'),
fontSize: 10,
color: tokens.globalTextMuted,
),
onSelect: () {
if (!HardwareKeyboard.instance.isShiftPressed) return;
widget.onSelect('bypassPermissions');
_overlay.close();
},
),
];
@@ -94,7 +117,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
side: ClideAnchorSide.above,
align: ClideAnchorAlign.end,
offset: const Offset(0, -6),
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ClideSettings.theme.of(ctx).surface)),
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ctx, ClideSettings.theme.of(ctx).surface)),
anchor: ListenableBuilder(
listenable: _overlay,
builder: (ctx, _) {
@@ -115,7 +138,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
context,
'permissionControl.tooltip',
namespace: 'builtin.claude',
placeholder: 'Permission mode: ${permissionModeLabel(widget.mode)} — change (Ctrl/Cmd+M cycles)',
placeholder: 'Permission mode: ${permissionModeLabel(widget.mode)} — change (Ctrl/Cmd+M cycles; +Shift includes bypass)',
replacers: [I18nReplacer(from: '{mode}', replace: permissionModeLabel(widget.mode))],
),
builder: (ctx, hovered, _) => Container(
+9 -1
View File
@@ -284,7 +284,15 @@ class _ClideTestAppState extends State<ClideTestApp> {
// PqlExtension precedes GraphExtension: the graph view depends on it
// (activateAll resolves the order, but this documents the dependency).
final extensions = <ClideExtension>[DiffExtension(), FilesExtension(), GitExtension(), TerminalExtension(), PqlExtension(), GraphExtension(), CanvasExtension()];
final extensions = <ClideExtension>[
DiffExtension(),
FilesExtension(),
GitExtension(),
TerminalExtension(),
PqlExtension(),
GraphExtension(),
CanvasExtension(),
];
for (final ext in extensions) {
try {