diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 5952c4e5..184b66d0 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 74a015b2..8b35cf46 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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 ` → set the session model directly to (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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 417a63e6..563c4085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index b07be507..f20daa8a 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -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 { StreamSubscription? _statusSub; StreamSubscription? _endSub; StreamSubscription? _projectSub; + StreamSubscription? _modelErrorSub; ConversationController? _conversation; StreamJsonSession? _session; SessionStatus _status = const SessionStatus(); @@ -85,6 +87,11 @@ class _ClaudePaneState extends State { /// /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 { _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 { _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 { 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 { 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 { /// 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 { _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 { ), // 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( stream: _session?.busyStream, diff --git a/lib/builtin/claude/src/model_picker_card.dart b/lib/builtin/claude/src/model_picker_card.dart new file mode 100644 index 00000000..2c6c069a --- /dev/null +++ b/lib/builtin/claude/src/model_picker_card.dart @@ -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 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 createState() => _ModelPickerCardState(); +} + +class _ModelPickerCardState extends State { + 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), + ), + ); + } +} diff --git a/lib/builtin/claude/src/slash_commands.dart b/lib/builtin/claude/src/slash_commands.dart index d6aae9b7..131d61ae 100644 --- a/lib/builtin/claude/src/slash_commands.dart +++ b/lib/builtin/claude/src/slash_commands.dart @@ -30,8 +30,10 @@ bool isKnownSlashCommand(String text, Iterable 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 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 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 diff --git a/lib/builtin/claude/src/stream_json_session.dart b/lib/builtin/claude/src/stream_json_session.dart index 46a02f00..5a0f4569 100644 --- a/lib/builtin/claude/src/stream_json_session.dart +++ b/lib/builtin/claude/src/stream_json_session.dart @@ -147,6 +147,31 @@ abstract class McpServer { Future> callTool(String name, Map 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 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 = {}; + + List _availableModels = const []; + + /// Models selectable for this session, from the `initialize` response. + /// Empty until that response arrives (callers fall back to + /// [kFallbackModels]). + List get availableModels => _availableModels; + + final _modelErrorCtl = StreamController.broadcast(); + + /// Errors from rejected `set_model` requests (e.g. an unknown model name), + /// for the pane to surface (T-408). + Stream 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': {}, - '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': {}, + '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 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(); } } diff --git a/test/builtin/claude/claude_pane_test.dart b/test/builtin/claude/claude_pane_test.dart index 085450bd..48b20b8b 100644 --- a/test/builtin/claude/claude_pane_test.dart +++ b/test/builtin/claude/claude_pane_test.dart @@ -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(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; diff --git a/test/builtin/claude/model_picker_card_test.dart b/test/builtin/claude/model_picker_card_test.dart new file mode 100644 index 00000000..32e03f1a --- /dev/null +++ b/test/builtin/claude/model_picker_card_test.dart @@ -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); + }); +} diff --git a/test/builtin/claude/slash_commands_test.dart b/test/builtin/claude/slash_commands_test.dart index 614178c5..5ae39f81 100644 --- a/test/builtin/claude/slash_commands_test.dart +++ b/test/builtin/claude/slash_commands_test.dart @@ -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')); diff --git a/test/builtin/claude/stream_json_session_test.dart b/test/builtin/claude/stream_json_session_test.dart index e3fe618b..39e2714e 100644 --- a/test/builtin/claude/stream_json_session_test.dart +++ b/test/builtin/claude/stream_json_session_test.dart @@ -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': [], + 'models': [ + {'value': 'default', 'displayName': 'Default', 'description': 'recommended'}, + {'value': 'sonnet', 'displayName': 'Sonnet'}, + {'value': 12345}, // malformed entry → skipped + ], + }, + }, + }), + ); + await Future.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.delayed(Duration.zero); + expect(statuses.last.model, 'sonnet'); + }); + + test('setModel(default) does not guess the resolved model', () async { + session.setModel('default'); + await Future.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 = []; + session.modelErrors.listen(errors.add); + proc.emit(initEvent()); // model: claude-opus-4-7 + await Future.delayed(Duration.zero); + + session.setModel('bogus-model'); + await Future.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.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.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());