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
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,23 @@ internal fun NewSessionBody(
}

// --- Effort ---
// An agent whose levels differ by model lists none of its own:
// the chosen model's (or its default model's) are offered, its
// own default among them.
val chosenModel = modelOptions.firstOrNull { it.id == model.ifEmpty { agentModels?.defaultModel.orEmpty() } }
val modelEfforts = chosenModel?.efforts.orEmpty()
val efforts = agent?.efforts.orEmpty()
if (efforts.isNotEmpty()) {
if (modelEfforts.isNotEmpty()) {
if (shown++ > 0) Divider()
ValueRow("Effort") {
SelectField(
options = modelEfforts.map { PickerOption(it.id, it.label) },
// A level the model lacks reads as its default.
selected = effort.takeIf { pick -> modelEfforts.any { it.id == pick } } ?: modelEfforts.first().id,
onSelect = { effort = it },
)
}
} else if (efforts.isNotEmpty()) {
if (shown++ > 0) Divider()
ValueRow("Effort") {
SelectField(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withTimeoutOrNull
import uniffi.client_ffi.UniffiIntent
import uniffi.client_ffi.UniffiModelEntry
import uniffi.client_ffi.UniffiOptionChoice
import uniffi.client_ffi.UniffiSessionMcp
import uniffi.client_ffi.UniffiTranscriptDelta
Expand Down Expand Up @@ -495,13 +496,21 @@ fun SessionScreen(
inputFocus.requestFocus()
}

// Refresh the subscription-usage snapshot on open: the bridge only
// publishes usage when asked. An agent whose catalog entry says it has
// no usage is not asked (it would publish nothing); until the catalog
// is known the effect waits, and fires once it says yes.
// Refresh the usage snapshot on open and whenever a turn ends (the
// session's cost grows with each): the bridge only publishes usage when
// asked. An agent whose catalog entry says it has no usage is not asked
// (it would publish nothing); until the catalog is known the effect
// waits, and fires once it says yes.
// The agent's model list names the session's model in the controls bar.
LaunchedEffect(machine, agent?.id, agent?.supportsModels) {
val id = agent?.id
if (id != null && agent.supportsModels) core.dispatch(UniffiIntent.RequestModels(machine = machine, agent = id))
}

val supportsUsage = agent?.supportsUsage == true
LaunchedEffect(machine, sessionId, supportsUsage) {
if (supportsUsage) core.dispatch(UniffiIntent.RequestUsage(machine = machine, sessionId = sessionId))
val turnRunning = session?.state == "running"
LaunchedEffect(machine, sessionId, supportsUsage, turnRunning) {
if (supportsUsage && !turnRunning) core.dispatch(UniffiIntent.RequestUsage(machine = machine, sessionId = sessionId))
}

// Slash commands: the menu opens while the draft is a bare `/name` (not
Expand Down Expand Up @@ -666,11 +675,20 @@ fun SessionScreen(

SessionControlsBar(
effort = session?.effort,
efforts = agent?.efforts.orEmpty(),
// The session model's own levels, for an agent whose levels
// differ by model; else the agent's.
efforts = machineSummary?.models?.firstOrNull { it.agent == session?.agent }?.models
?.firstOrNull { it.id == session?.model }?.efforts?.takeIf { it.isNotEmpty() }
?: agent?.efforts.orEmpty(),
// The button shows only when the agent has modes to switch between.
modeLabel = modeLabel?.takeIf { modes.size >= 2 },
modePending = modeCycle.pending != null,
model = session?.model,
modelName = listedModelName(
session?.model,
listOfNotNull(machineSummary?.models?.firstOrNull { it.agent == session?.agent }?.models) +
machineSummary?.providerProfiles.orEmpty().filter { it.agent == session?.agent }.map { it.models },
),
contextPercentage = session?.contextPercentage,
contextWindow = session?.contextWindow?.toLong(),
onEffortSelect = { level ->
Expand Down Expand Up @@ -855,6 +873,8 @@ internal fun SessionControlsBar(
/** `null` hides the MCP pill (no servers, or an agent without MCP). */
mcp: UniffiSessionMcp? = null,
onMcpTap: () -> Unit = {},
/** The model list's name for [model] ([listedModelName]); null shows its tag. */
modelName: String? = null,
) {
Row(
Modifier
Expand All @@ -865,7 +885,7 @@ internal fun SessionControlsBar(
horizontalArrangement = Arrangement.spacedBy(Tokens.Space2),
) {
if (model != null) {
ModelContextChip(model = model, contextPercentage = contextPercentage, contextWindow = contextWindow)
ModelContextChip(model = model, modelName = modelName, contextPercentage = contextPercentage, contextWindow = contextWindow)
}
if (modeLabel != null) {
ModeButton(modeLabel, modePending, onModeTap)
Expand All @@ -876,6 +896,9 @@ internal fun SessionControlsBar(
if (mcp != null) {
McpChip(mcp, onMcpTap)
}
// What the session has cost so far: the agent's figure, shown whether
// or not the subscription windows are.
usage?.sessionCostUsd?.let(::sessionCost)?.let { CostPill(it) }
val badges = usageBadges(usage, System.currentTimeMillis())
if (showUsageBadge && badges.isNotEmpty()) {
UsageBox(usage, badges)
Expand Down Expand Up @@ -916,6 +939,31 @@ internal fun SendFailedBar(text: String, failedCount: Int, onRetry: () -> Unit)
}
}

/** A session's cost as a pill: "$0.42", "<$0.01" below a cent; nothing for
* a free (or unpriced) session. */
internal fun sessionCost(usd: Double): String? = when {
!usd.isFinite() || usd <= 0.0 -> null
usd < 0.01 -> "<$0.01"
usd < 100.0 -> "$" + String.format(java.util.Locale.ROOT, "%.2f", usd)
else -> "$" + String.format(java.util.Locale.ROOT, "%.0f", usd)
}

@Composable
private fun CostPill(cost: String) {
Text(
cost,
color = Tokens.TextMuted,
fontSize = Tokens.TextXs,
fontWeight = FontWeight.Bold,
maxLines = 1,
modifier = Modifier
.clip(RoundedCornerShape(Tokens.RadiusPill))
.background(Tokens.SurfaceHover)
.padding(horizontal = ControlPadH, vertical = ControlPadV)
.semantics { contentDescription = "Session cost $cost" },
)
}

/** Effort dropdown over the agent's advertised levels: shows the current
* level ("effort…" until the bridge reports one), opens the list on tap. The
* placeholder carries the muted "effort…" trigger for the not-yet-reported
Expand Down Expand Up @@ -1078,6 +1126,18 @@ internal fun modelLabel(id: String?): String {
return base.replace(Regex("^claude-"), "").replace(Regex("-\\d{8}$"), "")
}

/**
* The name a model list gives the session's model [id] — its agent's list,
* or a provider profile's — for a model [MODEL_TAGS] has no compact tag
* for: an OpenCode or gateway id such as `ccr/OpenCode Go/deepseek-v4.1-flash`
* reads as `deepseek-v4.1-flash`, as in the model picker. Null when the tag
* table knows it, or no list names it.
*/
internal fun listedModelName(id: String?, lists: List<List<UniffiModelEntry>>): String? {
if (id.isNullOrEmpty() || MODEL_TAGS.any { (modelId) -> modelId == stripContextMarker(id) }) return null
return lists.firstNotNullOfOrNull { list -> list.firstOrNull { it.id == id }?.label?.takeIf { it.isNotBlank() } }
}

/** Widest the model tag in [ModelContextChip] gets before it marquees. */
private val MODEL_TAG_MAX_WIDTH = 160.dp

Expand Down Expand Up @@ -1109,13 +1169,15 @@ private val MODEL_TAG_MAX_WIDTH = 160.dp
@Composable
private fun ModelContextChip(
model: String,
/** The model list's name for it ([listedModelName]), when it has one. */
modelName: String?,
contextPercentage: Double?,
contextWindow: Long?,
) {
Layout(
content = {
Text(
modelLabel(model),
modelName ?: modelLabel(model),
color = Tokens.Accent,
fontWeight = FontWeight.Bold,
fontSize = Tokens.TextXs,
Expand Down
11 changes: 10 additions & 1 deletion apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6119,6 +6119,12 @@ data class UniffiModelEntry (
* How many tokens the model takes in, when known.
*/
var `contextWindow`: kotlin.UInt?
,
/**
* The model's own reasoning levels, for an agent whose levels differ by
* model; empty: the agent's.
*/
var `efforts`: List<UniffiOptionChoice>

){

Expand All @@ -6139,21 +6145,24 @@ public object FfiConverterTypeUniffiModelEntry: FfiConverterRustBuffer<UniffiMod
FfiConverterOptionalString.read(buf),
FfiConverterOptionalString.read(buf),
FfiConverterOptionalUInt.read(buf),
FfiConverterSequenceTypeUniffiOptionChoice.read(buf),
)
}

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

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)
FfiConverterSequenceTypeUniffiOptionChoice.write(value.`efforts`, buf)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,21 +92,21 @@ 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", "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),
UniffiModelEntry("OpenCode Go/deepseek-v4.1-flash", "deepseek-v4.1-flash", "Home gateway · OpenCode Go", 1_000_000u, emptyList()),
UniffiModelEntry("Z.ai/glm-5.3-flash", "glm-5.3-flash", "Home gateway · Z.ai", 200_000u, emptyList()),
UniffiModelEntry("qwen3.8-coder", null, "Home gateway", null, emptyList()),
),
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", "model-$it", "OpenRouter · vendor", null) },
models = (1..42).map { UniffiModelEntry("vendor/model-$it", "model-$it", "OpenRouter · vendor", null, emptyList()) },
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", null)),
models = listOf(UniffiModelEntry("kimi-k3", "Kimi K3", "Kimi K3", null, emptyList())),
modelsFromProvider = false, defaultModel = "kimi-k3", hasToken = true, error = null,
),
)
Expand Down Expand Up @@ -188,13 +188,25 @@ internal object DesignFixtures {
models = listOf(UniffiAgentModels(
"claude-code",
listOf(
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),
UniffiModelEntry("opus", "Opus", "Anthropic", null, emptyList()),
UniffiModelEntry("fable", "Fable", "Anthropic", null, emptyList()),
UniffiModelEntry("OpenCode Go/glm-5.3-flash", "glm-5.3-flash", "OpenCode Go", null, emptyList()),
UniffiModelEntry("Z.ai/glm-5.3-flash", "glm-5.3-flash", "Z.ai", null, emptyList()),
),
"opus",
null,
), UniffiAgentModels(
"opencode",
listOf(
UniffiModelEntry(
"home-gateway/OpenCode Go/deepseek-v4.1-flash", "deepseek-v4.1-flash", "Home gateway · OpenCode Go", null,
listOf("default" to "Default", "low" to "Low", "medium" to "Medium", "high" to "High", "max" to "Max")
.map { (id, label) -> UniffiOptionChoice(id, label, null) },
),
UniffiModelEntry("opencode/big-pickle", "Big Pickle", "OpenCode Zen", null, emptyList()),
),
"home-gateway/OpenCode Go/deepseek-v4.1-flash",
null,
)),
providerProfiles = providerProfiles, plugins = listOf(claudePlugins, opencodePlugins), mcp = listOf(claudeMcp),
directAdvertised = listOf("wss://192.168.1.20:7447"), directPinned = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import com.codedeck.plus.ui.transcript.DisplayEntry
import com.codedeck.plus.ui.screens.AccountPage
import com.codedeck.plus.ui.screens.BackupPage
import com.codedeck.plus.ui.screens.RestoreContent
import uniffi.client_ffi.UniffiUsageData
import uniffi.client_ffi.UniffiBackupStatus
import uniffi.client_ffi.UniffiBackupView
import com.codedeck.plus.ui.screens.SettingsHub
Expand Down Expand Up @@ -93,6 +94,16 @@ private val pages: Map<String, @Composable () -> Unit> = linkedMapOf(
)
}
},
"session_controls_opencode" to {
Column(Modifier.background(Tokens.Bg)) {
SessionControlsBar(
effort = null, efforts = emptyList(), modeLabel = "Plan", modePending = false, model = "home-gateway/OpenCode Go/deepseek-v4.1-flash",
contextPercentage = 18.0, contextWindow = 1_000_000, onEffortSelect = {}, onModeTap = {}, showUsageBadge = true,
usage = UniffiUsageData(available = true, plan = null, windows = emptyList(), sessionCostUsd = 0.0371, fetchedAt = "2026-10-08T10:00:00Z"),
modelName = "deepseek-v4.1-flash",
)
}
},
"session_commands" to {
Column(Modifier.background(Tokens.Bg)) {
SessionTopBar(title = "Fix the flaky reconnect test", workspace = "/home/me/code/codedeck-plus", sessionState = "idle", onBack = {})
Expand Down Expand Up @@ -156,6 +167,9 @@ private val pages: Map<String, @Composable () -> Unit> = linkedMapOf(
"new_session" to {
NewSessionBody(machine = workstation, events = MutableSharedFlow<CoreEvent>(), dispatch = {}, onClose = {}, onCreated = {})
},
"new_session_opencode" to {
NewSessionBody(machine = workstation.copy(defaultAgent = "opencode"), events = MutableSharedFlow<CoreEvent>(), dispatch = {}, onClose = {}, onCreated = {})
},
"pairing" to {
CompositionLocalProvider(LocalActivityResultRegistryOwner provides DesignFixtures.noResults) {
PairingBody(
Expand Down Expand Up @@ -276,12 +290,14 @@ class DesignSnapshotTest {
@Test fun home() = paparazzi.page("home")
@Test fun home_nothing_paired() = paparazzi.page("home_nothing_paired")
@Test fun session() = paparazzi.page("session")
@Test fun session_controls_opencode() = paparazzi.page("session_controls_opencode")
@Test fun session_commands() = paparazzi.page("session_commands")
@Test fun transcript() = paparazzi.page("transcript")
@Test fun transcript_plan() = paparazzi.page("transcript_plan")
@Test fun transcript_cards() = paparazzi.page("transcript_cards")
@Test fun transcript_long() = paparazzi.page("transcript_long")
@Test fun new_session() = paparazzi.page("new_session")
@Test fun new_session_opencode() = paparazzi.page("new_session_opencode")
@Test fun pairing() = paparazzi.page("pairing")
@Test fun settings() = paparazzi.page("settings")
@Test fun plugins() = paparazzi.page("plugins")
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", null),
UniffiModelEntry("Z.ai/glm-5.3-flash", "glm-5.3-flash", "Z.ai", null),
UniffiModelEntry("OpenCode Go/minimax-m3", null, "OpenCode Go", null),
UniffiModelEntry("OpenCode Go/glm-5.3-flash", "glm-5.3-flash", "OpenCode Go", null, emptyList()),
UniffiModelEntry("Z.ai/glm-5.3-flash", "glm-5.3-flash", "Z.ai", null, emptyList()),
UniffiModelEntry("OpenCode Go/minimax-m3", null, "OpenCode Go", null, emptyList()),
),
)
assertEquals(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.codedeck.plus.ui.session

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import uniffi.client_ffi.UniffiModelEntry

/** Port of `apps/mobile/src/ui/__tests__/modelLabel.test.ts` — the same
* cases, asserting the Kotlin port of `modelLabel` (SessionScreen.kt) keeps
Expand Down Expand Up @@ -45,4 +47,15 @@ class ModelLabelTest {
assertEquals("?", modelLabel(null))
assertEquals("?", modelLabel(""))
}

@Test
fun aListedModelReadsByItsListName() {
val opencode = listOf(UniffiModelEntry("ccr/OpenCode Go/deepseek-v4.1-flash", "deepseek-v4.1-flash", "CCR · OpenCode Go", null, emptyList()))
val profile = listOf(UniffiModelEntry("kimi-k3", "Kimi K3", "Moonshot", null, emptyList()))
assertEquals("deepseek-v4.1-flash", listedModelName("ccr/OpenCode Go/deepseek-v4.1-flash", listOf(opencode, profile)))
assertEquals("Kimi K3", listedModelName("kimi-k3", listOf(opencode, profile)))
// Claude's compact tags stay; an unlisted model has no name.
assertNull(listedModelName("claude-opus-5", listOf(listOf(UniffiModelEntry("claude-opus-5", "Opus 5", null, null, emptyList())))))
assertNull(listedModelName("unlisted/model", listOf(opencode)))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class SessionChromeSnapshotTest {
UniffiUsageWindow(label = "5h", utilization = 61.0, resetsAt = null),
UniffiUsageWindow(label = "7d", utilization = 23.0, resetsAt = null),
),
sessionCostUsd = null,
sessionCostUsd = 0.42,
fetchedAt = "2026-09-23T10:00:00Z",
),
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.codedeck.plus.ui.session

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

class SessionCostTest {
@Test
fun a_cost_reads_in_dollars_and_a_free_session_shows_none() {
assertEquals("$0.42", sessionCost(0.4213))
assertEquals("<$0.01", sessionCost(0.004))
assertEquals("$12.30", sessionCost(12.3))
assertEquals("$140", sessionCost(139.6))
assertNull(sessionCost(0.0))
assertNull(sessionCost(Double.NaN))
}
}
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.
Loading
Loading