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
@@ -4309,3 +4309,4 @@ Acceptance:
- Regression test covering chorded-Shift-then-Shift-tap sequences.', NULL, '2026-06-12 06:40:54', '2026-06-12 06:40:54', '2026-06-12 06:40:54', NULL, '4f7eddbb58a3551d208dcd03541bed5d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBN3VYR84023Z5XFEX9DS0S0', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 06:40:59', '2026-06-12 06:40:59', '2026-06-12 06:40:59', NULL, '6f856737a3d115b0a8dd510d2051047d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBN3VYR84023Z5XFEX9DS0S0', 'status', 'in_progress', 'done', NULL, '2026-06-12 06:49:24', '2026-06-12 06:49:24', '2026-06-12 06:49:24', NULL, 'ce08a8f54370d08b033552e169a8bbb9', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBN3VTK2MYQQ173MSJN6E1DM', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 06:50:01', '2026-06-12 06:50:01', '2026-06-12 06:50:01', NULL, '801c09560f70be28365441175fb78440', 2) ON CONFLICT(hash) DO NOTHING;
+17
View File
@@ -4624,3 +4624,20 @@ Acceptance:
- Shifted typing in general (capitals, symbols) never triggers double-tap bindings.
- Genuine double-Shift (two bare taps within the window) still opens quick-open in all four presets.
- Regression test covering chorded-Shift-then-Shift-tap sequences.', 'done', 'high', NULL, NULL, NULL, '2026-06-12 06:40:26', '2026-06-12 06:49:24', NULL, '7a26c6d1ae0aa3086ef337aa1640f799', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBN3VTK2MYQQ173MSJN6E1DM', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'intercept /model in the Claude pane: arg sets model, bare shows picker', 'User report: typing /model in the Claude conversation view does nothing useful — it is forwarded to the session''s stream-json stdin like a plain message, and the CLI''s interactive /model picker only exists in its own TUI. Clide must own it (same class as /clear,/resume,/fork — T-156).
Interaction design:
- `/model <name>` set the session model directly to <name> (accept aliases like sonnet/opus and full ids).
- `/model` (bare) show a model picker in the interaction zone replaces the composer while open, like ToolPromptCard (D-78); list selectable via keyboard (numbers/arrows + Enter), Esc cancels back to the composer.
Implementation map (from code exploration):
- Add ''model'' to kClideOwnedCommands in lib/builtin/claude/src/slash_commands.dart and handle it in ClaudePane._send (lib/builtin/claude/src/claude_pane.dart).
- Add StreamJsonSession.setModel(String) following the setPermissionMode control_request pattern (lib/builtin/claude/src/stream_json_session.dart) subtype set_model; optimistically merge SessionStatus(model: ) so the status bar updates.
- Model list for the bare-picker: query the CLI via the supported_models-style control request if available (verify exact subtype/shapes against the installed CLI), falling back to a static alias list.
- Picker widget swaps in via the existing pending-interaction slot in ClaudePane; reuse composer focus/draft preservation (the draft must survive the swap).
Acceptance:
- `/model sonnet` switches the live session model; status bar reflects it on the next status merge.
- bare `/model` opens the picker; choosing an entry sets the model; Esc restores the composer with the draft intact.
- `/model` is never forwarded to the session as message text.
- Unit tests in test/builtin/claude/ for the parsing (slash_commands_test.dart), the pane interception, and the picker widget.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 06:40:25', '2026-06-12 06:50:01', NULL, 'cba563b9c69c2bb0033b33bd32895059', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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);
+7
View File
@@ -16,6 +16,13 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased]
### Added
- **`/model` works in the Claude pane.** With a name (`/model sonnet`) it
switches the live session's model over the control channel; bare `/model`
opens a picker in the interaction zone with the CLI's model list and the
current model marked. A rejected name rolls back and raises a toast. (T-408)
## [2.4.1] — 2026-06-12
### Fixed
+54 -2
View File
@@ -14,6 +14,7 @@ import 'clipboard_paste.dart';
import 'activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey;
import 'conversation_controller.dart';
import 'conversation_view.dart';
import 'model_picker_card.dart';
import 'permission_mode_control.dart';
import 'prompt_card.dart';
import 'session_index.dart';
@@ -73,6 +74,7 @@ class _ClaudePaneState extends State<ClaudePane> {
StreamSubscription<SessionStatus>? _statusSub;
StreamSubscription<SessionEnd>? _endSub;
StreamSubscription<ProjectOpened>? _projectSub;
StreamSubscription<String>? _modelErrorSub;
ConversationController? _conversation;
StreamJsonSession? _session;
SessionStatus _status = const SessionStatus();
@@ -85,6 +87,11 @@ class _ClaudePaneState extends State<ClaudePane> {
/// /resume, and respawns operate on this pane's own session (T-375).
late String? _forkSource = widget.forkSourceId;
/// Whether a bare `/model` opened the picker in the interaction zone
/// (T-408). An open prompt takes precedence; the picker shows once it
/// resolves.
bool _modelPickerOpen = false;
bool _spawned = false;
/// Per-session composer draft (text + caret), held here so an unsent
@@ -184,6 +191,8 @@ class _ClaudePaneState extends State<ClaudePane> {
_statusSub = null;
_endSub?.cancel();
_endSub = null;
_modelErrorSub?.cancel();
_modelErrorSub = null;
// The orchestrator owns the session, so disposing this pane does NOT kill
// it — that's what lets a hidden/kept-alive pane keep its session (T-169).
// A secondary tab being *closed* is a real teardown, so close its session;
@@ -237,6 +246,9 @@ class _ClaudePaneState extends State<ClaudePane> {
_statusSub = null;
_endSub?.cancel();
_endSub = null;
_modelErrorSub?.cancel();
_modelErrorSub = null;
_modelPickerOpen = false;
await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session
_conversation = null;
_session = null;
@@ -340,6 +352,11 @@ class _ClaudePaneState extends State<ClaudePane> {
if (!mounted) return;
setState(() => _status = s);
});
// A rejected /model change (unknown name) rolls back silently in the
// status — say why out loud (T-408).
_modelErrorSub = managed.session.modelErrors.listen((msg) {
_kernel?.notify.warn(msg, title: 'model');
});
// Surface a dead process instead of letting it look thoughtful (T-361):
// late binders read the replayed end; live sessions stream it.
final alreadyEnded = managed.session.end;
@@ -377,10 +394,34 @@ class _ClaudePaneState extends State<ClaudePane> {
case 'fork':
_forkSession();
return;
case 'model':
_modelCommand(slashCommandArg(text) ?? '');
return;
}
_session?.send(text);
}
/// clide-owned `/model` (T-408): with an argument, set the model directly;
/// bare, open the picker in the interaction zone (D-78).
void _modelCommand(String arg) {
if (_session == null) return;
if (arg.isNotEmpty) {
_session!.setModel(arg);
return;
}
setState(() => _modelPickerOpen = true);
}
void _pickModel(String value) {
_session?.setModel(value);
_closeModelPicker();
}
void _closeModelPicker() {
setState(() => _modelPickerOpen = false);
_composerFocus.requestFocus();
}
/// Record a submitted prompt in the active session's history (T-163),
/// de-duping immediate repeats. Empty/whitespace prompts are skipped.
void _appendHistory(String text) {
@@ -405,7 +446,7 @@ class _ClaudePaneState extends State<ClaudePane> {
/// background tap must never pull focus from (or resurrect) the composer
/// over an open prompt.
void _focusComposerOnTap() {
if (_session?.pendingPrompt != null) return;
if (_session?.pendingPrompt != null || _modelPickerOpen) return;
_composerFocus.requestFocus();
}
@@ -477,6 +518,9 @@ class _ClaudePaneState extends State<ClaudePane> {
_statusSub = null;
_endSub?.cancel();
_endSub = null;
_modelErrorSub?.cancel();
_modelErrorSub = null;
_modelPickerOpen = false;
await activeSessionOrchestrator?.close(_orchId); // kills the old session
// Erase only after the process is dead, so claude isn't mid-write.
final root = _repoRoot;
@@ -548,9 +592,17 @@ class _ClaudePaneState extends State<ClaudePane> {
),
// An open prompt takes the composer's space and hides the text
// input until it's answered, so interaction stays out of the
// conversation stream (D-78).
// conversation stream (D-78). The /model picker uses the same
// slot; a prompt outranks it (T-408).
if (prompt != null && _session != null)
ToolPromptCard(prompt: prompt, onResolve: _session!.resolvePrompt)
else if (_modelPickerOpen && _session != null)
ModelPickerCard(
models: _session!.availableModels.isEmpty ? kFallbackModels : _session!.availableModels,
currentModel: _status.model,
onPick: _pickModel,
onCancel: _closeModelPicker,
)
else
StreamBuilder<bool>(
stream: _session?.busyStream,
@@ -0,0 +1,166 @@
/// The `/model` picker for the interaction zone (T-408, D-78): a bare
/// `/model` swaps this card in for the composer; picking an entry sends
/// `set_model` over the control channel and the composer returns. Esc
/// cancels. Like [ToolPromptCard], it lives in the composer zone — never
/// inline in the conversation.
///
/// Keyboard: number keys pick directly (CLI muscle memory, T-240), Up/Down
/// move the highlight, Enter picks the highlighted entry, Esc cancels.
library;
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
/// Whether [option] is the session's current model. Options carry aliases
/// (`sonnet`) or full ids while the status holds the full id
/// (`claude-sonnet-4-6`), so match on equality or alias containment.
bool modelOptionIsCurrent(ModelOption option, String? currentModel) {
if (currentModel == null || option.value == 'default') return false;
if (option.value == currentModel) return true;
return currentModel.toLowerCase().contains(option.value.toLowerCase());
}
class ModelPickerCard extends StatefulWidget {
const ModelPickerCard({super.key, required this.models, this.currentModel, required this.onPick, required this.onCancel});
/// Selectable entries, in display order. Callers pass [kFallbackModels]
/// when the session hasn't reported its list yet.
final List<ModelOption> models;
/// The session's current model (full id), to mark the active entry.
final String? currentModel;
/// Called once with the picked [ModelOption.value].
final void Function(String value) onPick;
/// Called when the user dismisses the picker without choosing.
final VoidCallback onCancel;
@override
State<ModelPickerCard> createState() => _ModelPickerCardState();
}
class _ModelPickerCardState extends State<ModelPickerCard> {
late int _highlight = _initialHighlight();
int _initialHighlight() {
for (var i = 0; i < widget.models.length; i++) {
if (modelOptionIsCurrent(widget.models[i], widget.currentModel)) return i;
}
return 0;
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent || !node.hasPrimaryFocus) return KeyEventResult.ignored;
final hw = HardwareKeyboard.instance;
if (hw.isControlPressed || hw.isAltPressed || hw.isMetaPressed) return KeyEventResult.ignored;
final key = e.logicalKey;
if (key == LogicalKeyboardKey.escape) {
widget.onCancel();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowDown) {
setState(() => _highlight = (_highlight + 1) % widget.models.length);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowUp) {
setState(() => _highlight = (_highlight - 1 + widget.models.length) % widget.models.length);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) {
widget.onPick(widget.models[_highlight].value);
return KeyEventResult.handled;
}
final digit = _digitOf(key);
if (digit != null && digit >= 1 && digit <= widget.models.length) {
widget.onPick(widget.models[digit - 1].value);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
static int? _digitOf(LogicalKeyboardKey key) {
const digits = [
LogicalKeyboardKey.digit1,
LogicalKeyboardKey.digit2,
LogicalKeyboardKey.digit3,
LogicalKeyboardKey.digit4,
LogicalKeyboardKey.digit5,
LogicalKeyboardKey.digit6,
LogicalKeyboardKey.digit7,
LogicalKeyboardKey.digit8,
LogicalKeyboardKey.digit9,
];
const numpad = [
LogicalKeyboardKey.numpad1,
LogicalKeyboardKey.numpad2,
LogicalKeyboardKey.numpad3,
LogicalKeyboardKey.numpad4,
LogicalKeyboardKey.numpad5,
LogicalKeyboardKey.numpad6,
LogicalKeyboardKey.numpad7,
LogicalKeyboardKey.numpad8,
LogicalKeyboardKey.numpad9,
];
var i = digits.indexOf(key);
if (i < 0) i = numpad.indexOf(key);
return i < 0 ? null : i + 1;
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Focus(
autofocus: true,
onKeyEvent: _onKey,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: tokens.panelBackground,
border: Border(top: BorderSide(color: tokens.statusInfo, width: 2)),
),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
ClideText('model', fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusInfo),
const Spacer(),
ClideText('↑↓ · 1-${widget.models.length} · Enter · Esc', fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
],
),
const SizedBox(height: 8),
for (var i = 0; i < widget.models.length; i++) _row(tokens, i),
const SizedBox(height: 6),
Row(
children: [
const Spacer(),
ClideButton(label: 'cancel', variant: ClideButtonVariant.subtle, onPressed: widget.onCancel),
],
),
],
),
),
);
}
Widget _row(SurfaceTokens tokens, int i) {
final m = widget.models[i];
final current = modelOptionIsCurrent(m, widget.currentModel);
final highlighted = i == _highlight;
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: ClideButton(
label: '${i + 1}. ${current ? '' : ''} ${m.displayName}${m.description.isEmpty ? '' : '${m.description}'}',
variant: highlighted ? ClideButtonVariant.primary : ClideButtonVariant.subtle,
onPressed: () => widget.onPick(m.value),
),
);
}
}
+13 -2
View File
@@ -30,8 +30,10 @@ bool isKnownSlashCommand(String text, Iterable<String> known) {
/// Slash commands clide handles itself instead of forwarding to Claude:
/// Claude Code's own handling forks the session to a new id that clide's
/// transcript reader can't follow, so clide owns the semantics (T-156).
/// `/fork` branches the current session into a new pane (T-172).
const Set<String> kClideOwnedCommands = {'clear', 'resume', 'fork'};
/// `/fork` branches the current session into a new pane (T-172). `/model`
/// is interactive in the CLI's TUI only — forwarded it does nothing — so
/// clide owns it as a set_model control request / picker (T-408).
const Set<String> kClideOwnedCommands = {'clear', 'resume', 'fork', 'model'};
/// The clide-owned command in [text] (a single-line leading-slash token in
/// [kClideOwnedCommands]), or null.
@@ -40,6 +42,15 @@ String? clideOwnedCommand(String text) {
return token != null && kClideOwnedCommands.contains(token) ? token : null;
}
/// The argument text after the command token — `"/model sonnet"` → `"sonnet"`
/// — trimmed; empty when there is none (`"/model"`). Null when [text] isn't
/// single-line leading-slash input.
String? slashCommandArg(String text) {
if (slashCommandToken(text) == null) return null;
final ws = text.indexOf(RegExp(r'\s'));
return ws < 0 ? '' : text.substring(ws + 1).trim();
}
bool _isWs(String c) => c == ' ' || c == '\t' || c == '\n';
/// An in-progress slash query at the cursor — the `/` position and the word
+122 -16
View File
@@ -147,6 +147,31 @@ abstract class McpServer {
Future<Map<String, dynamic>> callTool(String name, Map<String, dynamic> arguments);
}
/// A model selectable for a session, from the `initialize` control_response's
/// `models[]` (T-408). Pure data, Flutter-free.
class ModelOption {
const ModelOption({required this.value, required this.displayName, this.description = ''});
/// The id/alias sent in `set_model` — e.g. `default`, `sonnet`, `opus`.
final String value;
/// Human label, e.g. `Sonnet`.
final String displayName;
/// One-line blurb shown muted next to the label.
final String description;
}
/// Fallback picker entries for when the `initialize` response hasn't arrived
/// (or carried no models): the stable aliases every claude build accepts
/// (T-408). `default` resets to the CLI's configured model.
const List<ModelOption> kFallbackModels = [
ModelOption(value: 'default', displayName: 'Default', description: 'recommended — the CLI\'s configured model'),
ModelOption(value: 'sonnet', displayName: 'Sonnet', description: 'fast, great for everyday tasks'),
ModelOption(value: 'opus', displayName: 'Opus', description: 'most capable'),
ModelOption(value: 'haiku', displayName: 'Haiku', description: 'fastest, lightweight'),
];
/// An interactive prompt Claude is blocked on, from the stream-json control
/// channel (a `can_use_tool` control_request) — a tool needing permission, or
/// an `AskUserQuestion`. Pure data; the decision goes back via
@@ -261,6 +286,27 @@ class StreamJsonSession {
String? _claudeSessionId;
int _localSeq = 0;
/// The `initialize` handshake's request id — its control_response carries
/// the selectable `models[]` (T-408).
String? _initRequestId;
/// In-flight `set_model` request ids → the model the status held before the
/// optimistic merge, so an error response can roll it back (T-408).
final _pendingSetModel = <String, String?>{};
List<ModelOption> _availableModels = const [];
/// Models selectable for this session, from the `initialize` response.
/// Empty until that response arrives (callers fall back to
/// [kFallbackModels]).
List<ModelOption> get availableModels => _availableModels;
final _modelErrorCtl = StreamController<String>.broadcast();
/// Errors from rejected `set_model` requests (e.g. an unknown model name),
/// for the pane to surface (T-408).
Stream<String> get modelErrors => _modelErrorCtl.stream;
/// Token-by-token streaming state (T-168, wire shape verified by T-184).
///
/// With `--include-partial-messages`, claude emits the in-progress reply as
@@ -368,22 +414,22 @@ class StreamJsonSession {
// code is not (T-361).
final exit = _proc.exitCode;
if (exit != null) unawaited(exit.then(_onExit));
// Declaring our in-process MCP servers in the `initialize` handshake is what
// makes claude drive their JSON-RPC over `mcp_message` (T-170). Only sent
// when we actually host a server, so a plain session is unchanged.
if (_mcpServers.isNotEmpty) {
_proc.writeLine(
jsonEncode({
'type': 'control_request',
'request_id': 'init-${_localSeq++}',
'request': {
'subtype': 'initialize',
'hooks': <String, dynamic>{},
'sdkMcpServers': [for (final s in _mcpServers) s.name],
},
}),
);
}
// The `initialize` handshake is side-effect-free (verified in the protocol
// spike) and does double duty: declaring our in-process MCP servers is what
// makes claude drive their JSON-RPC over `mcp_message` (T-170), and the
// response's `models[]` feeds the /model picker (T-408).
_initRequestId = 'init-${_localSeq++}';
_proc.writeLine(
jsonEncode({
'type': 'control_request',
'request_id': _initRequestId,
'request': {
'subtype': 'initialize',
'hooks': <String, dynamic>{},
'sdkMcpServers': [for (final s in _mcpServers) s.name],
},
}),
);
}
void _onLine(String line) {
@@ -411,6 +457,12 @@ class StreamJsonSession {
_onControlRequest(ev);
return;
}
// Responses to OUR control requests: the initialize result (models) and
// set_model acks/errors (T-408).
if (ev['type'] == 'control_response') {
_onControlResponse(ev);
return;
}
// A `result` ends the turn — clear the busy/interruptible state and reset
// streaming state so the next turn is fresh.
if (ev['type'] == 'result') {
@@ -778,6 +830,59 @@ class StreamJsonSession {
_mergeStatus(SessionStatus(permissionMode: mode));
}
/// Set the model for subsequent turns (T-408). Sends a `set_model`
/// control_request; [model] is an alias (`sonnet`, `opus`) or full id, and
/// `default` resets to the CLI's configured model. The status merges
/// optimistically (mirroring [setPermissionMode]); an error response rolls
/// it back and surfaces on [modelErrors].
void setModel(String model) {
final rid = 'set-model-${_localSeq++}';
_pendingSetModel[rid] = _status.model;
_proc.writeLine(
jsonEncode({
'type': 'control_request',
'request_id': rid,
'request': {'subtype': 'set_model', 'model': model},
}),
);
// `default` resolves to a model only the CLI knows — leave the status to
// the next assistant event in that case.
if (model != 'default') _mergeStatus(SessionStatus(model: model));
}
/// A `control_response` to one of our requests: capture the initialize
/// result's `models[]`, and roll back + surface a rejected set_model (T-408).
void _onControlResponse(Map<String, dynamic> ev) {
final resp = ev['response'];
if (resp is! Map) return;
final rid = resp['request_id'] as String?;
if (rid == null) return;
final isError = resp['subtype'] == 'error';
if (rid == _initRequestId && !isError) {
final result = resp['response'];
final models = result is Map ? result['models'] : null;
if (models is List) {
_availableModels = List.unmodifiable([
for (final m in models)
if (m is Map && m['value'] is String)
ModelOption(
value: m['value'] as String,
displayName: m['displayName'] as String? ?? m['value'] as String,
description: m['description'] as String? ?? '',
),
]);
}
return;
}
if (_pendingSetModel.containsKey(rid)) {
final previous = _pendingSetModel.remove(rid);
if (isError) {
if (previous != null) _mergeStatus(SessionStatus(model: previous));
_modelErrorCtl.add(resp['error'] as String? ?? 'model change rejected');
}
}
}
/// The process exited under a live session. Flip every "in flight"
/// surface off so the pane reflects reality instead of spinning forever.
void _onExit(int code) {
@@ -803,5 +908,6 @@ class StreamJsonSession {
await _pendingCtl.close();
await _busyCtl.close();
await _endCtl.close();
await _modelErrorCtl.close();
}
}
+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());