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:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user