diff --git a/.pql/changelog/ticket_history/2026-07.sql b/.pql/changelog/ticket_history/2026-07.sql index 11f0b4ff..6061f34e 100644 --- a/.pql/changelog/ticket_history/2026-07.sql +++ b/.pql/changelog/ticket_history/2026-07.sql @@ -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 — 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 (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 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; diff --git a/.pql/changelog/ticket_idmap/2026-07.sql b/.pql/changelog/ticket_idmap/2026-07.sql index 11bad85f..c815b867 100644 --- a/.pql/changelog/ticket_idmap/2026-07.sql +++ b/.pql/changelog/ticket_idmap/2026-07.sql @@ -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); diff --git a/.pql/changelog/tickets/2026-07.sql b/.pql/changelog/tickets/2026-07.sql index aa1409e6..e94e566e 100644 --- a/.pql/changelog/tickets/2026-07.sql +++ b/.pql/changelog/tickets/2026-07.sql @@ -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 — 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 (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 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); diff --git a/CHANGELOG.md b/CHANGELOG.md index d24025b7..bfad8254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/assets/i18n/en_us/builtin.claude.json b/assets/i18n/en_us/builtin.claude.json index f2f7659a..2f73fc73 100644 --- a/assets/i18n/en_us/builtin.claude.json +++ b/assets/i18n/en_us/builtin.claude.json @@ -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" }, diff --git a/assets/i18n/nl_nl/builtin.claude.json b/assets/i18n/nl_nl/builtin.claude.json index fd7295d2..39e23deb 100644 --- a/assets/i18n/nl_nl/builtin.claude.json +++ b/assets/i18n/nl_nl/builtin.claude.json @@ -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" }, diff --git a/lib/builtin/claude/src/claude_composer.dart b/lib/builtin/claude/src/claude_composer.dart index 342d5cf5..7073c0f9 100644 --- a/lib/builtin/claude/src/claude_composer.dart +++ b/lib/builtin/claude/src/claude_composer.dart @@ -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 { 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). diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 89b86d3e..bf9316dc 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -613,14 +613,23 @@ class _ClaudePaneState extends State { /// 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 { busy: busySnap.data ?? false, onInterrupt: _session?.interrupt, onCycleMode: _cycleMode, + onCycleModeFull: _cycleModeFull, permissionMode: _status.permissionMode, onSetPermissionMode: _session != null ? (m) => _session!.setPermissionMode(m) : null, onSubmit: _send, diff --git a/lib/builtin/claude/src/claude_status.dart b/lib/builtin/claude/src/claude_status.dart index 2c4baa06..cfc8038a 100644 --- a/lib/builtin/claude/src/claude_status.dart +++ b/lib/builtin/claude/src/claude_status.dart @@ -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 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 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 diff --git a/lib/builtin/claude/src/permission_mode_control.dart b/lib/builtin/claude/src/permission_mode_control.dart index c0cdbde8..350f6078 100644 --- a/lib/builtin/claude/src/permission_mode_control.dart +++ b/lib/builtin/claude/src/permission_mode_control.dart @@ -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 { super.dispose(); } - List _entries(SurfaceTokens tokens) => [ + List _entries(BuildContext ctx, SurfaceTokens tokens) => [ for (final m in kSafePermissionCycle) ClideMenuItem( leading: permissionModeIcon(m), @@ -76,13 +79,33 @@ class _PermissionModeControlState extends State { 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 { 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 { 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( diff --git a/lib/test_app.dart b/lib/test_app.dart index 34c97bf1..48fa612f 100644 --- a/lib/test_app.dart +++ b/lib/test_app.dart @@ -284,7 +284,15 @@ class _ClideTestAppState extends State { // PqlExtension precedes GraphExtension: the graph view depends on it // (activateAll resolves the order, but this documents the dependency). - final extensions = [DiffExtension(), FilesExtension(), GitExtension(), TerminalExtension(), PqlExtension(), GraphExtension(), CanvasExtension()]; + final extensions = [ + DiffExtension(), + FilesExtension(), + GitExtension(), + TerminalExtension(), + PqlExtension(), + GraphExtension(), + CanvasExtension(), + ]; for (final ext in extensions) { try { diff --git a/test/builtin/claude/claude_composer_test.dart b/test/builtin/claude/claude_composer_test.dart index 519d29f2..f0c76e8b 100644 --- a/test/builtin/claude/claude_composer_test.dart +++ b/test/builtin/claude/claude_composer_test.dart @@ -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(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)', () { diff --git a/test/builtin/claude/claude_status_test.dart b/test/builtin/claude/claude_status_test.dart index d7b8237e..215d6322 100644 --- a/test/builtin/claude/claude_status_test.dart +++ b/test/builtin/claude/claude_status_test.dart @@ -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); diff --git a/test/builtin/claude/permission_mode_control_test.dart b/test/builtin/claude/permission_mode_control_test.dart index ec3bd29e..374e4d0c 100644 --- a/test/builtin/claude/permission_mode_control_test.dart +++ b/test/builtin/claude/permission_mode_control_test.dart @@ -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 {