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
@@ -97,3 +97,4 @@ PROGRESS (2026-07-02, cont.): third foundation slice landed — interactive Canv
Refinement (2026-07-03, user-confirmed): (1) SLOT: Slots.workspace — diff/editor recipe, center column; coexists with Claude the way diff does (D-47). (2) TABS: real MultitabPane<CanvasDoc> — N .canvas files open as tabs (user explicitly chose over the single-TabContribution graph/diff precedent; needs MultitabController plumbing like Claude sessions). (3) FILE-OPEN ROUTING resolved by inspection: TabContribution.fileGlobs is dead (zero consumers) — do NOT wire it. Add a .canvas branch in lib/kernel/src/file_open.dart openWorkspaceFile() publishing to ''builtin.canvas''/''selection'' (mirror of the .md branch), and D-6 CLI parity via a ''canvas'' entry in the _readers map at lib/src/daemon/ui_command.dart:34 → clide ui open canvas <path> (diff precedent). (4) Fix stale doc header lib/src/canvas/json_canvas.dart:5-7 (claims SVG-lowering; actual architecture paints CanvasDoc directly per the ticket''s DECISION NOTE) in the same pass. (5) Edit phase later needs a mutation path (CanvasView has no onChanged; CanvasDoc is immutable-construction only) — deferred, flagged now.
PROGRESS (2026-07-05): REACHABLE-INTEGRATION PHASE COMPLETE (e2e305de). CanvasExtension is a real workspace pane: app-scoped MultitabController on the extension (diff/T-233 pattern) with real per-document sub-tabs (MultitabPane keepAlive, per refinement decision), CanvasPaneHost + CanvasDocumentTab (files.read -> CanvasDoc.parse -> CanvasView; loading/error states), routing wired (openWorkspaceFile .canvas branch; ui.open ''canvas'' reader so clide ui open canvas <path> works; fileGlobs left dead as decided), en_us+nl_nl catalogs, json_canvas doc header corrected. Tests: extension lifecycle (7), pane-host widget (6), file_open routing (5, new file), ui_command canvas case; testmode extension roster now includes builtin.canvas (26/26 real-boot pass). REMAINING (edit phase, fresh session): node drag + resize, add note/text/edge affordances, persist CanvasDoc back to disk — encode() exists; CanvasView needs an onChanged/mutation path (refinement note item 5).', NULL, '2026-07-05 07:29:15', '2026-07-05 07:29:15.452', '2026-07-05 07:29:15.452', NULL, '0d0ff6c05b550d4a85296eb38adc830f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FK3CDZQD2JM95ZQY5MH53PWC', 'status', 'backlog', 'in_progress', NULL, '2026-07-05 09:52:54', '2026-07-05 09:52:54.013', '2026-07-05 09:52:54.013', NULL, '59efe4bd0a098bfea3f146af21b0caf3', 2) ON CONFLICT(hash) DO NOTHING;
+1
View File
@@ -1,3 +1,4 @@
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ4D0GAXM1FN42PJKG7FE34G', 'T-507', '2026-07-02 09:41:05.241', '2026-07-02 09:41:05.241', NULL, 'd9dec9b9ed45db65467318f53eb05157', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ4D1M6ESSSQ1ZERTH4V9YN4', 'T-508', '2026-07-02 09:41:14.425', '2026-07-02 09:41:14.425', NULL, '5389a964251dc22408c41ceac6954f4d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ91RYFXBJH99HJ18QTHY2B0', 'T-509', '2026-07-02 20:31:02.783', '2026-07-02 20:31:02.783', NULL, 'ea835aca1960e1bee3d0710be19b8bba', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FK3CDZQD2JM95ZQY5MH53PWC', 'T-510', '2026-07-05 09:52:40.384', '2026-07-05 09:52:40.384', NULL, '6acffc225f49858f782c8c5e801e1c24', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
+2
View File
@@ -78,3 +78,5 @@ PROGRESS (2026-07-02, cont.): third foundation slice landed — interactive Canv
Refinement (2026-07-03, user-confirmed): (1) SLOT: Slots.workspace — diff/editor recipe, center column; coexists with Claude the way diff does (D-47). (2) TABS: real MultitabPane<CanvasDoc> — N .canvas files open as tabs (user explicitly chose over the single-TabContribution graph/diff precedent; needs MultitabController plumbing like Claude sessions). (3) FILE-OPEN ROUTING resolved by inspection: TabContribution.fileGlobs is dead (zero consumers) — do NOT wire it. Add a .canvas branch in lib/kernel/src/file_open.dart openWorkspaceFile() publishing to ''builtin.canvas''/''selection'' (mirror of the .md branch), and D-6 CLI parity via a ''canvas'' entry in the _readers map at lib/src/daemon/ui_command.dart:34 → clide ui open canvas <path> (diff precedent). (4) Fix stale doc header lib/src/canvas/json_canvas.dart:5-7 (claims SVG-lowering; actual architecture paints CanvasDoc directly per the ticket''s DECISION NOTE) in the same pass. (5) Edit phase later needs a mutation path (CanvasView has no onChanged; CanvasDoc is immutable-construction only) — deferred, flagged now.
PROGRESS (2026-07-05): REACHABLE-INTEGRATION PHASE COMPLETE (e2e305de). CanvasExtension is a real workspace pane: app-scoped MultitabController on the extension (diff/T-233 pattern) with real per-document sub-tabs (MultitabPane keepAlive, per refinement decision), CanvasPaneHost + CanvasDocumentTab (files.read -> CanvasDoc.parse -> CanvasView; loading/error states), routing wired (openWorkspaceFile .canvas branch; ui.open ''canvas'' reader so clide ui open canvas <path> works; fileGlobs left dead as decided), en_us+nl_nl catalogs, json_canvas doc header corrected. Tests: extension lifecycle (7), pane-host widget (6), file_open routing (5, new file), ui_command canvas case; testmode extension roster now includes builtin.canvas (26/26 real-boot pass). REMAINING (edit phase, fresh session): node drag + resize, add note/text/edge affordances, persist CanvasDoc back to disk — encode() exists; CanvasView needs an onChanged/mutation path (refinement note item 5).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:17', '2026-07-05 07:29:15.447', NULL, '1be84ba4d4357171ef329f39b601f4e1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FK3CDZQD2JM95ZQY5MH53PWC', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Composer bypass reachability: Ctrl/Cmd+Shift+M full cycle + shift-click bypass row', 'User decision (2026-07-05), amending T-226''s refinement wording: the original intent was Ctrl/Cmd+Shift+M cycling the FULL permission-mode list including bypassPermissions, with plain Ctrl/Cmd+M keeping the safe trio (default/acceptEdits/plan). Holding shift IS the explicit opt-in gesture — no separate confirm needed. Likewise the composer mode popup (T-275): the bypass row is currently permanently disabled, pointing at ''the cockpit''s confirmed path'' (T-181 roster shift-click) — but that roster is ghost-fed (T-396, TeamMemberJoined never emitted in production), so bypass is unreachable from the primary session''s UI entirely; a visible-but-unreachable option is worse than none. Scope: (1) claude_status: kFullPermissionCycle + nextPermissionMode(); (2) composer key handler: mod+shift+M -> full cycle, mod+M (no shift) -> safe trio; (3) PermissionModeControl: bypass row enabled behind a shift-click gate (plain click no-ops, menu stays open; shift-click selects + closes), trailing ''shift-click'' hint, semantics + tooltip updated; (4) i18n en+nl; (5) tests. The /permissions picker and roster badge paths are unchanged. Refs T-226, T-275, T-181, T-396.', 'backlog', 'medium', NULL, NULL, NULL, '2026-07-05 09:52:40.379', '2026-07-05 09:52:40.379', NULL, 'ec1eed07c9a69b4edbf3f4ffe671f696', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FK3CDZQD2JM95ZQY5MH53PWC', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Composer bypass reachability: Ctrl/Cmd+Shift+M full cycle + shift-click bypass row', 'User decision (2026-07-05), amending T-226''s refinement wording: the original intent was Ctrl/Cmd+Shift+M cycling the FULL permission-mode list including bypassPermissions, with plain Ctrl/Cmd+M keeping the safe trio (default/acceptEdits/plan). Holding shift IS the explicit opt-in gesture — no separate confirm needed. Likewise the composer mode popup (T-275): the bypass row is currently permanently disabled, pointing at ''the cockpit''s confirmed path'' (T-181 roster shift-click) — but that roster is ghost-fed (T-396, TeamMemberJoined never emitted in production), so bypass is unreachable from the primary session''s UI entirely; a visible-but-unreachable option is worse than none. Scope: (1) claude_status: kFullPermissionCycle + nextPermissionMode(); (2) composer key handler: mod+shift+M -> full cycle, mod+M (no shift) -> safe trio; (3) PermissionModeControl: bypass row enabled behind a shift-click gate (plain click no-ops, menu stays open; shift-click selects + closes), trailing ''shift-click'' hint, semantics + tooltip updated; (4) i18n en+nl; (5) tests. The /permissions picker and roster badge paths are unchanged. Refs T-226, T-275, T-181, T-396.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-07-05 09:52:40.379', '2026-07-05 09:52:54.013', NULL, '56204043cd686ca0fcae06db735b72ef', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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);
+3
View File
@@ -29,6 +29,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Changed
- Permission mode: Ctrl/Cmd+Shift+M cycles the full list including
bypassPermissions, and the composer menu's bypass row unlocks via
shift-click — plain Ctrl/Cmd+M and plain clicks stay safe-trio only (T-510).
- Bundled Claude skills pruned to clide-specific ones (T-507): broken/duplicate
skills removed, repo-agnostic ones moved to user scope, and stale guidance in
the remaining five (paths, commands, icon API) brought back in line with the
+3 -1
View File
@@ -106,7 +106,9 @@
"tool.name.TodoWrite": { "translation": "TodoWrite" },
"tool.name.ExitPlanMode": { "translation": "ExitPlanMode" },
"permissionControl.semantics": { "translation": "permission mode: {mode}. Activate to change." },
"permissionControl.tooltip": { "translation": "Permission mode: {mode} — change (Ctrl/Cmd+M cycles)" },
"permissionControl.tooltip": { "translation": "Permission mode: {mode} — change (Ctrl/Cmd+M cycles; +Shift includes bypass)" },
"permissionControl.bypassHint": { "translation": "shift-click" },
"permissionControl.bypassSemantics": { "translation": "bypassPermissions — shift-click to enable" },
"permissionBadge.tooltip": { "translation": "Permission mode: {mode}. Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions." },
"permissionBadge.semantics": { "translation": "Permission mode: {label}" },
"card.expand": { "translation": "Expand" },
+3 -1
View File
@@ -106,7 +106,9 @@
"tool.name.TodoWrite": { "translation": "Takenlijst" },
"tool.name.ExitPlanMode": { "translation": "Planmodus verlaten" },
"permissionControl.semantics": { "translation": "permissiemodus: {mode}. Activeer om te wijzigen." },
"permissionControl.tooltip": { "translation": "Permissiemodus: {mode} — wijzigen (Ctrl/Cmd+M wisselt)" },
"permissionControl.tooltip": { "translation": "Permissiemodus: {mode} — wijzigen (Ctrl/Cmd+M wisselt; +Shift ook bypass)" },
"permissionControl.bypassHint": { "translation": "shift-klik" },
"permissionControl.bypassSemantics": { "translation": "bypassPermissions — shift-klik om in te schakelen" },
"permissionBadge.tooltip": { "translation": "Permissiemodus: {mode}. Klik om te wisselen tussen default/acceptEdits/plan; Shift-klik voor bypassPermissions." },
"permissionBadge.semantics": { "translation": "Permissiemodus: {label}" },
"card.expand": { "translation": "Uitvouwen" },
+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 {
@@ -373,6 +373,39 @@ void main() {
await tester.pump();
expect(cycles, 0);
});
testWidgets('Ctrl+Shift+M fires onCycleModeFull, not onCycleMode (T-510)', (tester) async {
var safe = 0, full = 0;
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onCycleMode: () => safe++, onCycleModeFull: () => full++)));
await tester.tap(find.byType(EditableText));
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyM);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump();
expect(full, 1);
expect(safe, 0);
expect(tester.widget<EditableText>(find.byType(EditableText)).controller.text, isEmpty);
});
testWidgets('plain Ctrl+M still cycles the safe trio only', (tester) async {
var safe = 0, full = 0;
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onCycleMode: () => safe++, onCycleModeFull: () => full++)));
await tester.tap(find.byType(EditableText));
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyM);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump();
expect(safe, 1);
expect(full, 0);
});
});
group('ClaudeComposer external focus node (T-227)', () {
@@ -90,6 +90,20 @@ void main() {
});
});
group('nextPermissionMode (T-510)', () {
test('cycles the full list including bypass and wraps', () {
expect(nextPermissionMode('default'), 'acceptEdits');
expect(nextPermissionMode('acceptEdits'), 'plan');
expect(nextPermissionMode('plan'), 'bypassPermissions');
expect(nextPermissionMode('bypassPermissions'), 'default');
});
test('unknown restarts at default', () {
expect(nextPermissionMode('whatever'), 'default');
expect(kFullPermissionCycle, contains('bypassPermissions'));
});
});
group('statusSegmentsAroundMode (T-226)', () {
test('splits model (leading) from ctx/cost/rate (trailing), mode excluded', () {
const s = SessionStatus(model: 'claude-opus-4-7', permissionMode: 'plan', contextTokens: 21000, cost: 0.05);
@@ -1,10 +1,11 @@
/// 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.
/// safe trio + a shift-click-gated bypass row (T-510), 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/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -48,7 +49,7 @@ void main() {
expect(find.text('accept-edits'), findsNothing, reason: 'menu closes on select');
});
testWidgets('the bypass row is disabled — selecting it does nothing', (tester) async {
testWidgets('a plain click on the bypass row does nothing and keeps the menu open', (tester) async {
var picked = '';
await pump(tester, 'default', (m) => picked = m);
await tester.pump();
@@ -56,7 +57,24 @@ void main() {
await tester.pump();
await tester.tap(find.text('bypass'));
await tester.pump();
expect(picked, '', reason: 'bypass stays behind the cockpit guard (T-181)');
expect(picked, '', reason: 'bypass is never a plain click away (T-510)');
expect(find.text('bypass'), findsOneWidget, reason: 'menu stays open for a retry with shift');
});
testWidgets('shift-click on the bypass row selects it and closes the menu (T-510)', (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.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.tap(find.text('bypass'));
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.pump();
expect(picked, 'bypassPermissions');
expect(find.text('bypass'), findsNothing, reason: 'menu closes after the shift-select');
});
testWidgets('per-mode helpers map labels/colours/icons', (tester) async {