intercept /model: arg sets the model, bare opens a picker (T-408)
Typed into the conversation view, /model was forwarded to the session's stdin as message text — the CLI's interactive picker only exists in its own TUI, so nothing happened. clide now owns it like /clear//resume//fork (T-156). /model <name> sends a set_model control_request (verified against claude 2.1.175: subtype accepted alongside set_permission_mode; "default" resets to the CLI's configured model) with an optimistic status merge, rolled back with a toast if the CLI rejects the name. Bare /model swaps a picker card into the interaction zone (D-78) — numbers / arrows + Enter / Esc, mirroring the prompt card's shortcuts. The model list comes from the `initialize` handshake response, which the session now always sends — the spike verified it is side-effect- free, and it previously went out only when MCP servers were hosted. Until the response lands the picker falls back to the stable aliases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user