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