diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt index 3a708ace4e..8ec3308ca0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt @@ -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(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>(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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentPersonaEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentPersonaEditScreen.kt index bbf3c87faf..e274b32cb8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentPersonaEditScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentPersonaEditScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzOptionDropdown.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzOptionDropdown.kt new file mode 100644 index 0000000000..4d39c69d4e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzOptionDropdown.kt @@ -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, + 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 + }, + ) + } + } + } +}