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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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