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:
2026-06-03 13:45:52 +02:00
co-authored by Claude Opus 4.8
parent bda53b3bb1
commit 6c4c48bfc2
9 changed files with 217 additions and 12 deletions
@@ -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<EditableText>(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());
@@ -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);
});
});
}