intercept /model: arg sets the model, bare opens a picker (T-408)

Typed into the conversation view, /model was forwarded to the session's
stdin as message text — the CLI's interactive picker only exists in its
own TUI, so nothing happened. clide now owns it like /clear//resume//fork
(T-156).

/model <name> sends a set_model control_request (verified against
claude 2.1.175: subtype accepted alongside set_permission_mode;
"default" resets to the CLI's configured model) with an optimistic
status merge, rolled back with a toast if the CLI rejects the name.
Bare /model swaps a picker card into the interaction zone (D-78) —
numbers / arrows + Enter / Esc, mirroring the prompt card's shortcuts.

The model list comes from the `initialize` handshake response, which
the session now always sends — the spike verified it is side-effect-
free, and it previously went out only when MCP servers were hosted.
Until the response lands the picker falls back to the stable aliases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 09:04:29 +02:00
co-authored by Claude Fable 5
parent 073c0893b5
commit 1d6682df18
11 changed files with 638 additions and 21 deletions
+48
View File
@@ -14,12 +14,14 @@ import 'dart:convert';
import 'package:clide/builtin/claude/src/claude_composer.dart';
import 'package:clide/builtin/claude/src/claude_pane.dart';
import 'package:clide/builtin/claude/src/conversation_view.dart';
import 'package:clide/builtin/claude/src/model_picker_card.dart';
import 'package:clide/builtin/claude/src/session_naming.dart';
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/session_picker.dart';
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -200,6 +202,52 @@ void main() {
expect(forkedWith, primarySessionId('/repo-a'));
});
testWidgets('/model with an argument sends set_model, never a user message (T-408)', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
final proc = created.single;
await act(tester, () => composer(tester).onSubmit('/model sonnet'));
final sent = proc.writes.map((w) => jsonDecode(w) as Map).toList();
final setModel = sent.where((m) => (m['request'] as Map?)?['subtype'] == 'set_model').toList();
expect(setModel, hasLength(1));
expect((setModel.single['request'] as Map)['model'], 'sonnet');
expect(sent.any((m) => m['type'] == 'user'), isFalse, reason: '/model must not be forwarded as message text');
expect(find.byType(ModelPickerCard), findsNothing);
});
testWidgets('bare /model swaps the picker in; picking sends set_model and restores the composer (T-408)', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
final proc = created.single;
await act(tester, () => composer(tester).onSubmit('/model'));
expect(find.byType(ModelPickerCard), findsOneWidget);
expect(find.byType(ClaudeComposer), findsNothing, reason: 'the picker takes the interaction zone (D-78)');
tester.widget<ModelPickerCard>(find.byType(ModelPickerCard)).onPick('opus');
await tester.pump();
expect(find.byType(ModelPickerCard), findsNothing);
expect(find.byType(ClaudeComposer), findsOneWidget);
final setModel = proc.writes.map((w) => jsonDecode(w) as Map).where((m) => (m['request'] as Map?)?['subtype'] == 'set_model').toList();
expect((setModel.single['request'] as Map)['model'], 'opus');
});
testWidgets('Esc cancels the /model picker without sending (T-408)', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
final proc = created.single;
await act(tester, () => composer(tester).onSubmit('/model'));
expect(find.byType(ModelPickerCard), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(find.byType(ModelPickerCard), findsNothing);
expect(find.byType(ClaudeComposer), findsOneWidget);
expect(proc.writes.any((w) => w.contains('set_model')), isFalse);
});
testWidgets('cycling permission mode sends a control message', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
final proc = created.single;
@@ -0,0 +1,88 @@
/// Widget tests for [ModelPickerCard] — the bare `/model` interaction-zone
/// picker (T-408): rendering, current-model marking, number-key / arrow+Enter
/// selection, and Esc cancel.
library;
import 'package:clide/builtin/claude/src/model_picker_card.dart';
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
const models = [
ModelOption(value: 'default', displayName: 'Default', description: 'recommended'),
ModelOption(value: 'sonnet', displayName: 'Sonnet', description: 'fast'),
ModelOption(value: 'opus', displayName: 'Opus', description: 'most capable'),
];
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
group('modelOptionIsCurrent', () {
test('matches by exact value or alias containment, never for default', () {
const sonnet = ModelOption(value: 'sonnet', displayName: 'Sonnet');
expect(modelOptionIsCurrent(sonnet, 'sonnet'), isTrue);
expect(modelOptionIsCurrent(sonnet, 'claude-sonnet-4-6'), isTrue);
expect(modelOptionIsCurrent(sonnet, 'claude-opus-4-8'), isFalse);
expect(modelOptionIsCurrent(sonnet, null), isFalse);
expect(modelOptionIsCurrent(const ModelOption(value: 'default', displayName: 'Default'), 'claude-opus-4-8'), isFalse);
});
});
testWidgets('renders every model with the current one marked', (tester) async {
await tester.pumpWidget(harness(f, ModelPickerCard(models: models, currentModel: 'claude-sonnet-4-6', onPick: (_) {}, onCancel: () {})));
expect(find.textContaining('Default'), findsOneWidget);
expect(find.textContaining('● Sonnet'), findsOneWidget); // current
expect(find.textContaining('○ Opus'), findsOneWidget);
expect(find.textContaining('most capable'), findsOneWidget);
});
testWidgets('tapping an entry picks its value', (tester) async {
String? picked;
await tester.pumpWidget(harness(f, ModelPickerCard(models: models, currentModel: null, onPick: (v) => picked = v, onCancel: () {})));
await tester.tap(find.textContaining('Opus'));
await tester.pump();
expect(picked, 'opus');
});
testWidgets('a number key picks directly', (tester) async {
String? picked;
await tester.pumpWidget(harness(f, ModelPickerCard(models: models, currentModel: null, onPick: (v) => picked = v, onCancel: () {})));
await tester.sendKeyEvent(LogicalKeyboardKey.digit2);
await tester.pump();
expect(picked, 'sonnet');
});
testWidgets('arrows move the highlight and Enter picks it', (tester) async {
String? picked;
await tester.pumpWidget(harness(f, ModelPickerCard(models: models, currentModel: 'claude-sonnet-4-6', onPick: (v) => picked = v, onCancel: () {})));
// Highlight starts on the current model (sonnet, index 1).
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // → opus
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(picked, 'opus');
});
testWidgets('Esc cancels without picking', (tester) async {
String? picked;
var cancelled = false;
await tester.pumpWidget(harness(f, ModelPickerCard(models: models, currentModel: null, onPick: (v) => picked = v, onCancel: () => cancelled = true)));
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(cancelled, isTrue);
expect(picked, isNull);
});
testWidgets('an out-of-range number key is ignored', (tester) async {
String? picked;
await tester.pumpWidget(harness(f, ModelPickerCard(models: models, currentModel: null, onPick: (v) => picked = v, onCancel: () {})));
await tester.sendKeyEvent(LogicalKeyboardKey.digit9);
await tester.pump();
expect(picked, isNull);
});
}
+20 -1
View File
@@ -43,14 +43,33 @@ void main() {
expect(clideOwnedCommand('/resume'), 'resume');
});
test('recognises /model with and without an argument (T-408)', () {
expect(clideOwnedCommand('/model'), 'model');
expect(clideOwnedCommand('/model sonnet'), 'model');
});
test('returns null for commands clide forwards to Claude', () {
expect(clideOwnedCommand('/model sonnet'), isNull);
expect(clideOwnedCommand('/compact'), isNull);
expect(clideOwnedCommand('not a command'), isNull);
expect(clideOwnedCommand('/clearairspace'), isNull); // token must be exactly "clear"
});
});
group('slashCommandArg', () {
test('returns the trimmed argument after the command token', () {
expect(slashCommandArg('/model sonnet'), 'sonnet');
expect(slashCommandArg('/model claude-opus-4-8 '), 'claude-opus-4-8');
expect(slashCommandArg('/model\tsonnet'), 'sonnet');
});
test('empty for a bare command, null for non-command input', () {
expect(slashCommandArg('/model'), '');
expect(slashCommandArg('/model '), '');
expect(slashCommandArg('hello'), isNull);
expect(slashCommandArg('/foo\nbar'), isNull);
});
});
group('activeSlashQuery', () {
test('matches a slash token at the cursor, including inline', () {
expect(activeSlashQuery('/mod', 4), const SlashQuery(start: 0, query: 'mod'));
@@ -165,10 +165,112 @@ void main() {
session.items.listen(items.add);
session.statusStream.listen(statuses.add);
session.start();
// start() always sends the `initialize` handshake (T-408); drop it so the
// write assertions below stay about what each test sends. The handshake
// itself is asserted in the 'initialize handshake' group.
proc.writes.clear();
});
tearDown(() => session.dispose());
group('initialize handshake + model list (T-408)', () {
test('start() sends the initialize handshake even with no MCP servers', () {
final p = _FakeProc();
final s = StreamJsonSession(p);
addTearDown(s.dispose);
s.start();
final init = jsonDecode(p.writes.single) as Map;
expect(init['type'], 'control_request');
expect((init['request'] as Map)['subtype'], 'initialize');
expect((init['request'] as Map)['sdkMcpServers'], isEmpty);
});
test('the initialize response populates availableModels', () async {
final p = _FakeProc();
final s = StreamJsonSession(p);
addTearDown(s.dispose);
s.start();
final rid = (jsonDecode(p.writes.single) as Map)['request_id'];
expect(s.availableModels, isEmpty);
p.emit(
jsonEncode({
'type': 'control_response',
'response': {
'subtype': 'success',
'request_id': rid,
'response': {
'commands': <dynamic>[],
'models': [
{'value': 'default', 'displayName': 'Default', 'description': 'recommended'},
{'value': 'sonnet', 'displayName': 'Sonnet'},
{'value': 12345}, // malformed entry → skipped
],
},
},
}),
);
await Future<void>.delayed(Duration.zero);
expect(s.availableModels, hasLength(2));
expect(s.availableModels[0].value, 'default');
expect(s.availableModels[0].description, 'recommended');
expect(s.availableModels[1].displayName, 'Sonnet');
expect(s.availableModels[1].description, isEmpty);
});
});
group('setModel (T-408)', () {
test('sends a set_model control_request and optimistically merges status', () async {
session.setModel('sonnet');
final sent = jsonDecode(proc.writes.single) as Map;
expect(sent['type'], 'control_request');
expect((sent['request'] as Map)['subtype'], 'set_model');
expect((sent['request'] as Map)['model'], 'sonnet');
await Future<void>.delayed(Duration.zero);
expect(statuses.last.model, 'sonnet');
});
test('setModel(default) does not guess the resolved model', () async {
session.setModel('default');
await Future<void>.delayed(Duration.zero);
expect(statuses, isEmpty, reason: 'only the CLI knows what default resolves to');
});
test('an error response rolls the model back and surfaces the message', () async {
final errors = <String>[];
session.modelErrors.listen(errors.add);
proc.emit(initEvent()); // model: claude-opus-4-7
await Future<void>.delayed(Duration.zero);
session.setModel('bogus-model');
await Future<void>.delayed(Duration.zero);
expect(statuses.last.model, 'bogus-model'); // optimistic
final rid = (jsonDecode(proc.writes.single) as Map)['request_id'];
proc.emit(
jsonEncode({
'type': 'control_response',
'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unknown model: bogus-model'},
}),
);
await Future<void>.delayed(Duration.zero);
expect(statuses.last.model, 'claude-opus-4-7', reason: 'rolled back');
expect(errors, ['Unknown model: bogus-model']);
});
test('a success response keeps the optimistic model', () async {
session.setModel('opus');
final rid = (jsonDecode(proc.writes.single) as Map)['request_id'];
proc.emit(
jsonEncode({
'type': 'control_response',
'response': {'subtype': 'success', 'request_id': rid},
}),
);
await Future<void>.delayed(Duration.zero);
expect(statuses.last.model, 'opus');
});
});
test('parses assistant text + tool_use events into items', () async {
proc.emit(assistantText('hello there'));
proc.emit(assistantToolUse());