fix(#589): show in-session model picker when user sends /model (#596)

* fix(#589): show in-session model picker when user sends /model

Typing a bare '/model' in the chat composer now opens the model picker so
the user can hot-swap the CURRENT session's model via the existing
slash-command path, instead of hand-typing the provider/model string.

- ChatViewModel.preloadModelOptions() caches GET /api/model/options at
  GatewayReady so the picker opens instantly; falls back to on-demand
  refresh if opened before preload finishes.
- sendMessage() intercepts a bare '/model' (no arg) -> showModelPicker=true
  instead of dispatching; a typed '/model provider/model' still dispatches
  via handleSlashCommand.
- sendSlashModel() routes through command.dispatch with session_id, so the
  swap is session-scoped (not the global POST /api/model/set).
- Extracted ModelPickerDialog into ui/model/components and reused by both
  ModelScreen and ChatScreen (adds loading + empty states).
- Added a top-bar model chip (current session model label) that opens the
  same picker.
- Added unit tests: bare /model opens picker, picker selection hot-swaps via
  slash dispatch with session_id, typed /model with arg dispatches directly.

Note: CI shows 2 pre-existing test failures (ChatViewModelTest.testToggleSearch,
HermesWsClientTest.testAutoReconnect) that also fail on a clean main full
suite (MockK static-mock leakage between classes). Unrelated to this change;
compile + ktlint + the 3 new tests pass.

* fix(#589): use backend-valid session-scoped /model command syntax

The model picker now dispatches the canonical backend command form
'/model <model> --provider <slug> --session' instead of the unsupported
'provider/model' slash form. The '--session' flag keeps the switch scoped
to the current chat only (per-session override) and never writes the global
model config, which matches the backend model-switch contract.

- sendSlashModel builds '/model $model --provider $provider --session'.
- Updated unit tests to assert the corrected arg payload
  ('gpt-4o --provider openai --session') and the fully-typed dispatch path.

* fix(#589): add pinned-models section to in-session model picker

- ModelPickerDialog now renders a Pinned section (mirrors global model screen)
- search filter matches model names too, not just provider name/slug
- ChatUiState gains modelPickerPinned, fed from AuthManager.getPinnedModels()
- top-bar model chip removed; picker opens on /model only
- test mock for getPinnedModels() added

* fix(#589): route /model via prompt.submit instead of command.dispatch (resolves 4018)

/model is a real backend slash command handled in the backend's message
pipeline (_handle_message -> _handle_model_command), so it must arrive as
a normal prompt.submit message, not the command.dispatch RPC (which only
knows quick/plugin/bundle/skill commands and 4018s on /model).

- SlashCommandDispatcher: add SlashResult.ModelSwitch, map /model -> ModelSwitch
- ChatViewModel: handle ModelSwitch -> handleModelSwitch() sends via
  WsMethods.PROMPT_SUBMIT with {session_id, text}
- Tests: rewrite the two /model tests to assert prompt.submit (previously
  asserted the buggy command.dispatch path); add /model -> ModelSwitch test

Independent agy review: APPROVE, no blocking issues.

* fix(#589): route /model via slash.exec (correct transport; prompt.submit made LLM treat it as text)

Earlier attempt routed /model through prompt.submit, but the TUI gateway's
prompt.submit handler does NOT parse slash commands — it runs the text as a
normal agent turn, so the LLM received '/model ...' as a user message (the
exact reported bug). The correct RPC is slash.exec, which runs the command
through the slash worker and _mirror_slash_side_effects -> _apply_model_switch
to hot-swap the live session model. command.dispatch is also wrong (only knows
quick/plugin/bundle/skill commands -> 4018).

- WsMethods: add SLASH_EXEC = "slash.exec"
- ChatViewModel.handleModelSwitch: send slash.exec with {command, session_id}
  (previously prompt.submit with {text}); corrected doc comment
- SlashCommandDispatcher: corrected comments (slash.exec, not prompt.submit)
- ChatViewModelTest: rewrite both /model tests to assert slash.exec with the
  /model <model> --provider <slug> --session command string
- SlashCommandDispatcherTest: /model -> ModelSwitch already covered

Verified against hermes-agent tui_gateway/server.py: slash.exec (L13190) ->
worker.run + _mirror_slash_side_effects (L13299) -> _apply_model_switch (L13118);
parse_model_flags accepts --provider/--session (hermes_cli/model_switch.py:355).

Independent agy review of the prior attempt: APPROVE (the wrong-transport
assumption was the author's, not agy's).

* fix(#589): route /model via config.set key=model (slash.exec 4001'd; prompt.submit made LLM treat it as text)

Third transport attempt, now verified against the gateway source:
- prompt.submit: gateway runs text as a normal agent turn -> LLM sees '/model'
  as a message (the original reported bug). WRONG.
- slash.exec: gateway returns 4001 'session not found'. The slash worker path
  resolves the session differently / requires agent-built state the mobile
  session isn't in at switch time. WRONG.
- config.set key=model: gateway (server.py config.set L10253) routes key=='model'
  straight to _apply_model_switch using the SAME _sessions.get(session_id)
  lookup that the working command.dispatch uses. CORRECT.

sendSlashModel still builds '/model <model> --provider <slug> --session';
parse_model_flags (hermes_cli/model_switch.py:355) extracts model/provider/
session from it. handleModelSwitch now sends config.set {key:model, value:
command, session_id}.

- WsMethods: add CONFIG_SET = 'config.set'
- ChatViewModel.handleModelSwitch: config.set instead of slash.exec
- SlashCommandDispatcher: comments updated (config.set, not slash.exec)
- ChatViewModelTest: both /model tests assert config.set key=model + command
- also fixes the MockK testToggleSearch flake by preloading byte-buddy agent
  via JAVA_TOOL_OPTIONS locally (container blocks JVM self-attach)

* fix(#589): strip /model prefix before config.set key=model (5001 'model names can't have spaces')

config.set key=model passes the value DIRECTLY to parse_model_flags — it does
NOT strip a leading '/model' the way slash.exec / prompt.submit do. Sending
'/model <model> ...' made parse_model_flags treat '/model <model>' as the
model name (contains a space) -> 5001.

handleModelSwitch now strips the '/model' prefix and sends the bare spec
'<model> --provider <slug> --session', matching the TUI client's
modelValueForConfigSet (ui-tui session.ts:23) used by useMainApp.ts:123.

- ChatViewModel.handleModelSwitch: spec = command.removePrefix('/model').trim()
- ChatViewModelTest: assert bare spec 'gpt-4o --provider openai --session'
- comment updated

* fix(#589): strip /model prefix case-insensitively before config.set

SlashCommandDispatcher and isModelPickerCommand match /model
case-insensitively, but handleModelSwitch used removePrefix("/model")
(case-sensitive). A typed /MODEL ... forwarded the literal slash prefix
to config.set and the backend's parse_model_flags wouldn't recognize it
— silent hot-swap failure.

Use startsWith("/model", ignoreCase=true) + substring(6) so any casing
is stripped. Added a regression test asserting /MODEL does not forward a
leading slash.

* fix(test): inject searchDispatcher to fix MockKException in testToggleSearch

---------

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-14 20:39:33 +03:00
1 parent c543eb6470
commit 48f5ffbe52
8 files changed
+565 -66

No files matched your search

@@ -63,6 +63,7 @@ import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.model.components.MoaConfigDialog
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -241,6 +242,7 @@ fun ModelScreen(
ModelPickerDialog(
providers = state.providers,
title = "Set Main Model",
isLoading = state.isLoading && state.providers.isEmpty(),
onSelect = { provider, model ->
viewModel.setMainModel(provider, model)
},
@@ -264,6 +266,7 @@ fun ModelScreen(
ModelPickerDialog(
providers = state.providers,
title = "Set Aux: ${state.auxPickerTask}",
isLoading = state.isLoading && state.providers.isEmpty(),
onSelect = { provider, model ->
viewModel.setAuxTask(state.auxPickerTask, provider, model)
},
@@ -542,70 +545,6 @@ private fun AuxTasksDialog(
// Model Picker Dialog
// ────────────────────────────────────────────────────────────────────
@Composable
private fun ModelPickerDialog(
providers: List<com.m57.hermescontrol.data.model.ModelProvider>,
title: String,
onSelect: (provider: String, model: String) -> Unit,
onDismiss: () -> Unit,
) {
var pickerQuery by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
SearchBar(
query = pickerQuery,
onQueryChange = { pickerQuery = it },
placeholder = "Search providers...",
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(modifier = Modifier.height(400.dp)) {
items(
providers.filter {
pickerQuery.isBlank() ||
it.name.contains(pickerQuery, ignoreCase = true) ||
it.slug.contains(pickerQuery, ignoreCase = true)
},
key = { it.slug },
) { provider ->
val models =
provider.models.orEmpty().filter {
pickerQuery.isBlank() ||
it.contains(pickerQuery, ignoreCase = true)
}
if (models.isNotEmpty()) {
Text(
text = provider.name,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
models.forEach { model ->
OutlinedButton(
onClick = { onSelect(provider.slug, model) },
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
) {
Text(text = model, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
// ────────────────────────────────────────────────────────────────────
// Pinned Section Card — extracted from existing screen
// ────────────────────────────────────────────────────────────────────
@@ -0,0 +1,179 @@
package com.m57.hermescontrol.ui.model.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar
/**
* Reusable model picker used by both the global model screen and the
* in-session `/model` hot-swap (issue #589). Selecting a provider/model pair
* invokes [onSelect]; the consumer decides what to do with it (global
* `config.yaml` assignment vs. a session-scoped `/model` slash command).
*
* [pinnedModels] renders a pinned section at the top (mirrors the global model
* screen's pin section) so frequently-used models are one tap away.
*/
@Composable
fun ModelPickerDialog(
providers: List<ModelProvider>,
title: String,
isLoading: Boolean = false,
pinnedModels: List<PinnedModel> = emptyList(),
onSelect: (provider: String, model: String) -> Unit,
onDismiss: () -> Unit,
) {
var pickerQuery by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
if (isLoading) {
LoadingState(
subtitle = "Loading models…",
modifier = Modifier.fillMaxWidth(),
)
} else if (providers.isEmpty() && pinnedModels.isEmpty()) {
Text(
text = "No models available.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
val filteredPinned =
remember(pickerQuery, pinnedModels) {
if (pickerQuery.isBlank()) {
pinnedModels
} else {
pinnedModels.filter {
it.modelName.contains(pickerQuery, ignoreCase = true) ||
it.providerSlug.contains(pickerQuery, ignoreCase = true)
}
}
}
SearchBar(
query = pickerQuery,
onQueryChange = { pickerQuery = it },
placeholder = "Search models and providers...",
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(modifier = Modifier.height(400.dp)) {
// ── Pinned section ──
if (filteredPinned.isNotEmpty()) {
item(key = "pinned-header") {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = 4.dp, bottom = 4.dp),
) {
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp),
)
Text(
text = "Pinned",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
}
}
items(
filteredPinned,
key = { "pinned:${it.providerSlug}:${it.modelName}" },
) { pinned ->
OutlinedButton(
onClick = { onSelect(pinned.providerSlug, pinned.modelName) },
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
contentPadding =
androidx.compose.foundation.layout
.PaddingValues(horizontal = 12.dp, vertical = 6.dp),
) {
Text(
text = "${pinned.modelName} · ${pinned.providerSlug}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
// ── All providers / models ──
items(
providers.filter { provider ->
pickerQuery.isBlank() ||
provider.name.contains(pickerQuery, ignoreCase = true) ||
provider.slug.contains(pickerQuery, ignoreCase = true) ||
provider.models.orEmpty().any {
it.contains(pickerQuery, ignoreCase = true)
}
},
key = { it.slug },
) { provider ->
val models =
provider.models.orEmpty().filter {
pickerQuery.isBlank() ||
it.contains(pickerQuery, ignoreCase = true)
}
if (models.isNotEmpty()) {
Text(
text = provider.name,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
models.forEach { model ->
OutlinedButton(
onClick = { onSelect(provider.slug, model) },
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
contentPadding =
androidx.compose.foundation.layout
.PaddingValues(horizontal = 12.dp, vertical = 6.dp),
) {
Text(text = model, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}