Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.codedeck.plus.ui.screens

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.Checkbox
Expand All @@ -17,10 +19,13 @@ import androidx.compose.runtime.rememberCoroutineScope
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.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import com.codedeck.plus.core.CoreHost
import com.codedeck.plus.ui.components.Chip
import com.codedeck.plus.ui.components.ErrorNote
import com.codedeck.plus.ui.components.ExpandableRow
import com.codedeck.plus.ui.components.Field
import com.codedeck.plus.ui.components.Group
import com.codedeck.plus.ui.components.GroupBody
Expand Down Expand Up @@ -114,6 +119,8 @@ fun ProvidersContent(
onBack: () -> Unit,
/** Opens straight on the editor (for a snapshot). */
startEditing: ProviderEditor? = null,
/** The provider shown opened (for a snapshot). */
openProfile: String? = null,
/** The core's base URL rule — a native call, so a snapshot, which cannot
* load the core, passes its own. */
validBaseUrl: (String) -> Boolean = ::isValidProviderBaseUrl,
Expand Down Expand Up @@ -187,8 +194,7 @@ fun ProvidersContent(
Group(footer = agent?.let { providerUse(it) + " Tokens stay on the machine; this phone never stores them." }) {
own.forEachIndexed { i, p ->
if (i > 0) Divider()
GroupBody {
ProfileSummary(p)
ProfileRow(p, startOpen = p.id == openProfile) {
if (confirmDelete == p.id) {
Text(
if (agent?.supportsProviderModels == true) {
Expand Down Expand Up @@ -229,8 +235,7 @@ fun ProvidersContent(
) {
unassigned.forEachIndexed { i, p ->
if (i > 0) Divider()
GroupBody {
ProfileSummary(p)
ProfileRow(p, startOpen = p.id == openProfile) {
Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space1)) {
QuietButton("Use for $agentName", onClick = { write(p.id, rewrite(p, agentId)) }, enabled = !busy && p.hasToken)
QuietButton("Delete", onClick = { write(p.id, null) }, danger = true, enabled = !busy)
Expand All @@ -244,24 +249,92 @@ fun ProvidersContent(
}
}

/** "3 models", "1 model". */
private fun modelCount(n: Int) = "$n ${if (n == 1) "model" else "models"}"

/**
* One provider: its name and endpoint, with a chip for what matters most —
* why its agent does not offer it, a missing token, or how many models it
* has. Opened, it lists its models and then [actions].
*/
@Composable
private fun ProfileSummary(p: UniffiProviderProfileInfo) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) {
Text(p.label, color = Tokens.Text, fontSize = Tokens.TextMd, modifier = Modifier.weight(1f))
Chip(
if (p.hasToken) "token set" else "no token",
color = if (p.hasToken) Tokens.Success else Tokens.TextDim,
border = if (p.hasToken) Tokens.Success.copy(alpha = 0.4f) else Tokens.Border,
)
private fun ProfileRow(p: UniffiProviderProfileInfo, startOpen: Boolean, actions: @Composable () -> Unit) {
var open by remember(p.id) { mutableStateOf(startOpen) }
ExpandableRow(
p.label,
p.baseUrl,
enabled = p.error == null,
open = open,
onOpenChange = { open = it },
subtitleMono = true,
openSubtitleLines = 3,
trailing = {
when {
p.error != null -> Chip("not offered", color = Tokens.Danger, border = Tokens.Danger.copy(alpha = 0.4f))
!p.hasToken -> Chip("no token", color = Tokens.Warn, border = Tokens.Warn.copy(alpha = 0.4f))
else -> Chip(modelCount(p.models.size))
}
},
) {
Column(Modifier.padding(top = Tokens.Space2), verticalArrangement = Arrangement.spacedBy(Tokens.Space3)) {
// Why the agent does not offer its models (its name is taken, say).
p.error?.let { Text(it, color = Tokens.Danger, fontSize = Tokens.TextSm) }
Text(
(if (p.modelsFromProvider) "Models read from the provider at the last save" else "Models typed in by hand") +
if (p.hasToken) " · token set" else " · no token, so nothing runs on it",
color = Tokens.TextMuted,
fontSize = Tokens.TextSm,
)
ProfileModels(p)
actions()
}
}
}

/**
* A profile's models, under the provider a gateway routes each to when the
* endpoint names one (the part of the model's group after the profile's
* name); its default marked.
*/
@Composable
private fun ProfileModels(p: UniffiProviderProfileInfo) {
// Those under no other provider first, so none reads as part of a group.
val byUpstream = p.models
.groupBy { m -> m.provider?.removePrefix(p.label)?.removePrefix(" · ")?.takeIf { it.isNotEmpty() } }
.entries
.sortedBy { it.key != null }
val default = p.defaultModel ?: p.models.firstOrNull()?.id
Column(verticalArrangement = Arrangement.spacedBy(Tokens.Space2)) {
byUpstream.forEach { (upstream, models) ->
if (upstream != null) Text(upstream, color = Tokens.TextMuted, fontSize = Tokens.TextXs, fontWeight = FontWeight.Medium)
models.forEach { m ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) {
Text(
m.label ?: m.id,
color = Tokens.Text,
fontSize = Tokens.TextMd,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (m.id == default) Chip("default")
m.contextWindow?.let { Text(contextSize(it), color = Tokens.TextDim, fontSize = Tokens.TextXs, fontFamily = Tokens.FontMono) }
}
}
}
}
}

/** A context window as people write it: 1M, 200K, 128K (2^17 tokens). */
internal fun contextSize(tokens: UInt): String {
val n = tokens.toLong()
fun scaled(unit: Long, binary: Long, suffix: String): String =
"${if (n % binary == 0L) n / binary else Math.round(n.toDouble() / unit)}$suffix"
return when {
n >= 1_000_000L -> scaled(1_000_000L, 1L shl 20, "M")
n >= 1_000L -> scaled(1_000L, 1L shl 10, "K")
else -> n.toString()
}
Text(p.baseUrl, color = Tokens.TextMuted, fontSize = Tokens.TextSm, fontFamily = Tokens.FontMono)
Text(
"${p.models.size} ${if (p.models.size == 1) "model" else "models"}" +
(if (p.modelsFromProvider) " from the provider" else "") +
(p.defaultModel?.let { ", default $it" } ?: ""),
color = Tokens.TextMuted,
fontSize = Tokens.TextSm,
)
}

/** The last save's outcome, in words. */
Expand Down
20 changes: 18 additions & 2 deletions apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6114,6 +6114,11 @@ data class UniffiModelEntry (
* Who serves the model, when the agent says.
*/
var `provider`: kotlin.String?
,
/**
* How many tokens the model takes in, when known.
*/
var `contextWindow`: kotlin.UInt?

){

Expand All @@ -6133,19 +6138,22 @@ public object FfiConverterTypeUniffiModelEntry: FfiConverterRustBuffer<UniffiMod
FfiConverterString.read(buf),
FfiConverterOptionalString.read(buf),
FfiConverterOptionalString.read(buf),
FfiConverterOptionalUInt.read(buf),
)
}

override fun allocationSize(value: UniffiModelEntry) = (
FfiConverterString.allocationSize(value.`id`) +
FfiConverterOptionalString.allocationSize(value.`label`) +
FfiConverterOptionalString.allocationSize(value.`provider`)
FfiConverterOptionalString.allocationSize(value.`provider`) +
FfiConverterOptionalUInt.allocationSize(value.`contextWindow`)
)

override fun write(value: UniffiModelEntry, buf: ByteBuffer) {
FfiConverterString.write(value.`id`, buf)
FfiConverterOptionalString.write(value.`label`, buf)
FfiConverterOptionalString.write(value.`provider`, buf)
FfiConverterOptionalUInt.write(value.`contextWindow`, buf)
}
}

Expand Down Expand Up @@ -6803,6 +6811,11 @@ data class UniffiProviderProfileInfo (
var `defaultModel`: kotlin.String?
,
var `hasToken`: kotlin.Boolean
,
/**
* Why the agent does not offer this profile's models, when it does not.
*/
var `error`: kotlin.String?

){

Expand All @@ -6827,6 +6840,7 @@ public object FfiConverterTypeUniffiProviderProfileInfo: FfiConverterRustBuffer<
FfiConverterBoolean.read(buf),
FfiConverterOptionalString.read(buf),
FfiConverterBoolean.read(buf),
FfiConverterOptionalString.read(buf),
)
}

Expand All @@ -6838,7 +6852,8 @@ public object FfiConverterTypeUniffiProviderProfileInfo: FfiConverterRustBuffer<
FfiConverterSequenceTypeUniffiModelEntry.allocationSize(value.`models`) +
FfiConverterBoolean.allocationSize(value.`modelsFromProvider`) +
FfiConverterOptionalString.allocationSize(value.`defaultModel`) +
FfiConverterBoolean.allocationSize(value.`hasToken`)
FfiConverterBoolean.allocationSize(value.`hasToken`) +
FfiConverterOptionalString.allocationSize(value.`error`)
)

override fun write(value: UniffiProviderProfileInfo, buf: ByteBuffer) {
Expand All @@ -6850,6 +6865,7 @@ public object FfiConverterTypeUniffiProviderProfileInfo: FfiConverterRustBuffer<
FfiConverterBoolean.write(value.`modelsFromProvider`, buf)
FfiConverterOptionalString.write(value.`defaultModel`, buf)
FfiConverterBoolean.write(value.`hasToken`, buf)
FfiConverterOptionalString.write(value.`error`, buf)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,21 +92,22 @@ internal object DesignFixtures {
UniffiProviderProfileInfo(
id = "home-gateway", agent = "opencode", label = "Home gateway", baseUrl = "http://192.168.1.2:3458",
models = listOf(
UniffiModelEntry("OpenCode Go/deepseek-v4.1-flash", null, "Home gateway"),
UniffiModelEntry("Z.ai/glm-5.3-flash", null, "Home gateway"),
UniffiModelEntry("qwen3.8-coder", null, "Home gateway"),
UniffiModelEntry("OpenCode Go/deepseek-v4.1-flash", "deepseek-v4.1-flash", "Home gateway · OpenCode Go", 1_000_000u),
UniffiModelEntry("Z.ai/glm-5.3-flash", "glm-5.3-flash", "Home gateway · Z.ai", 200_000u),
UniffiModelEntry("qwen3.8-coder", null, "Home gateway", null),
),
modelsFromProvider = true, defaultModel = "OpenCode Go/deepseek-v4.1-flash", hasToken = true,
modelsFromProvider = true, defaultModel = "OpenCode Go/deepseek-v4.1-flash", hasToken = true, error = null,
),
UniffiProviderProfileInfo(
id = "openrouter", agent = "opencode", label = "OpenRouter", baseUrl = "https://openrouter.ai/api",
models = (1..42).map { UniffiModelEntry("vendor/model-$it", null, "OpenRouter") },
models = (1..42).map { UniffiModelEntry("vendor/model-$it", "model-$it", "OpenRouter · vendor", null) },
modelsFromProvider = true, defaultModel = null, hasToken = false,
error = "OpenCode already has a provider called 'openrouter'. Give this profile another name.",
),
UniffiProviderProfileInfo(
id = "kimi-k3", agent = "", label = "Kimi K3", baseUrl = "https://api.moonshot.ai/anthropic",
models = listOf(UniffiModelEntry("kimi-k3", "Kimi K3", "Kimi K3")),
modelsFromProvider = false, defaultModel = "kimi-k3", hasToken = true,
models = listOf(UniffiModelEntry("kimi-k3", "Kimi K3", "Kimi K3", null)),
modelsFromProvider = false, defaultModel = "kimi-k3", hasToken = true, error = null,
),
)

Expand Down Expand Up @@ -187,10 +188,10 @@ internal object DesignFixtures {
models = listOf(UniffiAgentModels(
"claude-code",
listOf(
UniffiModelEntry("opus", "Opus", "Anthropic"),
UniffiModelEntry("fable", "Fable", "Anthropic"),
UniffiModelEntry("OpenCode Go/glm-5.3-flash", "glm-5.3-flash", "OpenCode Go"),
UniffiModelEntry("Z.ai/glm-5.3-flash", "glm-5.3-flash", "Z.ai"),
UniffiModelEntry("opus", "Opus", "Anthropic", null),
UniffiModelEntry("fable", "Fable", "Anthropic", null),
UniffiModelEntry("OpenCode Go/glm-5.3-flash", "glm-5.3-flash", "OpenCode Go", null),
UniffiModelEntry("Z.ai/glm-5.3-flash", "glm-5.3-flash", "Z.ai", null),
),
"opus",
null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ private val pages: Map<String, @Composable () -> Unit> = linkedMapOf(
},
"plugins_opencode" to { PluginsContent(workstation, "opencode", dispatch = {}, onBack = {}) },
"providers_opencode" to { ProvidersContent(workstation, "opencode", status = null, dispatch = {}, onBack = {}) },
"providers_opencode_open" to { ProvidersContent(workstation, "opencode", status = null, dispatch = {}, onBack = {}, openProfile = "home-gateway") },
"providers_claude_empty" to { ProvidersContent(workstation, "claude-code", status = null, dispatch = {}, onBack = {}) },
"providers_edit" to {
ProvidersContent(workstation, "opencode", status = null, dispatch = {}, onBack = {}, startEditing = ProviderEditor.Existing("home-gateway"), validBaseUrl = { true })
Expand Down Expand Up @@ -290,6 +291,7 @@ class DesignSnapshotTest {
@Test fun mcp_add() = paparazzi.page("mcp_add")
@Test fun mcp_import() = paparazzi.page("mcp_import")
@Test fun providers_opencode() = paparazzi.page("providers_opencode")
@Test fun providers_opencode_open() = paparazzi.page("providers_opencode_open")
@Test fun providers_claude_empty() = paparazzi.page("providers_claude_empty")
@Test fun providers_edit() = paparazzi.page("providers_edit")
@Test fun providers_add() = paparazzi.page("providers_add")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ class ModelPickerOptionsTest {
fun `models are grouped by provider, in the order providers first appear`() {
val options = modelPickerOptions(
listOf(
UniffiModelEntry("OpenCode Go/glm-5.3-flash", "glm-5.3-flash", "OpenCode Go"),
UniffiModelEntry("Z.ai/glm-5.3-flash", "glm-5.3-flash", "Z.ai"),
UniffiModelEntry("OpenCode Go/minimax-m3", null, "OpenCode Go"),
UniffiModelEntry("OpenCode Go/glm-5.3-flash", "glm-5.3-flash", "OpenCode Go", null),
UniffiModelEntry("Z.ai/glm-5.3-flash", "glm-5.3-flash", "Z.ai", null),
UniffiModelEntry("OpenCode Go/minimax-m3", null, "OpenCode Go", null),
),
)
assertEquals(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.codedeck.plus.ui.screens

import org.junit.Assert.assertEquals
import org.junit.Test

class ProvidersTest {
@Test
fun a_context_window_reads_as_people_write_it() {
assertEquals("1M", contextSize(1_000_000u))
assertEquals("1M", contextSize(1_048_576u))
assertEquals("200K", contextSize(200_000u))
assertEquals("128K", contextSize(131_072u))
assertEquals("33K", contextSize(32_768u + 1u))
assertEquals("512", contextSize(512u))
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion crates/agent-protocol/src/codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ impl HostMessage {
| Self::McpServers { .. }
| Self::SessionMcp { .. }
| Self::CredentialChecked { .. }
| Self::ProviderModels { .. } => true,
| Self::ProviderModels { .. }
| Self::ProvidersSet { .. } => true,
Self::SessionEvent { .. }
| Self::RequestPermission(_)
| Self::AskQuestion(_)
Expand Down
19 changes: 16 additions & 3 deletions crates/agent-protocol/src/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ pub struct ProviderBinding {
pub default_model: Option<String>,
}

/// A provider profile an agent left out of `set-providers`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
pub struct RefusedProvider {
/// The profile's id.
pub id: String,
pub reason: String,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct StartSession {
Expand Down Expand Up @@ -415,9 +423,11 @@ pub enum BridgeMessage {
auth_token: Secret,
},
/// The provider profiles of an agent whose catalog entry `supports`
/// `providerModels`, all of them: sent after `initialize` and whenever
/// one changes. The agent offers their models beside its own. Reply:
/// `ack`.
/// `providerModels`, all of them, oldest saved first: sent after
/// `initialize` and whenever one changes. The agent offers their models
/// beside its own. One it cannot add (its name is taken by one of the
/// agent's own providers, or by an earlier profile) is left out. Reply:
/// `providers-set`.
SetProviders {
agent: String,
providers: Vec<ProviderBinding>,
Expand Down Expand Up @@ -494,6 +504,9 @@ pub enum HostMessage {
},
/// Reply to `list-provider-models`: never empty.
ProviderModels { models: Vec<ProviderModel> },
/// Reply to `set-providers`: the profiles left out, with the reason in
/// words for a person; every other one is offered.
ProvidersSet { refused: Vec<RefusedProvider> },
/// A notification: no frame id, no reply.
SessionEvent {
session_id: String,
Expand Down
Loading
Loading