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:
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user