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

@@ -25,6 +25,8 @@ object WsMethods {
const val COMMANDS_CATALOG = "commands.catalog"
const val COMMAND_DISPATCH = "command.dispatch"
const val COMMAND_RESOLVE = "command.resolve"
const val SLASH_EXEC = "slash.exec"
const val CONFIG_SET = "config.set"
// ── Attachments ───────────────────────────────────────────────────────
@@ -135,6 +135,7 @@ import com.m57.hermescontrol.ui.common.CredentialWarningBanner
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
import com.m57.hermescontrol.ui.providers.ProvidersScreen
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
@@ -552,6 +553,20 @@ fun ChatScreen(
},
)
}
// In-session model picker (issue #589) — opens on "/model".
if (state.showModelPicker) {
ModelPickerDialog(
providers = state.modelPickerProviders,
title = "Switch model (this chat)",
isLoading = state.modelPickerLoading && state.modelPickerProviders.isEmpty(),
pinnedModels = state.modelPickerPinned,
onSelect = { provider, model ->
viewModel.sendSlashModel(provider, model)
},
onDismiss = { viewModel.closeModelPicker() },
)
}
}
}
@@ -10,6 +10,8 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -78,6 +80,15 @@ data class ChatUiState(
val typingEffectDelayMs: Int = 30,
// Commands catalog
val commandCatalog: CommandCatalog = CommandCatalog(),
// In-session model picker (issue #589) — surfaced when the user types /model
// (or taps the top-bar model chip). Mirror of the global model screen's
// picker, but the selection hot-swaps the CURRENT session via the slash path.
val showModelPicker: Boolean = false,
val modelPickerProviders: List<ModelProvider> = emptyList(),
val modelPickerPinned: List<PinnedModel> = emptyList(),
val modelPickerLoading: Boolean = false,
// Current session's active model label (provider/model), shown in the chip
val currentSessionModel: String? = null,
// Attachment state
val pendingAttachments: List<Attachment> = emptyList(),
// Reaction animation — set when a reaction WS event arrives, auto-clears
@@ -135,6 +146,7 @@ class ChatViewModel(
ChatPersistenceRepository(
HermesDatabase.get(application).chatMessageDao(),
),
searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
) : AndroidViewModel(application) {
constructor(application: Application) : this(application, startCleanup = true)
@@ -164,9 +176,17 @@ class ChatViewModel(
ChatSearchDelegate(
scope = viewModelScope,
uiState = _uiState,
dispatcher = searchDispatcher,
)
private val attachmentsDelegate = ChatAttachmentsDelegate(uiState = _uiState)
/**
* Model options cached from GET /api/model/options so the in-session model
* picker (issue #589) opens instantly when the user types /model or taps the
* top-bar chip. Preloaded at GatewayReady; refreshed on open if empty.
*/
private var cachedModelOptions: List<ModelProvider> = emptyList()
private val streamingController =
ChatStreamingController(
scope = viewModelScope,
@@ -278,6 +298,7 @@ class ChatViewModel(
addSystemMessage("Connected to Hermes")
loadSessions()
fetchCommandCatalog()
preloadModelOptions()
val currentId = _uiState.value.currentSessionId
if (currentId != null) {
viewModelScope.launch(Dispatchers.IO) {
@@ -653,6 +674,12 @@ class ChatViewModel(
val trimmed = text.trim()
if (trimmed.startsWith("/", ignoreCase = true)) {
// Issue #589: a bare "/model" (no argument) opens the picker instead
// of requiring the user to hand-type the provider/model.
if (isModelPickerCommand(trimmed)) {
openModelPicker()
return
}
handleSlashCommand(trimmed)
return
}
@@ -841,6 +868,10 @@ class ChatViewModel(
branchSession(command)
}
is SlashResult.ModelSwitch -> {
handleModelSwitch(command)
}
is SlashResult.RpcDispatch -> {
dispatchViaRpc(command)
}
@@ -889,6 +920,51 @@ class ChatViewModel(
}
}
/**
* Hot-swap the current session's model via the backend's model-switch
* mechanism (issue #589).
*
* The TUI gateway's `prompt.submit` does NOT parse slash commands (it would
* make the LLM treat "/model ..." as a chat message), and `command.dispatch`
* only knows quick/plugin/bundle/skill commands (4018s on /model). The
* correct RPC is `config.set` with `key="model"` — the 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.
*
* IMPORTANT: `config.set` key=model passes the value DIRECTLY to
* `parse_model_flags` (it does NOT strip a leading "/model" like slash.exec /
* prompt.submit do). So we strip the "/model" prefix here and send the bare
* spec `parse_model_flags` understands:
* `<model> --provider <slug> --session`
* (matching the TUI client's `modelValueForConfigSet`).
*/
private fun handleModelSwitch(command: String) {
val sessionId = runtimeSessionId
if (sessionId == null) {
addAssistantMessage("No active session. Use `/new` to create one.")
return
}
// Strip a leading "/model" (and any following whitespace) — config.set
// key=model expects the bare spec, not a slash command. Match the
// dispatcher's case-insensitive "/model" detection so a typed "/MODEL"
// (or any casing) doesn't forward the literal slash prefix to the
// backend, where parse_model_flags wouldn't recognize it.
val spec =
if (command.startsWith("/model", ignoreCase = true)) {
command.substring(6).trim()
} else {
command.trim()
}
viewModelScope.launch(Dispatchers.IO) {
wsClient.send(
WsMethods.CONFIG_SET,
mapOf("key" to "model", "value" to spec, "session_id" to sessionId),
onSent = { id -> trackRequest(id, WsMethods.CONFIG_SET) },
)
}
}
/**
* Submits [text] as a prompt to the current session via WS, without
* adding a duplicate user message. Used by [handleDispatchResult] when
@@ -994,6 +1070,118 @@ class ChatViewModel(
null
}
// ── In-session model picker (issue #589) ─────────────────────────────
/**
* Whether [command] should open the in-session model picker instead of being
* dispatched as a normal slash command. True for a bare `/model` (with no
* trailing model argument) — the picker supplies the argument interactively.
* A fully-typed `/model provider/model` is forwarded straight to the backend.
*/
private fun isModelPickerCommand(command: String): Boolean {
val trimmed = command.trim()
if (!trimmed.startsWith("/", ignoreCase = true)) return false
val body = trimmed.removePrefix("/").trimStart()
// Must be exactly "model" with no argument (or just whitespace).
return body.equals("model", ignoreCase = true) ||
body.startsWith("model ", ignoreCase = true) &&
body.substringAfter("model").trim().isEmpty()
}
/** Preload model options so the picker opens instantly (no spinner on /model). */
private fun preloadModelOptions() {
viewModelScope.launch(Dispatchers.IO) {
val result =
safeApiCall {
ApiClient.hermesApi.getModelOptions(refresh = false)
}
if (result is NetworkResult.Success) {
cachedModelOptions = result.data.providers.orEmpty()
_uiState.update { it.copy(modelPickerPinned = AuthManager.getPinnedModels()) }
}
}
}
/**
* Open the in-session model picker. Uses the preloaded options if available
* (instant open); otherwise shows a loading state and fetches them. The
* `/model` slash command is the supported session hot-swap mechanism per the
* backend contract (issue #589).
*/
fun openModelPicker() {
val hasCached = cachedModelOptions.isNotEmpty()
_uiState.update {
it.copy(
showModelPicker = true,
modelPickerProviders = if (hasCached) cachedModelOptions else emptyList(),
modelPickerPinned = AuthManager.getPinnedModels(),
modelPickerLoading = !hasCached,
)
}
if (!hasCached) {
refreshModelOptions()
}
}
/** Re-fetch options (pull-to-refresh style) when the picker is already open. */
fun refreshModelOptions() {
_uiState.update { it.copy(modelPickerLoading = true) }
viewModelScope.launch(Dispatchers.IO) {
val result =
safeApiCall {
ApiClient.hermesApi.getModelOptions(refresh = true)
}
when (result) {
is NetworkResult.Success -> {
cachedModelOptions = result.data.providers.orEmpty()
_uiState.update {
it.copy(
modelPickerProviders = cachedModelOptions,
modelPickerLoading = false,
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
modelPickerLoading = false,
errorMessage = "Failed to load models: ${result.error.message}",
)
}
}
}
}
}
fun closeModelPicker() {
_uiState.update { it.copy(showModelPicker = false, modelPickerLoading = false) }
}
/**
* Hot-swap the CURRENT session's model via the /model slash command.
*
* Builds the backend-valid command form `/model <model> --provider <slug>
* --session`. The `--session` flag keeps the switch scoped to this chat
* only (it writes a per-session override and never touches the global
* model config), per the backend model-switch contract.
*/
fun sendSlashModel(
provider: String,
model: String,
) {
_uiState.update {
it.copy(
showModelPicker = false,
modelPickerLoading = false,
// Optimistic: reflect the chosen model in the top-bar chip until
// the next session sync confirms the backend hot-swap.
currentSessionModel = "$provider/$model",
)
}
handleSlashCommand("/model $model --provider $provider --session")
}
fun switchSession(sessionId: String) {
if (sessionId == _uiState.value.currentSessionId) return
@@ -7,7 +7,14 @@ package com.m57.hermescontrol.ui.chat
* Pure logic — no I/O, no Android dependencies.
*
* Only commands that MUST be handled client-side (immediate UX) are here.
* Everything else is forwarded to the backend via [SlashResult.RpcDispatch].
* `/fork` and `/model` are NOT sent via [SlashResult.RpcDispatch] (which maps
* to the `command.dispatch` RPC — that RPC only knows quick/plugin/bundle/skill
* commands and 4018s on everything else). They are real backend commands that
* get their own results: `/fork` goes via the `session.branch` RPC and `/model`
* via the `config.set` RPC (key="model" → gateway `_apply_model_switch`; the
* TUI gateway's `prompt.submit` does NOT parse slash commands, so sending it as
* a normal prompt makes the LLM treat it as text). Everything else is forwarded
* to the backend via [SlashResult.RpcDispatch].
*/
class SlashCommandDispatcher {
fun dispatch(command: String): SlashResult {
@@ -18,6 +25,7 @@ class SlashCommandDispatcher {
"/stop", "/interrupt" -> SlashResult.Interrupt
"/new" -> SlashResult.NewSession
"/fork", "/branch" -> SlashResult.SessionBranch
"/model" -> SlashResult.ModelSwitch
else -> SlashResult.RpcDispatch
}
}
@@ -38,4 +46,13 @@ sealed class SlashResult {
/** Fork the active conversation via the session.branch WebSocket RPC. */
data object SessionBranch : SlashResult()
/**
* Hot-swap the current session's model via the backend `config.set` RPC
* (key="model" → `_apply_model_switch`). NOT command.dispatch (4018s on
* /model) and NOT prompt.submit (LLM would treat it as text). The value
* string carries the flags `parse_model_flags` understands:
* `/model <model> --provider <slug> --session`.
*/
data object ModelSwitch : SlashResult()
}
@@ -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") }
},
)
}