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 @@ -13,11 +13,16 @@ data class ConnectionProfile(
@SerialName("baseUrl") val baseUrl: String,
@SerialName("username") val username: String = "opencode",
@SerialName("password") val password: String? = null,
/** Bearer credential used by Nastech-Agent's API server. */
@SerialName("apiKey") val apiKey: String? = null,
@SerialName("dashboardUrl") val dashboardUrl: String? = null,
/** Connection protocol owned by the remote runtime. Defaults to OpenCode for old profiles. */
@SerialName("runtime") val runtime: String = "opencode",
@SerialName("allowInsecureLan") val allowInsecureLan: Boolean = false,
@SerialName("pinSha256") val pinSha256: String? = null,
) {
override fun toString(): String =
"ConnectionProfile(id=$id, name=$name, baseUrl=$baseUrl, username=$username, password=<redacted>, allowInsecureLan=$allowInsecureLan, pinSha256=<redacted>)"
"ConnectionProfile(id=$id, name=$name, baseUrl=$baseUrl, username=$username, password=<redacted>, apiKey=<redacted>, runtime=$runtime, allowInsecureLan=$allowInsecureLan, pinSha256=<redacted>)"
}

object ConnectionProfileCodec {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,15 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.nastechresearch.andcode.R
import com.nastechresearch.andcode.runtime.RuntimeRegistry
import com.nastechresearch.andcode.runtime.nastech.NastechRuntimeControl
import com.nastechresearch.andcode.ui.ViewModelFactory
import com.nastechresearch.andcode.ui.components.StatusChip

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ServerInfoScreen(
registry: RuntimeRegistry,
onOpenDashboard: (String) -> Unit = {},
onBack: () -> Unit,
) {
val viewModel: ServerInfoViewModel =
Expand All @@ -69,6 +71,7 @@ fun ServerInfoScreen(
factory = ViewModelFactory { ServerInfoViewModel(registry) },
)
val state by viewModel.state.collectAsState()
val selectedTarget by registry.selected.collectAsState()
var selectedTab by remember { mutableIntStateOf(0) }
val snackbarHostState = remember { SnackbarHostState() }
val configSavedMessage = stringResource(R.string.server_info_config_saved)
Expand All @@ -90,6 +93,11 @@ fun ServerInfoScreen(
}
},
actions = {
(selectedTarget as? NastechRuntimeControl)?.dashboardUrl?.let { url ->
TextButton(onClick = { onOpenDashboard(url) }) {
Text("Dashboard")
}
}
IconButton(onClick = viewModel::refresh) {
Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.refresh))
}
Expand Down Expand Up @@ -120,6 +128,13 @@ fun ServerInfoScreen(
onClick = { selectedTab = 3 },
text = { Text(stringResource(R.string.server_info_tab_skills)) },
)
if (state.toolsetsJson != null) {
Tab(
selected = selectedTab == 4,
onClick = { selectedTab = 4 },
text = { Text("Toolsets") },
)
}
}

if (state.isLoading) {
Expand All @@ -136,6 +151,7 @@ fun ServerInfoScreen(
1 -> ProvidersTab(state)
2 -> CommandsTab(state)
3 -> SkillsTab(state)
4 -> ToolsetsTab(state)
}
}
}
Expand Down Expand Up @@ -370,3 +386,18 @@ private fun SkillsTab(state: ServerInfoUiState) {
}
}
}

