From 6c4c48bfc2e9d2c85d3f2a3605391ebf917e38b4 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 3 Jun 2026 13:45:52 +0200 Subject: [PATCH] add a permission-mode cycler to the primary Claude pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T-226. The primary pane showed the permission mode but had no way to change it (only the cockpit roster did, T-181). Add three affordances, all cycling the safe trio default -> acceptEdits -> plan over the stream-json control channel: - Ctrl/Cmd+M while the composer is focused, intercepted at the composer so it targets that pane's session. Shift+Tab (the CLI chord) is deliberately NOT used — Tab/Shift+Tab are real a11y focus-traversal intents since T-204. - The status-line mode label is now an interactive badge (ClideTappable): click, or focus + Enter/Space, cycles it. - A "Claude: Cycle permission mode" palette command targeting the primary session. bypassPermissions stays out of every cycle path here — it's reachable only via the cockpit's explicit confirm (T-181). Shared helpers (nextSafePermissionMode, statusSegmentsAroundMode) live in claude_status; the cockpit's existing copy is left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 9 +++ .pql/changelog/tickets/2026-06.sql | 6 ++ CHANGELOG.md | 4 + lib/builtin/claude/src/claude_composer.dart | 14 ++++ lib/builtin/claude/src/claude_pane.dart | 81 ++++++++++++++++--- lib/builtin/claude/src/claude_status.dart | 28 +++++++ lib/builtin/claude/src/extension.dart | 15 ++++ test/builtin/claude/claude_composer_test.dart | 36 +++++++++ test/builtin/claude/claude_status_test.dart | 36 +++++++++ 9 files changed, 217 insertions(+), 12 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 7f181ac5..836c2626 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -87,3 +87,12 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, Scope addition (2026-06-03, from T-220): clide status must surface the focused file + selection from BOTH the editor (EditorRegistry.active, already populated) AND the read-only markdown/decisions reader''s current file (D-81 ReaderNav), since viewer files rightly do not live in EditorRegistry. So ''focused file'' = whichever of {active editor buffer, active reader doc} the user is currently looking at. Pane field comes from the T-219 view-pane snapshot; git summary from git.status; layout from the arrangement.', NULL, '2026-06-03 10:59:31', '2026-06-03 10:59:31', '2026-06-03 10:59:31', NULL, 'd248f5407a3e698ad641ed9393b71607', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-221', 'status', 'backlog', 'in_progress', NULL, '2026-06-03 11:17:49', '2026-06-03 11:17:49', '2026-06-03 11:17:49', NULL, '243f91496d8b597612063bb3bf1ba5a6', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-226', 'status', 'ready', 'in_progress', NULL, '2026-06-03 11:38:13', '2026-06-03 11:38:13', '2026-06-03 11:38:13', NULL, '183df518819e0e8ab0822ead80d66dc7', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-226', 'description', 'Gap found while dogfooding (2026-06-03): the Claude pane shows the permission mode in its status line (permissionModeLabel, claude_status.dart:44) but has NO quick way to CHANGE it, unlike the CLI where Shift+Tab cycles work modes (plan / read / edit / yolo == plan, default, acceptEdits, bypassPermissions). Today the only interactive setter is the team meta-sidebar per-agent control (claude_meta_sidebar.dart:342); the primary pane has none. There is a command claude.agent.set-permission-mode (extension.dart:148) but it needs an explicit id + explicit mode, and there is no ''cycle'' verb. setPermissionMode() over the stream-json control channel already works (stream_json_session.dart:671, D-78). Scope: (1) add a mode-cycle action that targets the focused/primary session and advances the safe trio default -> acceptEdits -> plan -> default (bypassPermissions only via a confirmed/explicit path, per the T-181 footgun guard); (2) bind it to Shift+Tab INTERCEPTED at the focused Claude composer -- note shift+tab is globally focus.previous (default.yaml:31), so this needs consumer-level interception like the Vim editor (D-82), falling back to focus traversal elsewhere; (3) make the status-line mode label a clickable badge that cycles on click (the ''cockpit badge'' the stream_json_session comment already promises at line 668-669). Relates to T-181, D-77/D-78, D-82.', 'Gap found while dogfooding (2026-06-03): the Claude pane shows the permission mode in its status line (permissionModeLabel, claude_status.dart:44) but has NO quick way to CHANGE it, unlike the CLI where Shift+Tab cycles work modes (plan / read / edit / yolo == plan, default, acceptEdits, bypassPermissions). Today the only interactive setter is the team meta-sidebar per-agent control (claude_meta_sidebar.dart:342); the primary pane has none. There is a command claude.agent.set-permission-mode (extension.dart:148) but it needs an explicit id + explicit mode, and there is no ''cycle'' verb. setPermissionMode() over the stream-json control channel already works (stream_json_session.dart:671, D-78). Scope: (1) add a mode-cycle action that targets the focused/primary session and advances the safe trio default -> acceptEdits -> plan -> default (bypassPermissions only via a confirmed/explicit path, per the T-181 footgun guard); (2) bind it to Shift+Tab INTERCEPTED at the focused Claude composer -- note shift+tab is globally focus.previous (default.yaml:31), so this needs consumer-level interception like the Vim editor (D-82), falling back to focus traversal elsewhere; (3) make the status-line mode label a clickable badge that cycles on click (the ''cockpit badge'' the stream_json_session comment already promises at line 668-669). Relates to T-181, D-77/D-78, D-82. + +Refinement (2026-06-03): Shift+Tab is REJECTED as the trigger — Tab/Shift+Tab are real a11y focus-traversal intents (focus.next/focus.previous) since T-204, so hijacking Shift+Tab would break keyboard navigation. Instead: (a) Ctrl/Cmd+M cycles, intercepted at the focused Claude composer (so it targets that pane''s session, no global ''find focused session'' needed); (b) the status-line mode label becomes a FOCUSABLE button — cycles on click and on Enter/Space when focused (a11y-native, Tab reaches it); (c) a ''Claude: Cycle permission mode'' palette command. Safe trio default->acceptEdits->plan->default; bypassPermissions only via explicit confirmed path.', NULL, '2026-06-03 11:38:13', '2026-06-03 11:38:13', '2026-06-03 11:38:13', NULL, 'b67641486ea393fd30314a7202e4add7', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-181', 'status', 'done', 'in_progress', NULL, '2026-06-03 11:38:13', '2026-06-03 11:38:13', '2026-06-03 11:38:13', NULL, 'db3f97f062272f92dee5f266006d0c0d', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-181', 'description', 'Per-agent permission-mode control in the cockpit roster (extends T-171). Each roster row shows a mode badge (D default / A acceptEdits / P plan). Click cycles the SAFE trio default -> acceptEdits -> plan and sends set_permission_mode to that agent session (a confirmed stream-json control subtype). bypassPermissions is a footgun, so it is reachable only on SHIFT-click (behind a confirm), and the tooltip documents both behaviours. Acceptance: clicking the badge cycles the safe modes and the session receives set_permission_mode; shift-click can reach bypass behind a confirm; the badge reflects the live mode (T-157 status); tooltip explains click vs shift-click; widget + transport tests. Wireframe: docs/design/wireframes/claude-prompts/05-team-cockpit-sidebar.png. Blocked by T-169 (orchestrator).', 'Per-agent permission-mode control in the cockpit roster (extends T-171). Each roster row shows a mode badge (D default / A acceptEdits / P plan). Click cycles the SAFE trio default -> acceptEdits -> plan and sends set_permission_mode to that agent session (a confirmed stream-json control subtype). bypassPermissions is a footgun, so it is reachable only on SHIFT-click (behind a confirm), and the tooltip documents both behaviours. Acceptance: clicking the badge cycles the safe modes and the session receives set_permission_mode; shift-click can reach bypass behind a confirm; the badge reflects the live mode (T-157 status); tooltip explains click vs shift-click; widget + transport tests. Wireframe: docs/design/wireframes/claude-prompts/05-team-cockpit-sidebar.png. Blocked by T-169 (orchestrator). + +Verified done (2026-06-03): the cockpit per-agent permission-mode cycle badge is implemented and wired in claude_meta_sidebar.dart — _PermissionModeBadge (safe cycle default->acceptEdits->plan, Shift-click bypass behind a confirm, live mode from SessionStatus). No further work; closing. The primary-pane equivalent is T-226.', NULL, '2026-06-03 11:41:17', '2026-06-03 11:41:17', '2026-06-03 11:41:17', NULL, '1a0f586aa78d40cb14e96592912176ca', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-181', 'status', 'in_progress', 'done', NULL, '2026-06-03 11:41:17', '2026-06-03 11:41:17', '2026-06-03 11:41:17', NULL, '791d993f7f46e80bbb5a4608f55a6038', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 0aa6bc3f..9f0a4655 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -225,3 +225,9 @@ Acceptance: INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-221', 'task', 'T-218', 'Add a clide status one-shot snapshot command', 'Gap 6. Add a `status` command to the dispatcher (currently unknown -> exit 3) returning a single snapshot: active pane, focused file + selection, git summary, layout — the natural first call an agent makes to orient. Acceptance: `clide status` returns a structured snapshot with exit 0. Depends on the pane/editor registries (C1/C2) for its pane and file fields. Scope addition (2026-06-03, from T-220): clide status must surface the focused file + selection from BOTH the editor (EditorRegistry.active, already populated) AND the read-only markdown/decisions reader''s current file (D-81 ReaderNav), since viewer files rightly do not live in EditorRegistry. So ''focused file'' = whichever of {active editor buffer, active reader doc} the user is currently looking at. Pane field comes from the T-219 view-pane snapshot; git summary from git.status; layout from the arrangement.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 11:17:49', NULL, 'd14252d59f0932f126f32ee73ff09d69', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-226', 'task', NULL, 'Shift+Tab work-mode cycler on the focused Claude pane', 'Gap found while dogfooding (2026-06-03): the Claude pane shows the permission mode in its status line (permissionModeLabel, claude_status.dart:44) but has NO quick way to CHANGE it, unlike the CLI where Shift+Tab cycles work modes (plan / read / edit / yolo == plan, default, acceptEdits, bypassPermissions). Today the only interactive setter is the team meta-sidebar per-agent control (claude_meta_sidebar.dart:342); the primary pane has none. There is a command claude.agent.set-permission-mode (extension.dart:148) but it needs an explicit id + explicit mode, and there is no ''cycle'' verb. setPermissionMode() over the stream-json control channel already works (stream_json_session.dart:671, D-78). Scope: (1) add a mode-cycle action that targets the focused/primary session and advances the safe trio default -> acceptEdits -> plan -> default (bypassPermissions only via a confirmed/explicit path, per the T-181 footgun guard); (2) bind it to Shift+Tab INTERCEPTED at the focused Claude composer -- note shift+tab is globally focus.previous (default.yaml:31), so this needs consumer-level interception like the Vim editor (D-82), falling back to focus traversal elsewhere; (3) make the status-line mode label a clickable badge that cycles on click (the ''cockpit badge'' the stream_json_session comment already promises at line 668-669). Relates to T-181, D-77/D-78, D-82. + +Refinement (2026-06-03): Shift+Tab is REJECTED as the trigger — Tab/Shift+Tab are real a11y focus-traversal intents (focus.next/focus.previous) since T-204, so hijacking Shift+Tab would break keyboard navigation. Instead: (a) Ctrl/Cmd+M cycles, intercepted at the focused Claude composer (so it targets that pane''s session, no global ''find focused session'' needed); (b) the status-line mode label becomes a FOCUSABLE button — cycles on click and on Enter/Space when focused (a11y-native, Tab reaches it); (c) a ''Claude: Cycle permission mode'' palette command. Safe trio default->acceptEdits->plan->default; bypassPermissions only via explicit confirmed path.', 'in_progress', 'medium', NULL, NULL, 'D-78', '2026-06-03 09:37:54', '2026-06-03 11:38:13', NULL, '5dd59c6cbf6f5586fef7c5f873bdfde4', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-181', 'task', 'T-164', 'Per-agent permission-mode cycle badge in the cockpit roster', 'Per-agent permission-mode control in the cockpit roster (extends T-171). Each roster row shows a mode badge (D default / A acceptEdits / P plan). Click cycles the SAFE trio default -> acceptEdits -> plan and sends set_permission_mode to that agent session (a confirmed stream-json control subtype). bypassPermissions is a footgun, so it is reachable only on SHIFT-click (behind a confirm), and the tooltip documents both behaviours. Acceptance: clicking the badge cycles the safe modes and the session receives set_permission_mode; shift-click can reach bypass behind a confirm; the badge reflects the live mode (T-157 status); tooltip explains click vs shift-click; widget + transport tests. Wireframe: docs/design/wireframes/claude-prompts/05-team-cockpit-sidebar.png. Blocked by T-169 (orchestrator). + +Verified done (2026-06-03): the cockpit per-agent permission-mode cycle badge is implemented and wired in claude_meta_sidebar.dart — _PermissionModeBadge (safe cycle default->acceptEdits->plan, Shift-click bypass behind a confirm, live mode from SessionStatus). No further work; closing. The primary-pane equivalent is T-226.', 'done', 'low', NULL, NULL, 'D-77', '2026-05-25 09:16:05', '2026-06-03 11:41:17', NULL, '942902ee2825b610673362e5c0add5f7', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 cf90b92a..ab0270ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Cycle Claude's permission mode from the primary pane: Ctrl/Cmd+M while the + composer is focused, a clickable mode badge in the status line, or the + "Claude: Cycle permission mode" palette command — steps default → accept-edits + → plan (bypass stays behind the cockpit's confirm). (T-226) - `clide pane list` now reflects the live GUI tabs the user sees (Claude, Files, Editor, viewers) alongside PTY panes — each with a stable id, slot, title, and active/visible state — by snapshotting the kernel panel layout at request time. diff --git a/lib/builtin/claude/src/claude_composer.dart b/lib/builtin/claude/src/claude_composer.dart index 04edf132..58a1fe0f 100644 --- a/lib/builtin/claude/src/claude_composer.dart +++ b/lib/builtin/claude/src/claude_composer.dart @@ -48,6 +48,7 @@ class ClaudeComposer extends StatefulWidget { this.slashCommandsResolver, this.onInterrupt, this.busy = false, + this.onCycleMode, this.initialValue, this.onDraftChanged, this.history = const [], @@ -75,6 +76,11 @@ 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. + final VoidCallback? onCycleMode; + /// Whether a turn is in flight; shows the Stop affordance. final bool busy; @@ -229,6 +235,14 @@ 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.) + final mod = HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed; + if (mod && e.logicalKey == LogicalKeyboardKey.keyM && 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). if (e.logicalKey == LogicalKeyboardKey.escape) { diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 3731d431..eca51854 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -100,18 +100,31 @@ class _ClaudePaneState extends State { // skills count from ClaudeConfig (T-154). Null when there's nothing yet. Widget? _statusWidget(SurfaceTokens tokens) { final skills = formatSkillsLabel(activeClaudeConfig?.skills.length ?? 0); - final parts = [ - if (!_status.isEmpty) formatStatusLine(_status), - if (skills != null) skills, - ]; - if (parts.isEmpty) return null; - return ClideText( - parts.join(' · '), - fontSize: clideFontSmall, - fontFamily: clideMonoFamily, - color: tokens.statusBarForeground, - maxLines: 1, - ); + if (_status.isEmpty && skills == null) return null; + + final seg = statusSegmentsAroundMode(_status); + final mode = _status.permissionMode; + + Widget text(String t) => ClideText(t, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusBarForeground, maxLines: 1); + + final children = []; + void add(Widget w) { + if (children.isNotEmpty) { + children.add(ClideText(' · ', fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.globalTextMuted, maxLines: 1)); + } + children.add(w); + } + + if (seg.leading != null) add(text(seg.leading!)); + // The permission-mode segment is an interactive badge — click or + // Enter/Space (when focused) cycles it (T-226). + if (mode != null) { + add(_ModeBadge(label: permissionModeLabel(mode), tokens: tokens, onCycle: _session != null ? _cycleMode : null)); + } + if (seg.trailing != null) add(text(seg.trailing!)); + if (skills != null) add(text(skills)); + + return Row(mainAxisSize: MainAxisSize.min, children: children); } // Rebuild when the Claude environment changes (e.g. skills load or a @@ -282,6 +295,16 @@ class _ClaudePaneState extends State { if (list.isEmpty || list.last != text) list.add(text); } + /// 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). + void _cycleMode() { + final s = _session; + if (s == null) return; + s.setPermissionMode(nextSafePermissionMode(_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 @@ -428,6 +451,7 @@ class _ClaudePaneState extends State { enabled: _session != null, busy: busySnap.data ?? false, onInterrupt: _session?.interrupt, + onCycleMode: _cycleMode, onSubmit: _send, pasteResolver: () => resolveClipboardAttachment(const NativeClipboard()), initialValue: _sessionId == null ? null : _drafts[_sessionId], @@ -462,3 +486,36 @@ class _ClaudePaneState extends State { ); } } + +/// Interactive permission-mode badge in the status line (T-226). Click, or +/// focus + Enter/Space, cycles the safe trio (ClideTappable handles the +/// ActivateIntent). A null [onCycle] (no live session) renders it inert. +class _ModeBadge extends StatelessWidget { + const _ModeBadge({required this.label, required this.tokens, required this.onCycle}); + + final String label; + final SurfaceTokens tokens; + final VoidCallback? onCycle; + + @override + Widget build(BuildContext context) { + return Semantics( + button: onCycle != null, + label: 'permission mode: $label. Activate to cycle.', + excludeSemantics: true, + child: ClideTappable( + onTap: onCycle, + tooltip: 'Permission mode — click or Ctrl/Cmd+M to cycle (default · accept-edits · plan)', + builder: (context, hovered, _) => Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: hovered ? tokens.listItemHoverBackground : null, + border: Border.all(color: hovered && onCycle != null ? tokens.globalFocus : tokens.globalBorder), + borderRadius: BorderRadius.circular(4), + ), + child: ClideText(label, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusBarForeground, maxLines: 1), + ), + ), + ); + } +} diff --git a/lib/builtin/claude/src/claude_status.dart b/lib/builtin/claude/src/claude_status.dart index c5b01689..5c98bd82 100644 --- a/lib/builtin/claude/src/claude_status.dart +++ b/lib/builtin/claude/src/claude_status.dart @@ -40,6 +40,34 @@ String shortModelLabel(String model) { return s; } +/// 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). +const List kSafePermissionCycle = ['default', 'acceptEdits', 'plan']; + +/// The next mode in [kSafePermissionCycle] after [current] (wraps). An +/// unknown or `bypassPermissions` current restarts the cycle at `default`. +String nextSafePermissionMode(String current) { + final i = kSafePermissionCycle.indexOf(current); + return kSafePermissionCycle[(i + 1) % kSafePermissionCycle.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 +/// null when there's nothing to show. +({String? leading, String? trailing}) statusSegmentsAroundMode(SessionStatus s) { + final trailing = [ + if (s.contextTokens != null) _contextLabel(s), + if (s.cost != null) '\$${s.cost!.toStringAsFixed(2)}', + if (s.rateLimitInfo != null) s.rateLimitInfo!, + ].join(' · '); + return ( + leading: s.model != null ? shortModelLabel(s.model!) : null, + trailing: trailing.isEmpty ? null : trailing, + ); +} + /// Friendly label for Claude's permission modes. String permissionModeLabel(String mode) { switch (mode) { diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index d439a19a..a182b0a5 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:clide/clide.dart'; import 'package:clide/builtin/claude/src/claude_config.dart'; +import 'package:clide/builtin/claude/src/claude_status.dart' show nextSafePermissionMode; import 'package:clide/builtin/claude/src/claude_session_host.dart'; import 'package:clide/builtin/claude/src/session_orchestrator.dart'; import 'package:clide/builtin/claude/src/pane_context_status.dart'; @@ -162,6 +163,20 @@ class ClaudeExtension extends ClideExtension { return IpcResponse.ok(id: '', data: {'id': id, 'mode': mode, 'status': 'sent'}); }, ), + // T-226: cycle the primary session's permission mode through the safe + // trio. Palette-discoverable counterpart to the composer's Ctrl/Cmd+M. + CommandContribution( + id: 'claude.mode.cycle', + command: 'claude.mode.cycle', + title: 'Claude: Cycle permission mode', + run: (_) async { + final managed = _orchestrator?.byId('primary'); + if (managed == null) return IpcResponse.ok(id: '', data: const {'error': 'no primary session'}); + final next = nextSafePermissionMode(managed.session.status.permissionMode ?? 'default'); + managed.session.setPermissionMode(next); + return IpcResponse.ok(id: '', data: {'mode': next, 'status': 'sent'}); + }, + ), // Usage: clide claude.task.reassign CommandContribution( id: 'claude.task.reassign', diff --git a/test/builtin/claude/claude_composer_test.dart b/test/builtin/claude/claude_composer_test.dart index b893a274..27d5e179 100644 --- a/test/builtin/claude/claude_composer_test.dart +++ b/test/builtin/claude/claude_composer_test.dart @@ -336,6 +336,42 @@ void main() { }); }); + group('ClaudeComposer mode cycle (T-226)', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + testWidgets('Ctrl+M fires onCycleMode and is consumed', (tester) async { + var cycles = 0; + await tester.pumpWidget(harness( + f, + ClaudeComposer(onSubmit: (_) {}, onCycleMode: () => cycles++), + )); + 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(cycles, 1); + // The chord didn't type an 'm' into the field. + expect(tester.widget(find.byType(EditableText)).controller.text, isEmpty); + }); + + testWidgets('plain m types normally (no modifier, no cycle)', (tester) async { + var cycles = 0; + await tester.pumpWidget(harness( + f, + ClaudeComposer(onSubmit: (_) {}, onCycleMode: () => cycles++), + )); + await tester.enterText(find.byType(EditableText), 'm'); + await tester.pump(); + expect(cycles, 0); + }); + }); + group('ClaudeComposer external focus node (T-227)', () { late KernelFixture f; setUp(() async => f = await KernelFixture.create()); diff --git a/test/builtin/claude/claude_status_test.dart b/test/builtin/claude/claude_status_test.dart index 1ab77466..defa9aac 100644 --- a/test/builtin/claude/claude_status_test.dart +++ b/test/builtin/claude/claude_status_test.dart @@ -81,4 +81,40 @@ void main() { expect(line, contains('\$0.05')); }); }); + + group('nextSafePermissionMode (T-226)', () { + test('cycles the safe trio and wraps', () { + expect(nextSafePermissionMode('default'), 'acceptEdits'); + expect(nextSafePermissionMode('acceptEdits'), 'plan'); + expect(nextSafePermissionMode('plan'), 'default'); + }); + + test('bypassPermissions / unknown restarts at default (never cycles into bypass)', () { + expect(nextSafePermissionMode('bypassPermissions'), 'default'); + expect(nextSafePermissionMode('whatever'), 'default'); + expect(kSafePermissionCycle, isNot(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, + ); + final seg = statusSegmentsAroundMode(s); + expect(seg.leading, 'opus 4.7'); + expect(seg.trailing, contains('21k ctx')); + expect(seg.trailing, contains('\$0.05')); + expect(seg.trailing, isNot(contains('plan'))); // mode is its own badge + }); + + test('nulls when nothing to show on a side', () { + final seg = statusSegmentsAroundMode(const SessionStatus(permissionMode: 'default')); + expect(seg.leading, isNull); + expect(seg.trailing, isNull); + }); + }); }