Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@ ENV/

# Local Markdown Web Preview fixtures copied from iOS for manual Android rendering tests
docs/ios_markdown_preview_fixtures/

# TMP working design docs (merged into PRD/RFC/working before PR merge, then deleted)
docs/TMP*
12 changes: 11 additions & 1 deletion app/src/main/java/com/yage/opencode_client/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,9 @@ private fun PhoneLayout(viewModel: MainViewModel) {
onNavigateToSettings = {
navigateToTopLevel(Screen.Settings.route)
},
onManageModels = {
navigateToTopLevel(Screen.Settings.route)
},
showSettingsButton = false
)
}
Expand Down Expand Up @@ -370,7 +373,13 @@ private fun BoxScope.DeepLinkFeedback(
private fun TabletLayout(viewModel: MainViewModel) {
var selectedTab by remember { mutableIntStateOf(0) }
var sessionsPaneCollapsed by rememberSaveable { mutableStateOf(false) }
val onOpenSettings: () -> Unit = { selectedTab = 1 }
// Opening Settings (e.g. from the chat "Manage models" jump) must also expand
// the left pane, otherwise the Settings screen isn't composed when the Sessions
// pane is collapsed and the pending model-shortlist focus is never consumed.
val onOpenSettings: () -> Unit = {
sessionsPaneCollapsed = false
selectedTab = 1
}
val state by viewModel.state.collectAsStateWithLifecycle()
val filesWeight = if (sessionsPaneCollapsed) 0.5f else 0.375f
val chatWeight = if (sessionsPaneCollapsed) 0.5f else 0.375f
Expand Down Expand Up @@ -483,6 +492,7 @@ private fun TabletLayout(viewModel: MainViewModel) {
},
useInlineFilePreview = true,
onNavigateToSettings = onOpenSettings,
onManageModels = onOpenSettings,
showSettingsButton = false,
showNewSessionInTopBar = false,
showSessionListInTopBar = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ interface OpenCodeApi {
@GET("config/providers")
suspend fun getProviders(): ProvidersResponse

@GET("provider")
suspend fun getProviderRegistry(): ProviderRegistryResponse

@GET("agent")
suspend fun getAgents(): List<AgentInfo>

Expand Down
35 changes: 34 additions & 1 deletion app/src/main/java/com/yage/opencode_client/data/model/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ data class ProvidersResponse(
}
}

/**
* Response of `GET /provider`: every known provider plus the subset that is
* connected (authenticated, or keyless-local providers like a custom Ollama
* endpoint). Used to scope the model catalog to models the user can actually
* run. Mirrors iOS `ProviderRegistryResponse`.
*/
@Serializable
data class ProviderRegistryResponse(
val all: List<ConfigProvider> = emptyList(),
@SerialName("default") val defaultByProvider: Map<String, String> = emptyMap(),
val connected: List<String> = emptyList()
) {
val connectedProviderIds: Set<String> get() = connected.toSet()
}

@Serializable
data class ConfigProvider(
val id: String = "",
Expand All @@ -37,11 +52,29 @@ data class ProviderModel(
val name: String? = null,
@SerialName("providerID") val providerId: String? = null,
@SerialName("providerId") val providerIdAlt: String? = null,
val limit: ProviderModelLimit? = null
val limit: ProviderModelLimit? = null,
val capabilities: ProviderModelCapabilities? = null
) {
val resolvedProviderId: String? get() = providerId ?: providerIdAlt
}

/**
* Minimal slice of the server `capabilities` object. Chat-capable means the
* model can produce text output; missing info is treated as capable (older
* servers may not report it) so a false negative never hides a working model.
*/
@Serializable
data class ProviderModelCapabilities(
val output: ProviderModelOutput? = null
) {
val isChatCapable: Boolean get() = output?.text ?: true
}

@Serializable
data class ProviderModelOutput(
val text: Boolean? = null
)

@Serializable
data class ProviderModelLimit(
val context: Int? = null,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.yage.opencode_client.data.model

import kotlinx.serialization.Serializable

/**
* A user-curated model entry shown in the chat model picker. Persisted locally
* (JSON array) and managed from Settings. Mirrors iOS `ModelShortlistItem`.
* The stable identity is `providerId/modelId`; `displayName`/`shortName` are
* display-only and may be refreshed from the server catalog.
*/
@Serializable
data class ModelShortlistItem(
val providerId: String,
val modelId: String,
val displayName: String,
val shortName: String
) {
val id: String get() = "$providerId/$modelId"
}
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ class OpenCodeRepository @Inject constructor() {

suspend fun getProviders(): Result<ProvidersResponse> = apiCall { api.getProviders() }

suspend fun getProviderRegistry(): Result<ProviderRegistryResponse> = apiCall { api.getProviderRegistry() }

suspend fun getAgents(): Result<List<AgentInfo>> = apiCall { api.getAgents() }

suspend fun getSessionDiff(sessionId: String): Result<List<FileDiff>> = apiCall {
Expand Down
156 changes: 123 additions & 33 deletions app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ data class AppState(
val agents: List<AgentInfo> = emptyList(),
val selectedAgentName: String = "build",
val selectedModelIndex: Int = 2,
val selectedModelId: String? = null,
val modelShortlist: List<ModelShortlistItem> = emptyList(),
val catalogModels: List<CatalogModel> = emptyList(),
val providerDisplayNames: Map<String, String> = emptyMap(),
val pendingModelShortlistFocus: Boolean = false,
val providers: ProvidersResponse? = null,
val pendingPermissions: List<PermissionRequest> = emptyList(),
val pendingQuestions: List<QuestionRequest> = emptyList(),
Expand Down Expand Up @@ -116,21 +121,9 @@ data class AppState(
val aiUsageError: String? = null
) {
data class NfcPendingAction(val prompt: String, val autoSend: Boolean)
data class ModelOption(val displayName: String, val providerId: String, val modelId: String) {
data class ModelOption(val displayName: String, val providerId: String, val modelId: String, val customShortName: String? = null) {
val shortName: String
get() = when {
displayName == "DeepSeek V4 Flash" -> "DS-Flash"
displayName == "DeepSeek Local" -> "DS-L"
displayName == "Ollama GLM 5.2" -> "OGLM-5.2"
displayName == "GPT-5.6 Terra Fast" -> "GPT-TF"
displayName == "GPT-5.6 Luna" -> "GPT-L"
"Haiku" in displayName -> "Haiku"
"Gemini" in displayName -> "Gemini"
"GPT" in displayName -> "GPT"
"Grok" in displayName -> "Grok"
"Qwen" in displayName -> "Qwen"
else -> displayName.split(" ").firstOrNull() ?: displayName
}
get() = customShortName?.trim()?.takeIf { it.isNotEmpty() } ?: suggestedShortName(displayName)
}

data class ContextUsage(
Expand Down Expand Up @@ -209,8 +202,12 @@ data class AppState(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val languageMode: LanguageMode = LanguageMode.SYSTEM,
val selectedModelIndex: Int = 2,
val selectedModelId: String? = null,
val selectedAgentName: String = "build",
val availableModels: List<ModelOption> = ModelPresets.list,
val availableModels: List<ModelOption> = emptyList(),
val modelShortlist: List<ModelShortlistItem> = emptyList(),
val catalogModels: List<CatalogModel> = emptyList(),
val providerDisplayNames: Map<String, String> = emptyMap(),
val contextUsage: ContextUsage? = null,
val agents: List<AgentInfo> = emptyList(),
val providers: ProvidersResponse? = null,
Expand Down Expand Up @@ -279,8 +276,12 @@ data class AppState(
themeMode = themeMode,
languageMode = languageMode,
selectedModelIndex = selectedModelIndex,
selectedModelId = selectedModelId,
selectedAgentName = selectedAgentName,
availableModels = availableModels,
modelShortlist = modelShortlist,
catalogModels = catalogModels,
providerDisplayNames = providerDisplayNames,
contextUsage = contextUsage,
agents = agents,
providers = providers,
Expand All @@ -305,9 +306,16 @@ data class AppState(
val visibleAgents: List<AgentInfo>
get() = agents.filter { it.isVisible }

/** Curated model list (filtered like iOS), not the full API response. */
/** Curated model list (the user shortlist), not the full API response. */
val availableModels: List<ModelOption>
get() = ModelPresets.list
get() = modelShortlist.map { item ->
ModelOption(
displayName = item.displayName,
providerId = item.providerId,
modelId = item.modelId,
customShortName = item.shortName
)
}

val selectedAIUsageQuota: AIUsageQuota?
get() {
Expand All @@ -323,17 +331,7 @@ data class AppState(
}

private val providerModelsIndex: Map<String, ProviderModel>
get() = providers?.providers?.flatMap { provider ->
provider.models.flatMap { (modelKey, model) ->
listOfNotNull(
"${provider.id}/$modelKey" to model,
model.id.takeIf { it.isNotEmpty() }?.let { "${provider.id}/$it" to model },
model.resolvedProviderId?.let { resolvedProvider ->
model.id.takeIf { it.isNotEmpty() }?.let { modelId -> "$resolvedProvider/$modelId" to model }
}
)
}
}?.toMap() ?: emptyMap()
get() = buildProviderModelsIndex(providers)

val contextUsage: ContextUsage?
get() {
Expand Down Expand Up @@ -1549,7 +1547,7 @@ class MainViewModel @Inject constructor(
}

private fun loadProviders() {
launchLoadProviders(hostRuntimeScope, repository, _state) { message, error ->
launchLoadProviders(hostRuntimeScope, repository, _state, settingsManager) { message, error ->
reportNonFatalIssue(TAG, message, error)
}
}
Expand Down Expand Up @@ -1765,10 +1763,102 @@ class MainViewModel @Inject constructor(
}

fun selectModel(index: Int) {
val clamped = index.coerceIn(0, ModelPresets.list.size - 1)
settingsManager.selectedModelIndex = clamped
_state.update { it.copy(selectedModelIndex = clamped) }
_state.value.currentSessionId?.let { settingsManager.setModelForSession(it, clamped) }
val list = _state.value.modelShortlist
if (list.isEmpty()) return
val clamped = index.coerceIn(0, list.size - 1)
val id = list[clamped].id
settingsManager.selectedModelId = id
_state.update { it.copy(selectedModelIndex = clamped, selectedModelId = id) }
_state.value.currentSessionId?.let { settingsManager.setModelIdForSession(it, id) }
}

fun moveModelShortlist(from: Int, to: Int) {
val current = _state.value.modelShortlist
val next = moveShortlistItem(current, from, to)
if (next == current) return
settingsManager.modelShortlistJson = encodeShortlist(next)
_state.update {
it.copy(modelShortlist = next, selectedModelIndex = reanchorSelectedModelIndex(next, it.selectedModelId))
}
}

fun removeModelShortlistItem(id: String) {
val current = _state.value.modelShortlist
val next = removeShortlistItem(current, id)
if (next == current) return
settingsManager.modelShortlistJson = encodeShortlist(next)
// If the removed model was the current selection, fall back to the first
// remaining model (or none) and persist that to both the global selection
// and the current session, so a restart doesn't re-read the deleted ID.
val wasSelected = _state.value.selectedModelId == id
val selectedId = if (wasSelected) next.firstOrNull()?.id else _state.value.selectedModelId
if (wasSelected) {
settingsManager.selectedModelId = next.firstOrNull()?.id
_state.value.currentSessionId?.let { sessionId ->
val fallbackId = next.firstOrNull()?.id
if (fallbackId != null) settingsManager.setModelIdForSession(sessionId, fallbackId)
else settingsManager.removeModelIdForSession(sessionId)
}
}
_state.update {
it.copy(
modelShortlist = next,
selectedModelId = selectedId,
selectedModelIndex = reanchorSelectedModelIndex(next, selectedId)
)
}
}

fun updateModelShortlistShortName(id: String, shortName: String) {
val current = _state.value.modelShortlist
val next = updateShortlistShortName(current, id, shortName)
if (next == current) return
settingsManager.modelShortlistJson = encodeShortlist(next)
_state.update { it.copy(modelShortlist = next) }
}

fun addModelsToShortlist(items: List<ModelShortlistItem>) {
var next = _state.value.modelShortlist
var changed = false
for (item in items) {
val (added, c) = addModelToShortlist(next, item.providerId, item.modelId, item.displayName)
next = added
changed = changed || c
}
if (!changed) return
settingsManager.modelShortlistJson = encodeShortlist(next)
// Ensure the ID invariant: a non-empty shortlist always has a valid
// selectedModelId. When the shortlist was empty (selectedModelId null)
// and we just added items, anchor to the first item and persist.
val currentId = _state.value.selectedModelId
val resolvedId = if (currentId != null && next.any { it.id == currentId }) {
currentId
} else {
next.firstOrNull()?.id
}
if (resolvedId != currentId) {
settingsManager.selectedModelId = resolvedId
_state.value.currentSessionId?.let { sessionId ->
if (resolvedId != null) settingsManager.setModelIdForSession(sessionId, resolvedId)
else settingsManager.removeModelIdForSession(sessionId)
}
}
_state.update {
it.copy(
modelShortlist = next,
selectedModelId = resolvedId,
selectedModelIndex = reanchorSelectedModelIndex(next, resolvedId)
)
}
}

/** Asks the Settings screen to open the model shortlist when it appears. */
fun requestModelShortlistFocus() {
_state.update { it.copy(pendingModelShortlistFocus = true) }
}

fun clearModelShortlistFocus() {
_state.update { it.copy(pendingModelShortlistFocus = false) }
}

fun setThemeMode(mode: ThemeMode) {
Expand Down
Loading
Loading