feat(app): pickers for agent key, kind, and persona model/provider/runtime

Replace raw-code text fields in the Buzz agent flows with pickers, matching
the workflow-definition picker and the app's existing people-typeahead.

- Agent Attestation "Agent public key": a single-agent people picker
  (name typeahead over the local user cache + removable chip), with
  npub/hex paste kept as the Enter escape hatch for keys that aren't
  contacts yet.
- Agent Attestation "Restrict to kind": an editable dropdown of common
  named kinds (1 Text note, 7 Reaction, …), free numeric entry preserved,
  with the resolved name shown as supporting text.
- Agent Persona edit "Model / Provider / Runtime": editable dropdowns of
  well-known values (claude-*, gpt-*, anthropic/openai/…, goose/…), free
  entry preserved since these stay free-form on the wire.
- New reusable EditableSuggestDropdown (BuzzOptionDropdown.kt) backing the
  open-ended pickers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
This commit is contained in:
Claude
2026-07-26 22:06:57 +00:00
parent 123a9545fb
commit e971a8ccd0
3 changed files with 290 additions and 28 deletions
@@ -20,50 +20,84 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.InputChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
// Common event kinds an agent might be restricted to — suggestions for the "Restrict to kind" field;
// any 0–65535 is still accepted by free numeric entry.
private val KIND_OPTIONS =
listOf(
DropdownOption("1", "1 · Text note"),
DropdownOption("7", "7 · Reaction"),
DropdownOption("9", "9 · Group chat message"),
DropdownOption("1111", "1111 · Comment"),
DropdownOption("30023", "30023 · Long-form article"),
DropdownOption("40002", "40002 · Buzz minichat message"),
)
private val KIND_LABELS = KIND_OPTIONS.associate { it.value to it.label }
/**
* Owner-side NIP-OA attestation issuance. The owner signs a standalone commitment
* ([OwnerAttestation]) authorizing an agent pubkey to publish under optional
@@ -106,7 +140,7 @@ fun AgentAttestationScreen(
if (privKey == null) {
ReadOnlyKeyNotice()
} else {
AttestationForm(ownerKey = keyPair)
AttestationForm(ownerKey = keyPair, accountViewModel = accountViewModel, nav = nav)
}
}
}
@@ -246,11 +280,15 @@ private fun ReadOnlyKeyNotice() {
}
@Composable
private fun AttestationForm(ownerKey: KeyPair) {
private fun AttestationForm(
ownerKey: KeyPair,
accountViewModel: AccountViewModel,
nav: INav,
) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
var agentInput by remember { mutableStateOf("") }
var selectedAgent by remember { mutableStateOf<HexKey?>(null) }
var kindInput by remember { mutableStateOf("") }
var afterInput by remember { mutableStateOf("") }
var beforeInput by remember { mutableStateOf("") }
@@ -265,16 +303,23 @@ private fun AttestationForm(ownerKey: KeyPair) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = agentInput,
onValueChange = {
agentInput = it
// #1: pick the agent by name from the local user cache — or paste an npub/hex for a key that
// isn't a contact yet (the common case for an external agent operator).
AgentKeyPicker(
selected = selectedAgent,
accountViewModel = accountViewModel,
nav = nav,
onSelect = {
selectedAgent = it
error = null
result = null
},
label = { Text("Agent public key (npub or hex)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
onClear = {
selectedAgent = null
error = null
result = null
},
onInvalidPaste = { error = "Invalid agent public key. Enter an npub or 64-char hex key." },
)
Text(
@@ -283,17 +328,18 @@ private fun AttestationForm(ownerKey: KeyPair) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
// #3: kind is any 0–65535, but the common ones have names — offer them, keep free numeric entry.
EditableSuggestDropdown(
value = kindInput,
onValueChange = {
kindInput = it.filter(Char::isDigit)
error = null
result = null
},
label = { Text("Restrict to kind (0–65535)") },
singleLine = true,
label = "Restrict to kind (0–65535)",
options = KIND_OPTIONS,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
supportingText = KIND_LABELS[kindInput],
)
OutlinedTextField(
@@ -332,7 +378,8 @@ private fun AttestationForm(ownerKey: KeyPair) {
Button(
onClick = {
when (val outcome = buildAttestation(agentInput, kindInput, afterInput, beforeInput, ownerKey)) {
val agent = selectedAgent ?: return@Button
when (val outcome = buildAttestation(agent, kindInput, afterInput, beforeInput, ownerKey)) {
is AttestationOutcome.Failure -> {
error = outcome.message
result = null
@@ -343,7 +390,7 @@ private fun AttestationForm(ownerKey: KeyPair) {
}
}
},
enabled = agentInput.isNotBlank(),
enabled = selectedAgent != null,
modifier = Modifier.fillMaxWidth(),
) {
Text("Generate attestation")
@@ -357,6 +404,106 @@ private fun AttestationForm(ownerKey: KeyPair) {
}
}
/**
* Single-agent people picker: once an agent is chosen it shows as a removable chip; otherwise a
* name typeahead over the local user cache with an npub/hex paste escape hatch (Enter accepts it).
* Mirrors the New-DM recipient picker so authorizing an agent stops being the one raw-key field.
*/
@Composable
private fun AgentKeyPicker(
selected: HexKey?,
accountViewModel: AccountViewModel,
nav: INav,
onSelect: (HexKey) -> Unit,
onClear: () -> Unit,
onInvalidPaste: () -> Unit,
) {
if (selected != null) {
AgentChip(selected, accountViewModel, nav, onClear)
return
}
var query by remember { mutableStateOf("") }
var suggestions by remember { mutableStateOf<List<HexKey>>(emptyList()) }
LaunchedEffect(query) {
if (query.isBlank()) {
suggestions = emptyList()
return@LaunchedEffect
}
delay(150)
suggestions =
withContext(Dispatchers.IO) {
LocalCache.findUsersStartingWith(query.trim(), accountViewModel.account).map { it.pubkeyHex }.take(8)
}
}
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Agent (search a name, or paste npub / hex)") },
leadingIcon = { Icon(symbol = MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(20.dp)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = {
val hex = decodePublicKeyAsHexOrNull(query.trim())?.takeIf { it.isValid() }
if (hex != null) onSelect(hex) else onInvalidPaste()
},
),
modifier = Modifier.fillMaxWidth(),
)
// A plain Column (not LazyColumn) — this form lives inside a verticalScroll parent.
suggestions.forEach { hex ->
AgentSuggestionRow(hex, accountViewModel, nav) { onSelect(hex) }
}
}
/** One tappable agent search result — avatar + resolved name. */
@Composable
private fun AgentSuggestionRow(
hex: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
onClick: () -> Unit,
) {
val user: User = remember(hex) { LocalCache.getOrCreateUser(hex) }
val name by observeUserName(user, accountViewModel)
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
.padding(vertical = 8.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
UserPicture(hex, 34.dp, accountViewModel = accountViewModel, nav = nav)
Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyLarge)
}
}
/** The chosen agent as a removable chip — tapping the close affordance clears the selection. */
@Composable
private fun AgentChip(
hex: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
onRemove: () -> Unit,
) {
val user: User = remember(hex) { LocalCache.getOrCreateUser(hex) }
val name by observeUserName(user, accountViewModel)
InputChip(
selected = false,
onClick = onRemove,
label = { Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
avatar = { UserPicture(hex, 22.dp, accountViewModel = accountViewModel, nav = nav) },
trailingIcon = { Icon(symbol = MaterialSymbols.Close, contentDescription = "Change agent", modifier = Modifier.size(16.dp)) },
)
}
@Composable
private fun AttestationResultCard(
attestation: OwnerAttestation,
@@ -42,6 +42,28 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
// Well-known values for the persona's optional model / provider / runtime — suggestions only; any
// string is still accepted (the fields are free-form both in NIP-AP and in Buzz's persona events).
private val MODEL_OPTIONS =
listOf(
"claude-opus-4-8",
"claude-opus-4",
"claude-sonnet-4-5",
"claude-haiku-4-5",
"gpt-4o",
"gpt-4.1",
"o3",
"gemini-2.5-pro",
"gemini-2.5-flash",
"llama-3.3-70b",
).map { DropdownOption(it) }
private val PROVIDER_OPTIONS =
listOf("anthropic", "openai", "google", "groq", "openrouter", "ollama", "bedrock", "azure").map { DropdownOption(it) }
private val RUNTIME_OPTIONS =
listOf("goose", "claude-code", "custom").map { DropdownOption(it) }
/**
* Create or edit a Buzz Agent Persona (NIP-AP `kind:30175`). [slug] null → a new persona;
* otherwise edits the existing one (slug locked). Publishes to the workspace's Buzz relays
@@ -96,26 +118,25 @@ fun AgentPersonaEditScreen(
minLines = 3,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
// Model / provider / runtime are free-form strings upstream, but their real-world values
// are a small known set — offer them as suggestions while keeping free entry.
EditableSuggestDropdown(
value = state.model,
onValueChange = viewModel::onModelChange,
label = { Text("Model (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
label = "Model (optional)",
options = MODEL_OPTIONS,
)
OutlinedTextField(
EditableSuggestDropdown(
value = state.provider,
onValueChange = viewModel::onProviderChange,
label = { Text("Provider (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
label = "Provider (optional)",
options = PROVIDER_OPTIONS,
)
OutlinedTextField(
EditableSuggestDropdown(
value = state.runtime,
onValueChange = viewModel::onRuntimeChange,
label = { Text("Runtime (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
label = "Runtime (optional)",
options = RUNTIME_OPTIONS,
)
OutlinedTextField(
value = state.avatarUrl,
@@ -0,0 +1,94 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
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.Modifier
/** One suggested value for an [EditableSuggestDropdown] — its stored [value] and the [label] shown. */
data class DropdownOption(
val value: String,
val label: String = value,
) {
fun matches(query: String): Boolean = value.contains(query, ignoreCase = true) || label.contains(query, ignoreCase = true)
}
/**
* An editable text field that also offers a dropdown of known [options] — pick a suggestion or type
* any value (free entry is preserved). The menu narrows to options matching the current text. This
* is the "code → picker, but still open-ended" pattern for fields whose value set is well-known but
* not closed (model / provider / runtime names, common event kinds).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditableSuggestDropdown(
value: String,
onValueChange: (String) -> Unit,
label: String,
options: List<DropdownOption>,
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
supportingText: String? = null,
) {
var expanded by remember { mutableStateOf(false) }
val filtered = remember(value, options) { if (value.isBlank()) options else options.filter { it.matches(value) } }
val showMenu = expanded && filtered.isNotEmpty()
ExposedDropdownMenuBox(expanded = showMenu, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = value,
onValueChange = {
onValueChange(it)
expanded = true
},
label = { Text(label) },
singleLine = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showMenu) },
keyboardOptions = keyboardOptions,
supportingText = supportingText?.let { { Text(it) } },
modifier = modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
)
ExposedDropdownMenu(expanded = showMenu, onDismissRequest = { expanded = false }) {
filtered.forEach { opt ->
DropdownMenuItem(
text = { Text(opt.label) },
onClick = {
onValueChange(opt.value)
expanded = false
},
)
}
}
}
}