add a permission-mode cycler to the primary Claude pane
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<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.)
|
||||
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) {
|
||||
|
||||
@@ -100,18 +100,31 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
// 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 = <Widget>[];
|
||||
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<ClaudePane> {
|
||||
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<ClaudePane> {
|
||||
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<ClaudePane> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> 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) {
|
||||
|
||||
@@ -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 <taskId> <toSessionId>
|
||||
CommandContribution(
|
||||
id: 'claude.task.reassign',
|
||||
|
||||
Reference in New Issue
Block a user