()
+ var openSettings: (() -> Unit)? = null
+ when (tag) {
+ is CapabilityTag.Backend -> {
+ lines += context.getString(R.string.capability_detail_kind_backend)
+ tag.name?.let { lines += context.getString(R.string.capability_detail_source, it) }
+ if (tag.state == BackendState.READY || tag.state == BackendState.NOT_CONFIGURED) {
+ lines += tag.modelName?.let { context.getString(R.string.capability_detail_model, it) }
+ ?: context.getString(R.string.capability_detail_model_unknown)
+ }
+ lines += status
+ backendReason(tag.state)?.let { reason ->
+ lines += context.getString(R.string.capability_detail_reason, context.getString(reason))
+ }
+ tag.statusMessage?.let { lines += context.getString(R.string.capability_detail_reason, it) }
+ if (tag.status != CapabilityStatus.AVAILABLE) openSettings = onOpenSettings
+ }
+ is CapabilityTag.Web -> {
+ lines += context.getString(R.string.capability_detail_kind_web)
+ lines += status
+ lines += context.getString(
+ if (tag.online) R.string.capability_web_online_detail else R.string.capability_web_offline_detail
+ )
+ }
+ is CapabilityTag.Tools -> {
+ lines += context.getString(R.string.capability_detail_source, tag.sourceName)
+ lines += context.resources.getQuantityString(
+ R.plurals.capability_detail_tools, tag.toolCount, tag.toolCount
+ )
+ lines += status
+ if (tag.status != CapabilityStatus.AVAILABLE) {
+ tag.statusMessage?.let { lines += context.getString(R.string.capability_detail_reason, it) }
+ }
+ ToolSourceScreens.settingsFor(tag.providerId)?.let { screen ->
+ openSettings = { onOpenPluginScreen(screen) }
+ }
+ }
+ }
+
+ val builder = MaterialAlertDialogBuilder(context)
+ .setTitle(label(binding.capabilityTagGroup.context, tag))
+ .setMessage(lines.joinToString("\n"))
+ .setPositiveButton(android.R.string.ok, null)
+ openSettings?.let { open ->
+ builder.setNeutralButton(R.string.capability_detail_open_settings) { _, _ -> open() }
+ }
+ dialog?.dismiss()
+ dialog = builder.show()
+ }
+
+ private fun backendReason(state: BackendState): Int? = when (state) {
+ BackendState.READY -> null
+ BackendState.NOT_CONFIGURED -> R.string.capability_backend_not_configured
+ BackendState.NOT_INSTALLED -> R.string.capability_backend_not_installed
+ BackendState.NONE_INSTALLED -> R.string.capability_backend_none_installed
+ }
+
+ private fun statusLabel(status: CapabilityStatus): Int = when (status) {
+ CapabilityStatus.AVAILABLE -> R.string.capability_status_available
+ CapabilityStatus.CONNECTING -> R.string.capability_status_connecting
+ CapabilityStatus.DEGRADED -> R.string.capability_status_degraded
+ }
+
+ private fun tooltipTagFor(tag: CapabilityTag): String = when (tag) {
+ is CapabilityTag.Backend -> AiCorePlugin.TOOLTIP_TAG_CAPABILITY_BACKEND
+ is CapabilityTag.Web -> AiCorePlugin.TOOLTIP_TAG_CAPABILITY_WEB
+ is CapabilityTag.Tools -> AiCorePlugin.TOOLTIP_TAG_CAPABILITY_TOOLS
+ }
+
+ /** Colour resources for one status; degraded is the one that has to stand out. */
+ private class TagColors(val background: Int, val stroke: Int, val text: Int, val dot: Int)
+
+ private fun colorsFor(status: CapabilityStatus): TagColors = when (status) {
+ CapabilityStatus.AVAILABLE -> TagColors(
+ background = R.color.plugin_surface_variant,
+ stroke = R.color.plugin_outline_variant,
+ text = R.color.plugin_on_surface_variant,
+ dot = R.color.plugin_success,
+ )
+ CapabilityStatus.CONNECTING -> TagColors(
+ background = R.color.plugin_surface_variant,
+ stroke = R.color.plugin_outline_variant,
+ text = R.color.plugin_text_muted,
+ dot = R.color.plugin_outline,
+ )
+ CapabilityStatus.DEGRADED -> TagColors(
+ background = R.color.plugin_error_container,
+ stroke = R.color.plugin_error,
+ text = R.color.plugin_on_error_container,
+ dot = R.color.plugin_error,
+ )
+ }
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt
index a85ac546..4abea704 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt
@@ -30,6 +30,8 @@ import com.itsaky.androidide.plugins.PluginContext
import com.itsaky.androidide.plugins.aicore.BuildConfig
import com.itsaky.androidide.plugins.aicore.R
import com.itsaky.androidide.plugins.aicore.adapters.ChatAdapter
+import com.itsaky.androidide.plugins.aicore.capabilities.CapabilityMonitor
+import com.itsaky.androidide.plugins.aicore.capabilities.PluginScreen
import com.itsaky.androidide.plugins.aicore.databinding.FragmentChatBinding
import com.itsaky.androidide.plugins.aicore.logging.AgentTrace
import com.itsaky.androidide.plugins.aicore.logging.LOG_PREFIX
@@ -96,6 +98,7 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
private var composer: ComposerAutoHideController? = null
private var sidebar: ChatSidebarController? = null
+ private var capabilityTags: CapabilityTagRowController? = null
/**
* Closes the sidebar on Back rather than letting the press reach the host, which would shut the
@@ -232,6 +235,8 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
composer = null
sidebar?.detach()
sidebar = null
+ capabilityTags?.detach()
+ capabilityTags = null
_binding = null
}
@@ -303,7 +308,7 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
setupCutoutPadding()
setupComposer(savedInstanceState)
setupStatusBar()
- setupBackendIndicator()
+ setupCapabilityTags()
observeViewModel()
AgentTrace.stage(
@@ -400,9 +405,6 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
syncStorageToCurrentProject()
// On becoming visible, so the check runs after every plugin has loaded.
viewModel.checkBackendAvailability()
- // Re-resolve the selected backend here: the settings screen is a separate activity that
- // fully covers chat, so returning from it always delivers onResume.
- viewModel.refreshBackendLabel()
}
/**
@@ -620,7 +622,6 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
wireTooltip(binding.btnAddContext, AiCorePlugin.TOOLTIP_TAG_CONTEXT_FILES)
wireTooltip(binding.inputBarCard, AiCorePlugin.TOOLTIP_TAG_CHAT_INPUT)
wireTooltip(binding.sendButton, AiCorePlugin.TOOLTIP_TAG_CHAT_SEND)
- wireTooltip(binding.backendStatusText, AiCorePlugin.TOOLTIP_TAG_SETTINGS_BACKEND)
wireTooltip(binding.btnCancelEdit, AiCorePlugin.TOOLTIP_TAG_MESSAGE_EDIT)
}
@@ -776,14 +777,21 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
binding.agentStatusContainer.isVisible = false
}
- private fun setupBackendIndicator() {
- viewLifecycleOwner.lifecycleScope.launch {
- viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
- viewModel.activeBackendLabel.collect { label ->
- _binding?.backendStatusText?.text = label
- }
- }
- }
+ /**
+ * Stands up the tag row under the input, which took the backend caption's place: the backend
+ * tag names what the caption did, and updates live instead of on resume.
+ */
+ private fun setupCapabilityTags() {
+ val controller = CapabilityTagRowController(
+ binding = binding,
+ monitor = CapabilityMonitor(requireContext()),
+ showTooltip = ::showTooltip,
+ dialogContext = { if (isAdded) themedDialogContext() else null },
+ onOpenSettings = ::openSettingsFragment,
+ onOpenPluginScreen = ::openPluginSettings,
+ )
+ controller.attach(viewLifecycleOwner)
+ capabilityTags = controller
}
private fun observeViewModel() {
@@ -1063,6 +1071,23 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
}
}
+ /**
+ * Opens a settings screen another plugin owns, such as MCP's servers list. The host loads the
+ * Fragment with that plugin's class loader, so its own resources and state apply.
+ * @param screen the screen to open.
+ */
+ private fun openPluginSettings(screen: PluginScreen) {
+ val title = getString(screen.titleRes)
+ val opened = PluginFragmentHelper.getServiceRegistry(AiCorePlugin.PLUGIN_ID)
+ ?.get(IdeUIService::class.java)
+ ?.openPluginScreen(screen.pluginId, screen.fragmentClassName, title) ?: false
+ if (!opened) {
+ AiCorePlugin.getContext()?.logger
+ ?.warn("ChatFragment: could not open ${screen.fragmentClassName} of ${screen.pluginId}")
+ showInfoSnackbar(getString(R.string.msg_plugin_settings_unavailable, title))
+ }
+ }
+
/**
* Surfaces an [AgentState.Error] as a Snackbar with a shortcut into settings. Snackbar, never
* Toast: a Toast built from the plugin's Context crashes the IDE with a SecurityException,
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt
index e62cf109..ca836293 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt
@@ -5,6 +5,7 @@ import com.itsaky.androidide.plugins.IPlugin
import com.itsaky.androidide.plugins.PluginContext
import com.itsaky.androidide.plugins.ai.prompt.AssetPromptConfigSource
import com.itsaky.androidide.plugins.aicore.R
+import com.itsaky.androidide.plugins.aicore.capabilities.CapabilityMonitor
import com.itsaky.androidide.plugins.aicore.fragments.AiSettingsFragment
import com.itsaky.androidide.plugins.aicore.fragments.ChatFragment
import com.itsaky.androidide.plugins.aicore.prompt.PromptConfigChecks
@@ -88,6 +89,12 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten
/** The "< 2/3 >" version switcher on a user message whose older prompt was edited. */
const val TOOLTIP_TAG_MESSAGE_VERSIONS = "agent_message_versions"
+ // Tags for the capability row under the chat input (see CapabilityTagRowController).
+ const val TOOLTIP_TAG_CAPABILITY_BACKEND = "agent_capability_backend"
+ const val TOOLTIP_TAG_CAPABILITY_WEB = "agent_capability_web"
+ /** Every tool-source tag shares this one: MCP servers and plugins alike. */
+ const val TOOLTIP_TAG_CAPABILITY_TOOLS = "agent_capability_tools"
+
// Tags for the interactive controls on the AI Settings screen (see AiSettingsFragment).
const val TOOLTIP_TAG_SETTINGS_BACK = "ai_settings_back"
const val TOOLTIP_TAG_SETTINGS_BACKEND = "ai_settings_backend"
@@ -125,6 +132,7 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten
registerToolSourceRegistry()
preloadPromptConfig()
+ CapabilityMonitor.onServicesPublished()
PathGuard.setProjectRootProvider {
try {
@@ -741,6 +749,52 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten
PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0)
)
),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_CAPABILITY_BACKEND,
+ summary = "The backend and model that will answer your next message.",
+ detail = """
+ Names the AI backend selected under Preferences →
+ Configuration → Agent, and the model it will use when the
+ backend reports one.
+ It turns red when that backend is not ready — no API key, no
+ model chosen, or its plugin uninstalled — so you can fix it before
+ sending anything. Tap it for the details and a shortcut to the
+ settings.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_CAPABILITY_WEB,
+ summary = "Whether the device is online, which network backends and MCP servers need.",
+ detail = """
+ Shows Web while the device has a working internet
+ connection, and Offline, in red, while it does not.
+ A backend that runs on the device keeps working offline. A
+ network backend and every MCP server cannot be reached until the
+ connection comes back.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_CAPABILITY_TOOLS,
+ summary = "Extra tools the agent can use, from an MCP server or another plugin.",
+ detail = """
+ Each MCP server you configured, and each plugin that adds tools
+ to the agent, gets its own tag. Tap one to see where it comes from
+ and how many tools it offers.
+ A red tag is set up but not working right now — a server that
+ cannot be reached, say — and the details say why. It clears by
+ itself once the server answers again. A tag disappears when you
+ switch the server or plugin off.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0)
+ )
+ ),
PluginTooltipEntry(
tag = TOOLTIP_TAG_SETTINGS_BACK,
summary = "Close the Agent settings and go back where you came from.",
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/services/LlmInferenceServiceImpl.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/services/LlmInferenceServiceImpl.kt
index 32f816fe..c17b9c31 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/services/LlmInferenceServiceImpl.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/services/LlmInferenceServiceImpl.kt
@@ -7,6 +7,7 @@ import com.itsaky.androidide.plugins.services.LlmInferenceService
import com.itsaky.androidide.plugins.services.LlmInferenceService.*
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.CopyOnWriteArraySet
/**
* Implementation of LlmInferenceService.
@@ -24,12 +25,53 @@ class LlmInferenceServiceImpl(private val logger: PluginLogger? = null) : LlmInf
private val backends = ConcurrentHashMap()
@Volatile private var currentGeneration: CompletableFuture? = null
+ /**
+ * Told of every register, unregister, selection and [notifyBackendChanged]. A set, so adding a
+ * listener twice is the no-op the contract promises.
+ */
+ private val backendListeners = CopyOnWriteArraySet()
+
override fun registerBackend(backend: LlmBackend) {
- backends[backend.getId()] = backend
+ val id = backend.getId()
+ backends[id] = backend
+ fireBackendChanged(id)
}
override fun unregisterBackend(backendId: String) {
- backends.remove(backendId)
+ if (backends.remove(backendId) != null) fireBackendChanged(backendId)
+ }
+
+ override fun addBackendChangeListener(listener: BackendChangeListener) {
+ backendListeners.add(listener)
+ }
+
+ override fun removeBackendChangeListener(listener: BackendChangeListener) {
+ backendListeners.remove(listener)
+ }
+
+ /**
+ * Relays a backend's own change — a key entered, a model loaded or picked — to the listeners.
+ * Also how [BackendRegistry.select] reports a new selection, which is a change to the same id.
+ *
+ * @param backendId the backend that changed; one that is not registered is ignored
+ */
+ override fun notifyBackendChanged(backendId: String) {
+ if (backends.containsKey(backendId)) fireBackendChanged(backendId)
+ }
+
+ /**
+ * Tells every listener, on the caller's thread and outside any lock, as the contract requires.
+ * One that throws is logged and skipped: it is another plugin's code, and must not stop the
+ * rest from hearing, nor surface in the backend plugin that reported the change.
+ */
+ private fun fireBackendChanged(backendId: String) {
+ for (listener in backendListeners) {
+ try {
+ listener.onBackendChanged(backendId)
+ } catch (e: Throwable) {
+ logger?.error("A backend change listener threw for '$backendId'", e)
+ }
+ }
}
/**
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/services/ToolSourceRegistryImpl.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/services/ToolSourceRegistryImpl.kt
index ec9abc31..2a44562f 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/services/ToolSourceRegistryImpl.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/services/ToolSourceRegistryImpl.kt
@@ -10,6 +10,7 @@ import com.itsaky.androidide.plugins.aicore.tool.sources.ToolSourceStore
import com.itsaky.androidide.plugins.services.ToolSourceRegistry
import java.io.File
import java.util.concurrent.CompletableFuture
+import java.util.concurrent.CopyOnWriteArraySet
private const val TAG = "$LOG_PREFIX.ToolSourceRegistry"
@@ -32,6 +33,9 @@ class ToolSourceRegistryImpl(
/** The host-side sources, kept in registration order so [getToolSources] can return them. */
private val hostSources = LinkedHashMap()
+ /** Consumers of what [getToolSources] describes — the chat's tag row, say. */
+ private val listeners = CopyOnWriteArraySet()
+
override fun registerToolSource(source: ToolSourceRegistry.ToolSource) {
val providerId = try {
source.providerId.orEmpty().trim()
@@ -53,6 +57,7 @@ class ToolSourceRegistryImpl(
synchronized(lock) { hostSources[providerId] = source }
store.register(HostToolSource(source, providerId, displayName))
+ fireChanged(providerId)
}
override fun unregisterToolSource(source: ToolSourceRegistry.ToolSource) {
@@ -66,13 +71,52 @@ class ToolSourceRegistryImpl(
return
}
store.unregister(id)
+ fireChanged(id)
}
override fun getToolSources(): List =
synchronized(lock) { hostSources.values.toList() }
override fun notifyToolsChanged(providerId: String) {
- store.toolsChanged(providerId.trim())
+ val id = providerId.trim()
+ store.toolsChanged(id)
+ if (isRegistered(id)) fireChanged(id)
+ }
+
+ /**
+ * Relays a provider's health change to the listeners only: the tool list is unchanged, so the
+ * agent's router, executor and grammar are not rebuilt for it.
+ */
+ override fun notifyToolSourceStatusChanged(providerId: String) {
+ val id = providerId.trim()
+ if (isRegistered(id)) fire(id) { it.onToolSourceStatusChanged(id) }
+ }
+
+ override fun addToolSourceListener(listener: ToolSourceRegistry.ToolSourceListener) {
+ listeners.add(listener)
+ }
+
+ override fun removeToolSourceListener(listener: ToolSourceRegistry.ToolSourceListener) {
+ listeners.remove(listener)
+ }
+
+ private fun isRegistered(providerId: String): Boolean =
+ synchronized(lock) { hostSources.containsKey(providerId) }
+
+ private fun fireChanged(providerId: String) = fire(providerId) { it.onToolSourcesChanged(providerId) }
+
+ /**
+ * Tells every listener, on the caller's thread and outside [lock], as the contract requires.
+ * One that throws is logged and skipped rather than reaching the provider that reported.
+ */
+ private inline fun fire(providerId: String, call: (ToolSourceRegistry.ToolSourceListener) -> Unit) {
+ for (listener in listeners) {
+ try {
+ call(listener)
+ } catch (e: Throwable) {
+ Log.e(TAG, "A tool-source listener threw for '$providerId'", e)
+ }
+ }
}
/**
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt
index 5818478e..6c6136a9 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt
@@ -212,30 +212,6 @@ class ChatViewModel(
private val currentBackendId: String
get() = _backendStatus.value.id
- /**
- * Label for the backend the user *selected* in settings, shown under the chat input. Tracks the
- * selection, not the availability-resolved backend: picking Gemini must read "Gemini API" before
- * its key check runs, or it would always show "Local LLM".
- */
- private val _activeBackendLabel = MutableStateFlow(selectedBackendLabel())
- val activeBackendLabel: StateFlow = _activeBackendLabel.asStateFlow()
-
- private fun selectedBackendLabel(): String =
- // Resolved exactly as the settings screen and the availability check resolve it, so the
- // three cannot name different backends on the same launch.
- when (val selected = BackendRegistry.selected()) {
- is SelectedBackend.Installed -> selected.option.displayName
- // A stored selection resolving to nothing means its plugin is gone. Saying "no backend"
- // there would read as "install one" when one is installed — just not the chosen one.
- SelectedBackend.Missing -> str(R.string.backend_selected_missing_short)
- SelectedBackend.None -> str(R.string.backend_none_installed_short)
- }
-
- /** Re-read the selected backend and update [activeBackendLabel]; call when returning to chat. */
- fun refreshBackendLabel() {
- _activeBackendLabel.value = selectedBackendLabel()
- }
-
// Tool execution infrastructure
private val approvalManager = ToolApprovalManager(sharedPromptConfig) { handler ->
ToolDescriptions.describe(sharedPromptConfig.config(), RESPOND_TOOL, handler)
diff --git a/plugins/AI-Core/src/main/res/drawable/ic_status_dot.xml b/plugins/AI-Core/src/main/res/drawable/ic_status_dot.xml
new file mode 100644
index 00000000..f92c3bb8
--- /dev/null
+++ b/plugins/AI-Core/src/main/res/drawable/ic_status_dot.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/plugins/AI-Core/src/main/res/layout/fragment_chat.xml b/plugins/AI-Core/src/main/res/layout/fragment_chat.xml
index 237cf884..cf6df999 100644
--- a/plugins/AI-Core/src/main/res/layout/fragment_chat.xml
+++ b/plugins/AI-Core/src/main/res/layout/fragment_chat.xml
@@ -260,14 +260,25 @@
android:tint="@color/chat_send_tint" />
-
+
+ android:contentDescription="@string/desc_capability_row"
+ android:scrollbars="none">
+
+
+
diff --git a/plugins/AI-Core/src/main/res/values/dimens.xml b/plugins/AI-Core/src/main/res/values/dimens.xml
index 3f9c384f..586cc24e 100644
--- a/plugins/AI-Core/src/main/res/values/dimens.xml
+++ b/plugins/AI-Core/src/main/res/values/dimens.xml
@@ -27,6 +27,13 @@
22dp
12sp
4dp
+
+ 28dp
+ 6dp
+ 12sp
+ 1dp
+ 8dp
+ 10dp
8
diff --git a/plugins/AI-Core/src/main/res/values/strings.xml b/plugins/AI-Core/src/main/res/values/strings.xml
index 9693639b..9f52bded 100644
--- a/plugins/AI-Core/src/main/res/values/strings.xml
+++ b/plugins/AI-Core/src/main/res/values/strings.xml
@@ -179,6 +179,9 @@
Agent
AI backend, model and API key
Couldn\'t open Agent settings. Open them from Preferences → Configuration → Agent.
+
+ MCP servers
+ Couldn\'t open %1$s. Open it from Preferences → Configuration → %1$s.
AI Settings
@@ -244,4 +247,31 @@
%1$s has no settings to configure.
This backend\'s settings are unavailable. It may have been uninstalled or disabled.
Error: %s
+
+
+ %1$s \u00b7 %2$s
+ Web
+ Offline
+ Working
+ Connecting
+ Not working
+ Not set up yet. Open the Agent settings to finish configuring it.
+ No AI backend is installed. Install one from the Plugin Manager.
+ The backend you selected is no longer installed. Choose another in the Agent settings.
+ The device is online, so network backends and MCP servers can be reached.
+ The device is offline. Network backends and MCP servers cannot be reached until it reconnects.
+ Backend
+ Web access
+ Source: %1$s
+ Model: %1$s
+ Model: not reported
+ Status: %1$s
+ Reason: %1$s
+ Settings
+
+ - %d tool
+ - %d tools
+
+ %1$s, %2$s. Tap for details.
+ What the agent is connected to
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/CapabilityReadersTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/CapabilityReadersTest.kt
new file mode 100644
index 00000000..1d038cdd
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/CapabilityReadersTest.kt
@@ -0,0 +1,255 @@
+package com.itsaky.androidide.plugins.aicore.capabilities
+
+import com.itsaky.androidide.plugins.aicore.backends.BackendOption
+import com.itsaky.androidide.plugins.aicore.backends.SelectedBackend
+import com.itsaky.androidide.plugins.services.CapabilityStatus
+import com.itsaky.androidide.plugins.services.LlmInferenceService
+import com.itsaky.androidide.plugins.services.ToolSourceRegistry
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Tests how the host contracts become capability tags: one tag per MCP server rather than one
+ * for MCP, broken sources kept and marked, and a misbehaving provider costing only its own tag.
+ */
+class CapabilityReadersTest {
+
+ private companion object {
+ const val MCP = "com.itsaky.androidide.plugins.aiagentmcp"
+ const val OTHER = "com.example.searchplugin"
+ }
+
+ @Test
+ fun givenASourceWithGroups_whenRead_thenEachGroupIsItsOwnTag() {
+ val source = source(
+ MCP,
+ groups = listOf(
+ group("a", "GitHub", tools = 3, status = CapabilityStatus.AVAILABLE),
+ group("b", "Docs", tools = 0, status = CapabilityStatus.DEGRADED, message = "Docs is unreachable."),
+ ),
+ )
+
+ val tags = CapabilityReaders.tools(listOf(source))
+
+ assertEquals(listOf("GitHub", "Docs"), tags.map { it.name })
+ assertEquals(listOf(3, 0), tags.map { it.toolCount })
+ assertEquals(listOf(CapabilityStatus.AVAILABLE, CapabilityStatus.DEGRADED), tags.map { it.status })
+ assertEquals("Docs is unreachable.", tags[1].statusMessage)
+ assertTrue(tags.all { it.sourceName == "MCP servers" })
+ }
+
+ @Test
+ fun givenASourceWithoutGroups_whenRead_thenItIsOneTagNamedForTheSource() {
+ val source = source(OTHER, name = "Search", tools = 2)
+
+ val tag = CapabilityReaders.tools(listOf(source)).single()
+
+ assertEquals("Search", tag.name)
+ assertEquals(2, tag.toolCount)
+ assertNull(tag.groupId)
+ }
+
+ @Test
+ fun givenAGrouplessSourceOfferingNothing_whenRead_thenItHasNoTag() {
+ val source = source(MCP, tools = 0)
+
+ assertEquals(emptyList(), CapabilityReaders.tools(listOf(source)))
+ }
+
+ @Test
+ fun givenABrokenSourceOfferingNothing_whenRead_thenItKeepsADegradedTag() {
+ val source = source(OTHER, tools = 0, status = CapabilityStatus.DEGRADED)
+
+ assertEquals(CapabilityStatus.DEGRADED, CapabilityReaders.tools(listOf(source)).single().status)
+ }
+
+ @Test
+ fun givenASourceThatThrowsFromItsGroups_whenRead_thenTheOthersSurvive() {
+ val broken = source(OTHER, tools = 1).also { every { it.toolGroups } throws IllegalStateException("boom") }
+ val healthy = source(MCP, groups = listOf(group("a", "GitHub", tools = 1)))
+
+ val tags = CapabilityReaders.tools(listOf(broken, healthy))
+
+ assertEquals(listOf("Search", "GitHub"), tags.map { it.name })
+ }
+
+ @Test
+ fun givenANullStatus_whenMapped_thenItReadsAsDegraded() {
+ assertEquals(CapabilityStatus.DEGRADED, CapabilityReaders.status(null))
+ assertEquals(CapabilityStatus.CONNECTING, CapabilityReaders.status(CapabilityStatus.CONNECTING))
+ }
+
+ @Test
+ fun givenAReadyBackendReportingAModel_whenRead_thenTheTagCarriesIt() {
+ val service = service(backend("gemini", available = true, model = "gemini-2.5-flash"))
+
+ val tag = CapabilityReaders.backend(installed("gemini", "Gemini API"), service)
+
+ assertEquals("Gemini API", tag.name)
+ assertEquals("gemini-2.5-flash", tag.modelName)
+ assertEquals(CapabilityStatus.AVAILABLE, tag.status)
+ }
+
+ @Test
+ fun givenABackendThatIsNotConfigured_whenRead_thenItIsDegraded() {
+ val service = service(backend("openai", available = false, model = null))
+
+ val tag = CapabilityReaders.backend(installed("openai", "OpenAI"), service)
+
+ assertEquals(BackendState.NOT_CONFIGURED, tag.state)
+ assertEquals(CapabilityStatus.DEGRADED, tag.status)
+ assertNull(tag.modelName)
+ }
+
+ @Test
+ fun givenABackendThatThrowsFromItsModelName_whenRead_thenOnlyTheModelIsMissing() {
+ val backend = backend("local", available = true, model = null).also {
+ every { it.activeModelName } throws IllegalStateException("boom")
+ }
+
+ val tag = CapabilityReaders.backend(installed("local", "Local LLM"), service(backend))
+
+ assertEquals(BackendState.READY, tag.state)
+ assertNull(tag.modelName)
+ }
+
+ @Test
+ fun givenAReadyBackendWhoseServerIsDown_whenRead_thenItIsDegradedWithItsReason() {
+ val backend = backend(
+ "openai", available = true, model = "qwen2.5-coder",
+ status = CapabilityStatus.DEGRADED, message = "Nothing answered.",
+ )
+
+ val tag = CapabilityReaders.backend(installed("openai", "OpenAI"), service(backend))
+
+ assertEquals(BackendState.READY, tag.state)
+ assertEquals(CapabilityStatus.DEGRADED, tag.status)
+ assertEquals("Nothing answered.", tag.statusMessage)
+ }
+
+ @Test
+ fun givenAReadyBackendStillBeingChecked_whenRead_thenItIsConnecting() {
+ val backend = backend(
+ "openai", available = true, model = null, status = CapabilityStatus.CONNECTING,
+ )
+
+ val tag = CapabilityReaders.backend(installed("openai", "OpenAI"), service(backend))
+
+ assertEquals(CapabilityStatus.CONNECTING, tag.status)
+ }
+
+ @Test
+ fun givenABackendThatThrowsFromItsStatus_whenRead_thenItIsTakenAsAvailable() {
+ val backend = backend("gemini", available = true, model = null).also {
+ every { it.status } throws IllegalStateException("boom")
+ }
+
+ val tag = CapabilityReaders.backend(installed("gemini", "Gemini API"), service(backend))
+
+ assertEquals(CapabilityStatus.AVAILABLE, tag.status)
+ assertNull(tag.statusMessage)
+ }
+
+ @Test
+ fun givenABackendThatReportsNeitherStatusNorModel_whenRead_thenItIsAvailableWithNoModel() {
+ val backend = mockk {
+ every { getId() } returns "gemini"
+ every { isAvailable() } returns true
+ }
+
+ val tag = CapabilityReaders.backend(installed("gemini", "Gemini API"), service(backend))
+
+ assertEquals(CapabilityStatus.AVAILABLE, tag.status)
+ assertNull(tag.statusMessage)
+ assertNull(tag.modelName)
+ }
+
+ @Test
+ fun givenASourceThatReportsNeitherStatusNorGroups_whenRead_thenItIsOneAvailableTag() {
+ val plain = mockk {
+ every { providerId } returns OTHER
+ every { displayName } returns "Search"
+ every { listTools() } returns List(2) { mockk() }
+ }
+
+ val tag = CapabilityReaders.tools(listOf(plain)).single()
+
+ assertEquals(CapabilityStatus.AVAILABLE, tag.status)
+ assertEquals(2, tag.toolCount)
+ assertNull(tag.groupId)
+ }
+
+ @Test
+ fun givenNoBackendInstalled_whenRead_thenTheTagSaysSo() {
+ val tag = CapabilityReaders.backend(SelectedBackend.None, null)
+
+ assertEquals(BackendState.NONE_INSTALLED, tag.state)
+ assertEquals(CapabilityStatus.DEGRADED, tag.status)
+ }
+
+ private fun installed(id: String, name: String) =
+ SelectedBackend.Installed(BackendOption(id, name, null, null))
+
+ private fun service(backend: LlmInferenceService.LlmBackend): LlmInferenceService {
+ // Read before recording: a call on another mock inside `every` is recorded as part of it.
+ val id = backend.id
+ return mockk { every { getBackend(id) } returns backend }
+ }
+
+ private fun backend(
+ id: String,
+ available: Boolean,
+ model: String?,
+ status: CapabilityStatus = CapabilityStatus.AVAILABLE,
+ message: String? = null,
+ ) = mockk {
+ every { getId() } returns id
+ every { isAvailable() } returns available
+ every { activeModelName } returns model
+ every { getStatus() } returns status
+ every { statusMessage } returns message
+ }
+
+ private fun source(
+ id: String,
+ name: String = if (id == MCP) "MCP servers" else "Search",
+ tools: Int = 0,
+ status: CapabilityStatus = CapabilityStatus.AVAILABLE,
+ groups: List = emptyList(),
+ ) = mockk {
+ every { providerId } returns id
+ every { displayName } returns name
+ every { listTools() } returns List(tools) { mockk() }
+ every { this@mockk.status } returns status
+ every { statusMessage } returns null
+ every { toolGroups } returns groups
+ }
+
+ /** A backend that reports both status and model, as the OpenAI plugin's does. */
+ private interface FullBackend :
+ LlmInferenceService.StatusReportingBackend,
+ LlmInferenceService.ActiveModelReportingBackend
+
+ /** A source that reports both status and groups, as the MCP plugin's does. */
+ private interface FullSource :
+ ToolSourceRegistry.StatusReportingToolSource,
+ ToolSourceRegistry.GroupedToolSource
+
+ private fun group(
+ id: String,
+ name: String,
+ tools: Int,
+ status: CapabilityStatus = CapabilityStatus.AVAILABLE,
+ message: String? = null,
+ ) = mockk {
+ every { this@mockk.id } returns id
+ every { displayName } returns name
+ every { toolNames } returns List(tools) { "tool_$it" }
+ every { this@mockk.status } returns status
+ every { statusMessage } returns message
+ }
+}
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/services/LlmInferenceServiceListenerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/services/LlmInferenceServiceListenerTest.kt
new file mode 100644
index 00000000..3f0e6b41
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/services/LlmInferenceServiceListenerTest.kt
@@ -0,0 +1,93 @@
+package com.itsaky.androidide.plugins.aicore.services
+
+import com.itsaky.androidide.plugins.services.LlmInferenceService.BackendChangeListener
+import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmBackend
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * Tests the backend-change listeners the chat's capability tags depend on: every register,
+ * unregister and reported change reaches them, and nothing else does.
+ */
+class LlmInferenceServiceListenerTest {
+
+ private lateinit var service: LlmInferenceServiceImpl
+ private val heard = mutableListOf()
+ private val listener = BackendChangeListener { heard += it }
+
+ @Before
+ fun setUp() {
+ service = LlmInferenceServiceImpl()
+ service.addBackendChangeListener(listener)
+ }
+
+ @Test
+ fun givenAListener_whenABackendRegistersAndUnregisters_thenItHearsBoth() {
+ service.registerBackend(backend("gemini"))
+ service.unregisterBackend("gemini")
+
+ assertEquals(listOf("gemini", "gemini"), heard)
+ }
+
+ @Test
+ fun givenAListener_whenAnUnknownBackendUnregisters_thenItHearsNothing() {
+ service.unregisterBackend("nobody")
+
+ assertEquals(emptyList(), heard)
+ }
+
+ @Test
+ fun givenARegisteredBackend_whenItReportsAChange_thenTheListenerHearsItsId() {
+ service.registerBackend(backend("local"))
+ heard.clear()
+
+ service.notifyBackendChanged("local")
+
+ assertEquals(listOf("local"), heard)
+ }
+
+ @Test
+ fun givenAnUnregisteredId_whenAChangeIsReported_thenItIsIgnored() {
+ service.notifyBackendChanged("nobody")
+
+ assertEquals(emptyList(), heard)
+ }
+
+ @Test
+ fun givenARemovedListener_whenABackendRegisters_thenItIsNotCalled() {
+ service.removeBackendChangeListener(listener)
+
+ service.registerBackend(backend("gemini"))
+
+ assertEquals(emptyList(), heard)
+ }
+
+ @Test
+ fun givenTheSameListenerAddedTwice_whenABackendRegisters_thenItIsCalledOnce() {
+ service.addBackendChangeListener(listener)
+
+ service.registerBackend(backend("gemini"))
+
+ assertEquals(listOf("gemini"), heard)
+ }
+
+ @Test
+ fun givenAListenerThatThrows_whenABackendRegisters_thenTheOthersStillHearAndNothingEscapes() {
+ val throwing = BackendChangeListener { throw IllegalStateException("boom") }
+ service.removeBackendChangeListener(listener)
+ service.addBackendChangeListener(throwing)
+ service.addBackendChangeListener(listener)
+
+ service.registerBackend(backend("gemini"))
+
+ assertEquals(listOf("gemini"), heard)
+ }
+
+ private fun backend(id: String) = mockk(relaxed = true) {
+ every { getId() } returns id
+ every { getName() } returns id
+ }
+}
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/services/ToolSourceRegistryListenerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/services/ToolSourceRegistryListenerTest.kt
new file mode 100644
index 00000000..d773294e
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/services/ToolSourceRegistryListenerTest.kt
@@ -0,0 +1,152 @@
+package com.itsaky.androidide.plugins.aicore.services
+
+import com.itsaky.androidide.plugins.aicore.tool.sources.ToolSourceStore
+import com.itsaky.androidide.plugins.services.CapabilityStatus
+import com.itsaky.androidide.plugins.services.ToolSourceRegistry
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * Tests the registry's consumer listeners (contract 2): told on register, unregister, a tools
+ * change and a status change, and only for a provider that is actually registered.
+ */
+class ToolSourceRegistryListenerTest {
+
+ private companion object {
+ const val MCP = "com.itsaky.androidide.plugins.aiagentmcp"
+ }
+
+ private lateinit var store: ToolSourceStore
+ private lateinit var registry: ToolSourceRegistryImpl
+ private val heard = mutableListOf()
+ private val listener = ToolSourceRegistry.ToolSourceListener { heard += it }
+
+ @Before
+ fun setUp() {
+ store = ToolSourceStore()
+ registry = ToolSourceRegistryImpl(store)
+ registry.addToolSourceListener(listener)
+ }
+
+ @Test
+ fun givenAListener_whenASourceRegistersAndUnregisters_thenItHearsBoth() {
+ val source = source(MCP)
+
+ registry.registerToolSource(source)
+ registry.unregisterToolSource(source)
+
+ assertEquals(listOf(MCP, MCP), heard)
+ }
+
+ @Test
+ fun givenARegisteredSource_whenItsToolsOrStatusChange_thenTheListenerHearsEach() {
+ registry.registerToolSource(source(MCP))
+ heard.clear()
+
+ registry.notifyToolsChanged(MCP)
+ registry.notifyToolSourceStatusChanged(MCP)
+
+ assertEquals(listOf(MCP, MCP), heard)
+ }
+
+ @Test
+ fun givenAStatusChange_whenItArrives_thenTheAgentsToolSetIsNotRebuilt() {
+ registry.registerToolSource(source(MCP))
+ var rebuilds = 0
+ store.addChangeListener { rebuilds++ }
+
+ registry.notifyToolSourceStatusChanged(MCP)
+
+ assertEquals(0, rebuilds)
+ }
+
+ @Test
+ fun givenAnUnknownProvider_whenItReportsAChange_thenNothingIsHeard() {
+ registry.notifyToolsChanged("com.example.nobody")
+ registry.notifyToolSourceStatusChanged("com.example.nobody")
+
+ assertEquals(emptyList(), heard)
+ }
+
+ @Test
+ fun givenARemovedListener_whenASourceRegisters_thenItIsNotCalled() {
+ registry.removeToolSourceListener(listener)
+
+ registry.registerToolSource(source(MCP))
+
+ assertEquals(emptyList(), heard)
+ }
+
+ @Test
+ fun givenAListenerThatThrows_whenASourceRegisters_thenTheOthersStillHear() {
+ registry.removeToolSourceListener(listener)
+ registry.addToolSourceListener { throw IllegalStateException("boom") }
+ registry.addToolSourceListener(listener)
+
+ registry.registerToolSource(source(MCP))
+
+ assertEquals(listOf(MCP), heard)
+ }
+
+ @Test
+ fun givenARegisteredSource_whenTheRegistryListsIt_thenItsGroupsAndStatusPassThroughUnchanged() {
+ val group = mockk()
+ val source = source(MCP).also {
+ every { it.toolGroups } returns listOf(group)
+ every { it.status } returns CapabilityStatus.DEGRADED
+ }
+
+ registry.registerToolSource(source)
+ val listed = registry.toolSources.single() as FullSource
+
+ assertEquals(listOf(group), listed.toolGroups)
+ assertEquals(CapabilityStatus.DEGRADED, listed.status)
+ }
+
+ @Test
+ fun givenAListenerThatTellsThemApart_whenTheStatusChanges_thenOnlyItsStatusCallbackRuns() {
+ registry.removeToolSourceListener(listener)
+ val tools = mutableListOf()
+ val status = mutableListOf()
+ registry.addToolSourceListener(object : ToolSourceRegistry.ToolSourceListener {
+ override fun onToolSourcesChanged(providerId: String) {
+ tools += providerId
+ }
+
+ override fun onToolSourceStatusChanged(providerId: String) {
+ status += providerId
+ }
+ })
+ registry.registerToolSource(source(MCP))
+ tools.clear()
+
+ registry.notifyToolSourceStatusChanged(MCP)
+ registry.notifyToolsChanged(MCP)
+
+ assertEquals(listOf(MCP), status)
+ assertEquals(listOf(MCP), tools)
+ }
+
+ @Test
+ fun givenAListener_whenTheSameOneIsAddedTwice_thenItHearsEachChangeOnce() {
+ registry.addToolSourceListener(listener)
+
+ registry.registerToolSource(source(MCP))
+
+ assertEquals(listOf(MCP), heard)
+ }
+
+ /** A source that reports both status and groups, as the MCP plugin's does. */
+ private interface FullSource :
+ ToolSourceRegistry.StatusReportingToolSource,
+ ToolSourceRegistry.GroupedToolSource
+
+ private fun source(id: String) = mockk(relaxed = true) {
+ every { providerId } returns id
+ every { displayName } returns "MCP servers"
+ every { listTools() } returns emptyList()
+ }
+}