@Composable
private fun ToolsetsTab(state: ServerInfoUiState) {
Surface(
modifier = Modifier.fillMaxSize().padding(16.dp),
shape = MaterialTheme.shapes.medium,
tonalElevation = 1.dp,
) {
Text(
text = state.toolsetsJson ?: "{}",
modifier = Modifier.padding(12.dp).horizontalScroll(rememberScrollState()),
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, fontSize = 11.sp),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.nastechresearch.andcode.core.api.ConfiguredProvider
import com.nastechresearch.andcode.core.api.OpenCodeCommand
import com.nastechresearch.andcode.core.api.OpenCodeSkill
import com.nastechresearch.andcode.runtime.RuntimeRegistry
import com.nastechresearch.andcode.runtime.nastech.NastechRuntimeControl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
Expand All @@ -19,13 +20,21 @@ data class ServerInfoUiState(
val configProviders: List<ConfiguredProvider> = emptyList(),
val commands: List<OpenCodeCommand> = emptyList(),
val skills: List<OpenCodeSkill> = emptyList(),
val toolsetsJson: String? = null,
val isLoading: Boolean = false,
val error: String? = null,
val configEditDraft: String? = null,
val isSaving: Boolean = false,
val saveSuccess: Boolean = false,
)

private data class ServerInfoPayload(
val providers: List<ConfiguredProvider>,
val commands: List<OpenCodeCommand>,
val skills: List<OpenCodeSkill>,
val toolsets: String?,
)

class ServerInfoViewModel(
private val registry: RuntimeRegistry,
) : ViewModel() {
Expand All @@ -50,15 +59,17 @@ class ServerInfoViewModel(
val providers = runCatching { backend.configProviders() }.getOrDefault(emptyList())
val commands = runCatching { backend.commands() }.getOrDefault(emptyList())
val skills = runCatching { backend.skills() }.getOrDefault(emptyList())
config to Triple(providers, commands, skills)
val toolsets = (backend as? NastechRuntimeControl)?.let { runCatching { it.toolsets() }.getOrNull() }
config to ServerInfoPayload(providers, commands, skills, toolsets?.toString())
}.onSuccess { (config, data) ->
val (providers, commands, skills) = data
val (providers, commands, skills, toolsets) = data
_state.update {
it.copy(
configJson = config.toString(),
configProviders = providers,
commands = commands,
skills = skills,
toolsetsJson = toolsets,
isLoading = false,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@ internal fun ConnectionDialog(
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedButton(
onClick = {
form =
form.copy(
runtime = if (form.runtime == "nastech") "opencode" else "nastech",
testSucceeded = false,
testMessage = null,
)
},
modifier = Modifier.fillMaxWidth(),
) {
Text(if (form.runtime == "nastech") "Runtime: Nastech-Agent" else "Runtime: OpenCode")
}
OutlinedTextField(
value = form.baseUrl,
onValueChange = { form = form.copy(baseUrl = it, testSucceeded = false, testMessage = null) },
Expand All @@ -91,22 +104,44 @@ internal fun ConnectionDialog(
null
},
)
OutlinedTextField(
value = form.username,
onValueChange = { form = form.copy(username = it) },
label = { Text(stringResource(R.string.username)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = form.password,
onValueChange = { form = form.copy(password = it, testSucceeded = false, testMessage = null) },
label = { Text(stringResource(R.string.password)) },
leadingIcon = { Icon(Icons.Default.Key, contentDescription = stringResource(R.string.cd_password)) },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
if (form.runtime == "nastech") {
OutlinedTextField(
value = form.dashboardUrl,
onValueChange = { form = form.copy(dashboardUrl = it, testSucceeded = false, testMessage = null) },
label = { Text("Nastech dashboard URL (optional)") },
leadingIcon = { Icon(Icons.Default.Link, contentDescription = "Nastech dashboard URL") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
if (form.runtime == "nastech") {
OutlinedTextField(
value = form.apiKey,
onValueChange = { form = form.copy(apiKey = it, testSucceeded = false, testMessage = null) },
label = { Text("Nastech API key") },
leadingIcon = { Icon(Icons.Default.Key, contentDescription = "Nastech API key") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
} else {
OutlinedTextField(
value = form.username,
onValueChange = { form = form.copy(username = it) },
label = { Text(stringResource(R.string.username)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = form.password,
onValueChange = { form = form.copy(password = it, testSucceeded = false, testMessage = null) },
label = { Text(stringResource(R.string.password)) },
leadingIcon = { Icon(Icons.Default.Key, contentDescription = stringResource(R.string.cd_password)) },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
// No cleartext opt-in here: OpenCodeUrl.normalize already limits plain HTTP to
// loopback, RFC1918, link-local, Tailscale CGNAT and .local hosts, and anything
// beyond that has to be https. A checkbox would only add a step in front of the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ data class ConnectionFormState(
val id: String = UUID.randomUUID().toString(),
val name: String = "",
val baseUrl: String = "",
val runtime: String = "opencode",
val username: String = "opencode",
val password: String = "",
val apiKey: String = "",
val dashboardUrl: String = "",
val allowInsecureLan: Boolean = false,
val isTesting: Boolean = false,
val testMessage: String? = null,
Expand All @@ -35,8 +38,11 @@ data class ConnectionFormState(
id = id,
name = name.trim(),
baseUrl = url.toString(),
runtime = runtime,
username = username.trim().ifBlank { "opencode" },
password = password.takeIf { it.isNotBlank() },
apiKey = apiKey.takeIf { it.isNotBlank() },
dashboardUrl = dashboardUrl.takeIf { it.isNotBlank() },
// `opencode serve` on a PC is plain HTTP on the LAN. normalize() has already limited
// cleartext to private address space, so record the allowance here instead of asking
// the user to tick a box before the connection can be saved at all.
Expand All @@ -50,8 +56,11 @@ data class ConnectionFormState(
id = profile.id,
name = profile.name,
baseUrl = profile.baseUrl,
runtime = profile.runtime,
username = profile.username,
password = profile.password.orEmpty(),
apiKey = profile.apiKey.orEmpty(),
dashboardUrl = profile.dashboardUrl.orEmpty(),
allowInsecureLan = profile.allowInsecureLan,
testSucceeded = true,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ fun RemoteConnectionScreen(
testSucceeded = health.healthy,
testMessage =
if (health.healthy) {
"OpenCode ${health.version}"
"${if (form.runtime == "nastech") "Nastech-Agent" else "OpenCode"} ${health.version}"
} else {
context.getString(R.string.remote_connection_unhealthy)
},
Expand Down Expand Up @@ -276,6 +276,19 @@ fun RemoteConnectionScreen(
singleLine = true,
shape = RoundedCornerShape(14.dp),
)
OutlinedButton(
onClick = {
form =
form.copy(
runtime = if (form.runtime == "nastech") "opencode" else "nastech",
testSucceeded = false,
testMessage = null,
)
},
modifier = Modifier.fillMaxWidth(),
) {
Text(if (form.runtime == "nastech") "Runtime: Nastech-Agent" else "Runtime: OpenCode")
}
val urlInvalid = form.baseUrl.isNotBlank() && form.normalizedUrl == null
OutlinedTextField(
value = form.baseUrl,
Expand All @@ -297,41 +310,69 @@ fun RemoteConnectionScreen(
},
shape = RoundedCornerShape(14.dp),
)
OutlinedTextField(
value = form.username,
onValueChange = {
form = form.copy(username = it, testSucceeded = false, testMessage = null)
},
label = { Text(stringResource(R.string.username)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(14.dp),
)
OutlinedTextField(
value = form.password,
onValueChange = {
form = form.copy(password = it, testSucceeded = false, testMessage = null)
},
label = { Text(stringResource(R.string.password)) },
leadingIcon = { Icon(Icons.Default.Key, contentDescription = stringResource(R.string.cd_password)) },
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = stringResource(R.string.cd_toggle_password),
)
}
},
visualTransformation =
if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
if (form.runtime == "nastech") {
OutlinedTextField(
value = form.dashboardUrl,
onValueChange = {
form = form.copy(dashboardUrl = it, testSucceeded = false, testMessage = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(14.dp),
)
label = { Text("Nastech dashboard URL (optional)") },
leadingIcon = { Icon(Icons.Default.Link, contentDescription = "Nastech dashboard URL") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(14.dp),
)
}
if (form.runtime == "nastech") {
OutlinedTextField(
value = form.apiKey,
onValueChange = {
form = form.copy(apiKey = it, testSucceeded = false, testMessage = null)
},
label = { Text("Nastech API key") },
leadingIcon = { Icon(Icons.Default.Key, contentDescription = "Nastech API key") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(14.dp),
)
} else {
OutlinedTextField(
value = form.username,
onValueChange = {
form = form.copy(username = it, testSucceeded = false, testMessage = null)
},
label = { Text(stringResource(R.string.username)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(14.dp),
)
OutlinedTextField(
value = form.password,
onValueChange = {
form = form.copy(password = it, testSucceeded = false, testMessage = null)
},
label = { Text(stringResource(R.string.password)) },
leadingIcon = { Icon(Icons.Default.Key, contentDescription = stringResource(R.string.cd_password)) },
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = stringResource(R.string.cd_toggle_password),
)
}
},
visualTransformation =
if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(14.dp),
)
}
}

Row(
Expand Down
Loading
Loading