From 1a109998433c2a2912ae70c64895915a71dfcefe Mon Sep 17 00:00:00 2001
From: John Trujillo
Date: Fri, 18 Sep 2026 16:28:52 -0500
Subject: [PATCH 1/2] fix(ai-agents): draw the dropdown chevron and unify both
panes [ADFA-6119]
Material 1.10 ignores app:endIconDrawable, so the chevron is set in code on the OpenAI preset and model fields. Gemini's Spinner becomes the same control and its pane moves onto the shared button, text and spacing styles.
---
.../aiagentgemini/plugin/GeminiPlugin.kt | 2 +-
.../settings/GeminiSettingsFragment.kt | 315 +++++++++---------
.../src/main/res/drawable/ic_dropdown.xml | 10 +
.../res/layout/fragment_gemini_settings.xml | 137 ++++++--
.../src/main/res/layout/item_dropdown.xml | 15 +
.../src/main/res/values/dimens.xml | 22 ++
.../src/main/res/values/strings.xml | 15 +-
.../src/main/res/values/styles.xml | 85 +++++
.../aiagentopenai/plugin/OpenAiPlugin.kt | 3 +
.../settings/OpenAiSettingsFragment.kt | 29 +-
.../res/layout/fragment_openai_settings.xml | 7 +-
11 files changed, 460 insertions(+), 180 deletions(-)
create mode 100644 plugins/AI-Agent-Gemini/src/main/res/drawable/ic_dropdown.xml
create mode 100644 plugins/AI-Agent-Gemini/src/main/res/layout/item_dropdown.xml
create mode 100644 plugins/AI-Agent-Gemini/src/main/res/values/dimens.xml
diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt
index 391fd59e..dff4c643 100644
--- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt
+++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt
@@ -277,7 +277,7 @@ class GeminiPlugin : IPlugin, DocumentationExtension {
does by embedding every chunk of the project with this model. It
is a separate setting because no Gemini model does both: this list
holds the models that advertise embedContent, and the
- Gemini Model list above holds those that advertise
+ Model list above holds those that advertise
generateContent.
Changing it changes the vector space, so the project is indexed
again from scratch. Vectors from two different models are not
diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt
index 24e0b8cc..f1368335 100644
--- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt
+++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt
@@ -11,17 +11,18 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
-import android.widget.AdapterView
import android.widget.ArrayAdapter
+import android.widget.AutoCompleteTextView
import android.widget.Button
import android.widget.EditText
+import android.widget.Filter
import android.widget.LinearLayout
-import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.DrawableRes
-import androidx.fragment.app.Fragment
+import androidx.annotation.IdRes
import androidx.annotation.StringRes
+import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
@@ -38,7 +39,6 @@ import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
-import kotlin.math.roundToInt
/**
* This backend's settings pane, mounted by whichever screen offers a backend selector.
@@ -109,6 +109,9 @@ class GeminiSettingsFragment : Fragment() {
)[GeminiSettingsViewModel::class.java]
setupApiKeyUi(view)
+ setupModelPicker(view, chatModelPicker())
+ setupModelPicker(view, embeddingModelPicker())
+ setupModelRefresh(view)
}
override fun onResume() {
@@ -149,8 +152,9 @@ class GeminiSettingsFragment : Fragment() {
* Long-press on [box]'s end icon shows [tag]'s tooltip.
*
* Separate from [wireTooltip] because the end icon is a clickable child that consumes the
- * long-press before the box sees it — without this the reveal control would be the one
- * contributed element with no tooltip of its own.
+ * long-press before the box sees it — without this every end icon on the pane, the reveal
+ * control and the dropdown chevrons alike, would be a contributed element with no tooltip of
+ * its own.
*/
private fun wireEndIconTooltip(box: TextInputLayout, tag: String) {
box.setEndIconOnLongClickListener { icon ->
@@ -163,6 +167,7 @@ class GeminiSettingsFragment : Fragment() {
@SuppressLint("SetTextI18n")
private fun setupApiKeyUi(view: View) {
val apiKeyLayout = view.findViewById(R.id.gemini_api_key_layout)
+ val apiKeyLabel = view.findViewById(R.id.gemini_api_key_label)
val apiKeyBox = view.findViewById(R.id.gemini_api_key_box)
val apiKeyInput = view.findViewById(R.id.gemini_api_key_input)
val saveButton = view.findViewById(R.id.btn_save_api_key)
@@ -174,7 +179,7 @@ class GeminiSettingsFragment : Fragment() {
// Not on apiKeyInput: long-press there is the paste menu, and a key is pasted.
listOf(
- apiKeyBox, saveButton, editButton, clearButton, verificationText
+ apiKeyLabel, apiKeyBox, saveButton, editButton, clearButton, verificationText
).forEach { wireTooltip(it, GeminiPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_KEY) }
wireTooltip(getKeyButton, GeminiPlugin.TOOLTIP_TAG_SETTINGS_GET_KEY)
@@ -183,12 +188,6 @@ class GeminiSettingsFragment : Fragment() {
// is otherwise unreachable.
wireTooltip(statusTextView, GeminiPlugin.TOOLTIP_TAG_PLUGIN)
- // Create the two model selection containers: chat first, embedding beneath it.
- val chatPicker = chatModelPicker()
- val embeddingPicker = embeddingModelPicker()
- val chatModelContainer = createModelSelectionUi(view, chatPicker)
- val embeddingModelContainer = createModelSelectionUi(view, embeddingPicker)
-
/**
* Show the outcome of (or progress of) the live key check.
*
@@ -503,11 +502,6 @@ class GeminiSettingsFragment : Fragment() {
updateUiState(isEditing = true)
apiKeyInput.setText("")
}
-
- // Setup model selection
- setupModelSelection(chatModelContainer, chatPicker)
- setupModelSelection(embeddingModelContainer, embeddingPicker)
- fetchModelsIfNeeded()
}
/**
@@ -587,29 +581,44 @@ class GeminiSettingsFragment : Fragment() {
}
/**
- * Density-independent [dp] as whole pixels, for the views this pane builds in code. The
- * `setPadding` family takes raw pixels, so a literal shrinks as screen density rises.
+ * Put the dropdown chevron on [box]'s end icon.
*
- * @param dp the density-independent size to convert
- * @return the equivalent size in device pixels
+ * The drawable is set here rather than in the layout because Material 1.10 never reads
+ * `app:endIconDrawable`, so a custom end icon declared in XML draws blank — the same reason
+ * [SecretRevealController] sets the reveal icon in code. Without a visible chevron the field
+ * reads as a plain, read-only text box rather than a list to open.
*/
- private fun dp(dp: Int): Int = (dp * resources.displayMetrics.density).roundToInt()
+ private fun setupDropdownEndIcon(box: TextInputLayout) {
+ // Already the mode declared in the layout; set again so the drawable below cannot be the
+ // one a later mode switch discards.
+ box.endIconMode = TextInputLayout.END_ICON_CUSTOM
+ box.setEndIconDrawable(R.drawable.ic_dropdown)
+ // Nothing ever moves the icon's checked state, so TalkBack would read "not checked" over
+ // a control that is not a toggle.
+ box.isEndIconCheckable = false
+ }
+
+ // --- Model pickers -------------------------------------------------------------------------
/**
- * Everything one model spinner needs, so the chat and embedding pickers are one implementation
- * rather than two that drift.
+ * Everything one model dropdown needs, so the chat and embedding pickers are one
+ * implementation rather than two that drift.
*
- * @param tagPrefix namespaces this picker's view tags inside the shared container
- * @param titleRes the section heading
- * @param tooltipTag long-press help shared by the spinner and its Refresh button
+ * @param tooltipTag long-press help shared by the picker's label, field, hint and chevron
+ * @param helpHint hint shown while no catalog is offered
+ * @param liveHint hint shown once there is a list to tap
* @param read the currently saved model
* @param write persists a model the user picked
* @param options the catalog to offer
*/
private class ModelPicker(
- val tagPrefix: String,
- @StringRes val titleRes: Int,
+ @IdRes val boxId: Int,
+ @IdRes val inputId: Int,
+ @IdRes val labelId: Int,
+ @IdRes val hintId: Int,
val tooltipTag: String,
+ @StringRes val helpHint: Int,
+ @StringRes val liveHint: Int,
val read: () -> String,
val write: (String) -> Unit,
val options: LiveData,
@@ -617,9 +626,13 @@ class GeminiSettingsFragment : Fragment() {
/** The chat model: what a turn is generated with. */
private fun chatModelPicker() = ModelPicker(
- tagPrefix = "chat_model",
- titleRes = R.string.label_gemini_model,
+ boxId = R.id.gemini_model_box,
+ inputId = R.id.gemini_model_input,
+ labelId = R.id.gemini_model_label,
+ hintId = R.id.gemini_model_hint_text,
tooltipTag = GeminiPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_MODEL,
+ helpHint = R.string.hint_gemini_model_help,
+ liveHint = R.string.hint_gemini_model_live,
read = viewModel::getGeminiModel,
write = viewModel::saveGeminiModel,
options = viewModel.geminiModels,
@@ -627,109 +640,97 @@ class GeminiSettingsFragment : Fragment() {
/** The embedding model: what semantic search indexes and queries with. */
private fun embeddingModelPicker() = ModelPicker(
- tagPrefix = "embedding_model",
- titleRes = R.string.label_gemini_embedding_model,
+ boxId = R.id.gemini_embedding_model_box,
+ inputId = R.id.gemini_embedding_model_input,
+ labelId = R.id.gemini_embedding_model_label,
+ hintId = R.id.gemini_embedding_model_hint_text,
tooltipTag = GeminiPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_EMBEDDING_MODEL,
+ helpHint = R.string.hint_gemini_embedding_model_help,
+ liveHint = R.string.hint_gemini_embedding_model_live,
read = viewModel::getGeminiEmbeddingModel,
write = viewModel::saveGeminiEmbeddingModel,
options = viewModel.geminiEmbeddingModels,
)
/**
- * Builds one picker's views into [parent].
- *
- * Built in code rather than inflated because this pane's layout predates having two of them;
- * the views are tagged, not given ids, so two pickers can share one container without a second
- * set of resource ids.
+ * One model dropdown, built as the same control the OpenAI pane carries rather than a
+ * framework Spinner: one box, one chevron, one list.
*
- * @return the container holding this picker's views
+ * Pick-only — `keyListener = null` — because the catalog is whatever the key can reach, so
+ * unlike the OpenAI pane there is no free-text model to type. The field itself is what shows
+ * the model in use, which is why there is no separate "current model" line any more.
*/
- private fun createModelSelectionUi(parent: View, picker: ModelPicker): LinearLayout {
- val context = requireContext()
- val container = LinearLayout(context).apply {
- orientation = LinearLayout.VERTICAL
- setPadding(0, dp(32), 0, 0)
- }
-
- val titleText = TextView(context).apply {
- text = getString(picker.titleRes)
- textSize = 16f
- setPadding(0, 0, 0, dp(16))
- }
- container.addView(titleText)
-
- val currentModelText = TextView(context).apply {
- id = View.generateViewId()
- text = getString(R.string.current_model, picker.read())
- setPadding(0, 0, 0, dp(8))
- }
- container.addView(currentModelText)
-
- val modelSpinner = Spinner(context).apply { id = View.generateViewId() }
- container.addView(modelSpinner)
-
- val refreshButton = Button(context).apply {
- id = View.generateViewId()
- text = getString(R.string.refresh_models)
- }
- container.addView(refreshButton)
-
- if (parent is ViewGroup) {
- parent.addView(container)
- }
-
- // Tag the views for later reference, namespaced so both pickers can share one parent.
- container.tag = "${picker.tagPrefix}_container"
- currentModelText.tag = "${picker.tagPrefix}_current_text"
- modelSpinner.tag = "${picker.tagPrefix}_spinner"
- refreshButton.tag = "${picker.tagPrefix}_refresh"
-
- return container
- }
-
- @SuppressLint("ClickableViewAccessibility")
- private fun setupModelSelection(container: View, picker: ModelPicker) {
- val currentModelText =
- container.findViewWithTag("${picker.tagPrefix}_current_text")
- val modelSpinner = container.findViewWithTag("${picker.tagPrefix}_spinner")
- val refreshButton = container.findViewWithTag("${picker.tagPrefix}_refresh")
-
- if (modelSpinner == null || refreshButton == null) return
-
- wireTooltip(modelSpinner, picker.tooltipTag)
- wireTooltip(refreshButton, picker.tooltipTag)
-
- // Track real user taps so programmatic selection changes never persist a model.
- var userTouchedSpinner = false
-
- fun updateModelSpinner(models: List, isLive: Boolean) {
- val adapter = ArrayAdapter(
- requireContext(),
- android.R.layout.simple_spinner_item,
- models
- )
- adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
- modelSpinner.adapter = adapter
-
- // Migrate off a retired saved model only for a live catalog, never for the fallback.
- val currentModel = picker.read()
- val currentIndex = models.indexOf(currentModel)
- when {
- currentIndex >= 0 -> modelSpinner.setSelection(currentIndex)
- isLive && models.isNotEmpty() -> {
- modelSpinner.setSelection(0)
- val migrated = models[0]
- picker.write(migrated)
- currentModelText?.text = getString(R.string.current_model, migrated)
- }
+ private fun setupModelPicker(view: View, picker: ModelPicker) {
+ val modelBox = view.findViewById(picker.boxId)
+ val modelInput = view.findViewById(picker.inputId)
+ val modelLabel = view.findViewById(picker.labelId)
+ val modelHint = view.findViewById(picker.hintId)
+
+ setupDropdownEndIcon(modelBox)
+
+ listOf(modelLabel, modelInput, modelHint)
+ .forEach { wireTooltip(it, picker.tooltipTag) }
+
+ // A picker, not a text field: the list is the only way to change it.
+ modelInput.keyListener = null
+ // The list is rebuilt from the catalog on every view creation, so there is nothing for the
+ // framework to restore — and its replayed setText() is a filtering one, which is what left
+ // the list holding only the selected entry after a day/night switch.
+ modelInput.isSaveEnabled = false
+ // The suppressing overload throughout: a filtering write would narrow the list.
+ modelInput.setText(picker.read(), false)
+
+ // Tapping anywhere in the field opens the list; the end icon is only a second way in.
+ modelInput.setOnClickListener { modelInput.showDropDown() }
+ modelBox.setEndIconOnClickListener { modelInput.showDropDown() }
+ wireEndIconTooltip(modelBox, picker.tooltipTag)
+ // Only a real pick reaches here, so unlike the Spinner this replaced there is no
+ // programmatic selection to tell apart from a user's.
+ modelInput.setOnItemClickListener { parent, _, position, _ ->
+ val selected = parent.getItemAtPosition(position) as? String
+ if (selected == null || selected == picker.read()) {
+ return@setOnItemClickListener
}
+ picker.write(selected)
+ Toast.makeText(
+ requireContext(),
+ getString(R.string.model_changed, selected),
+ Toast.LENGTH_SHORT
+ ).show()
}
picker.options.observe(viewLifecycleOwner) { options ->
- if (options.models.isNotEmpty()) {
- updateModelSpinner(options.models, options.isLive)
+ // Cleared, not left stale: an empty list means there is no catalog to offer, and a
+ // remembered one would suggest models this key may no longer reach.
+ modelInput.setAdapter(
+ if (options.models.isEmpty()) {
+ null
+ } else {
+ DropdownAdapter(modelInput.context, options.models)
+ }
+ )
+ modelHint.setText(if (options.models.isEmpty()) picker.helpHint else picker.liveHint)
+
+ // Migrate off a retired saved model only for a live catalog, never for the fallback:
+ // the field has to show what will actually be requested.
+ if (!options.isLive || options.models.contains(picker.read())) {
+ return@observe
}
+ val migrated = options.models.firstOrNull() ?: return@observe
+ picker.write(migrated)
+ modelInput.setText(migrated, false)
}
+ }
+
+ /**
+ * The one Refresh button, shared by both pickers since one catalog walk answers them both.
+ *
+ * The initial fetch is guarded on the chat picker alone: the two are published together, so a
+ * non-empty chat list means the fetch has already happened.
+ */
+ private fun setupModelRefresh(view: View) {
+ val refreshButton = view.findViewById(R.id.btn_refresh_models)
+ wireTooltip(refreshButton, GeminiPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_MODEL)
viewModel.geminiModelsLoading.observe(viewLifecycleOwner) { isLoading ->
refreshButton.isEnabled = !isLoading
@@ -737,43 +738,57 @@ class GeminiSettingsFragment : Fragment() {
if (isLoading) getString(R.string.loading) else getString(R.string.refresh_models)
}
- modelSpinner.setOnTouchListener { _, _ ->
- userTouchedSpinner = true
- false
+ refreshButton.setOnClickListener { viewModel.fetchGeminiModels() }
+
+ if (viewModel.geminiModels.value?.models.isNullOrEmpty()) {
+ viewModel.fetchGeminiModels()
}
+ }
+}
- modelSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
- override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
- // Ignore programmatic selections; only a real user pick persists the model.
- if (!userTouchedSpinner) return
- val selectedModel = parent?.getItemAtPosition(position) as? String
- if (selectedModel != null && selectedModel != picker.read()) {
- picker.write(selectedModel)
- currentModelText?.text = getString(R.string.current_model, selectedModel)
- Toast.makeText(
- requireContext(),
- getString(R.string.model_changed, selectedModel),
- Toast.LENGTH_SHORT
- ).show()
- }
+/**
+ * The model dropdown's rows and, more importantly, its filter.
+ *
+ * [ArrayAdapter]'s own filter narrows the list to whatever is already in the field, so after a
+ * day/night switch — which replays the field's text — the only row left to pick is the one already
+ * chosen. Substring matching over the full list is what a picker needs; identical to the OpenAI
+ * pane's adapter so the two can be lifted into `plugin-api` together.
+ */
+private class DropdownAdapter(
+ context: Context,
+ private val items: List,
+) : ArrayAdapter(context, R.layout.item_dropdown, items.toMutableList()) {
+
+ private val substringFilter = object : Filter() {
+ override fun performFiltering(constraint: CharSequence?): FilterResults {
+ val query = constraint?.toString()?.trim().orEmpty()
+ val matches = if (query.isEmpty()) {
+ items
+ } else {
+ items.filter { it.contains(query, ignoreCase = true) }
+ }
+ return FilterResults().apply {
+ values = matches
+ count = matches.size
}
-
- override fun onNothingSelected(parent: AdapterView<*>?) {}
}
- refreshButton.setOnClickListener { viewModel.fetchGeminiModels() }
+ @Suppress("UNCHECKED_CAST")
+ override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
+ val rows = results?.values as? List ?: items
+ clear()
+ addAll(rows)
+ notifyDataSetChanged()
+ }
}
- /**
- * Fetches the catalog once for both pickers, since one walk answers them both.
- *
- * Guarded on the chat picker alone: the two are published together, so a non-empty chat list
- * means the fetch has already happened.
- */
- private fun fetchModelsIfNeeded() {
- if (viewModel.geminiModels.value?.models.isNullOrEmpty()) {
- viewModel.fetchGeminiModels()
- }
+ override fun getFilter(): Filter = substringFilter
+
+ override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
+ val view = super.getView(position, convertView, parent)
+ // Set on every bind rather than once: these rows are recycled.
+ (view as? TextView)?.setTextColor(context.getColor(R.color.plugin_on_surface))
+ return view
}
}
diff --git a/plugins/AI-Agent-Gemini/src/main/res/drawable/ic_dropdown.xml b/plugins/AI-Agent-Gemini/src/main/res/drawable/ic_dropdown.xml
new file mode 100644
index 00000000..8a9df932
--- /dev/null
+++ b/plugins/AI-Agent-Gemini/src/main/res/drawable/ic_dropdown.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/plugins/AI-Agent-Gemini/src/main/res/layout/fragment_gemini_settings.xml b/plugins/AI-Agent-Gemini/src/main/res/layout/fragment_gemini_settings.xml
index 333e638e..2eaa7bd4 100644
--- a/plugins/AI-Agent-Gemini/src/main/res/layout/fragment_gemini_settings.xml
+++ b/plugins/AI-Agent-Gemini/src/main/res/layout/fragment_gemini_settings.xml
@@ -1,16 +1,25 @@
+
+ android:orientation="vertical"
+ android:theme="@style/ThemeOverlay.Plugin">
+
+
+ android:text="@string/label_gemini_api_key" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/AI-Agent-Gemini/src/main/res/layout/item_dropdown.xml b/plugins/AI-Agent-Gemini/src/main/res/layout/item_dropdown.xml
new file mode 100644
index 00000000..ba750199
--- /dev/null
+++ b/plugins/AI-Agent-Gemini/src/main/res/layout/item_dropdown.xml
@@ -0,0 +1,15 @@
+
+
+
diff --git a/plugins/AI-Agent-Gemini/src/main/res/values/dimens.xml b/plugins/AI-Agent-Gemini/src/main/res/values/dimens.xml
new file mode 100644
index 00000000..bce1ab1c
--- /dev/null
+++ b/plugins/AI-Agent-Gemini/src/main/res/values/dimens.xml
@@ -0,0 +1,22 @@
+
+
+
+ 4dp
+ 8dp
+ 12dp
+ 16dp
+ 24dp
+
+ 8dp
+ 12dp
+
+ 11sp
+ 13sp
+ 15sp
+
+ 48dp
+
diff --git a/plugins/AI-Agent-Gemini/src/main/res/values/strings.xml b/plugins/AI-Agent-Gemini/src/main/res/values/strings.xml
index 946a4d1a..ae625c5c 100644
--- a/plugins/AI-Agent-Gemini/src/main/res/values/strings.xml
+++ b/plugins/AI-Agent-Gemini/src/main/res/values/strings.xml
@@ -16,7 +16,7 @@
The Gemini request failed. %1$s
- Gemini API Key
+ API Key
Enter your Gemini API key
@@ -51,9 +51,16 @@
Copied a new key? Tap Edit, then paste it into the field.
No browser available. The link was copied — open it on another device: %s
Couldn\'t open a browser. Get your key at %s
- Gemini Model
- Gemini Embedding Model
- Current: %s
+ Model
+ Gemini model
+ Show the model list
+ Refresh to list the models your key can reach.
+ Tap to pick from the models this key can reach.
+ Embedding Model
+ Gemini embedding model
+ Show the embedding model list
+ Used by semantic search, never for chat. Refresh to list the embedding models your key can reach.
+ Tap to pick from the embedding models this key can reach.
Refresh Models
Loading…
Model changed to %s
diff --git a/plugins/AI-Agent-Gemini/src/main/res/values/styles.xml b/plugins/AI-Agent-Gemini/src/main/res/values/styles.xml
index 1ba9a1f7..c86952f6 100644
--- a/plugins/AI-Agent-Gemini/src/main/res/values/styles.xml
+++ b/plugins/AI-Agent-Gemini/src/main/res/values/styles.xml
@@ -33,8 +33,85 @@
- @color/plugin_on_error
- @color/plugin_error_container
- @color/plugin_on_error_container
+
+ - @style/PluginButton.Filled
+ - @style/PluginTextInputLayout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt
index e17f0fe0..6fc0e457 100644
--- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt
+++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt
@@ -202,6 +202,9 @@ class OpenAiPlugin : IPlugin, DocumentationExtension {
override fun dispose() {
context.logger.info("OpenAiPlugin: Disposing plugin")
+ // deactivate() removes this too; a dispose without one would leave the host holding this.
+ runCatching { context.removePluginLifecycleListener(aiCoreLifecycle) }
+
releaseBackend()
pluginContext = null
context.logger.info("OpenAiPlugin: Released OpenAI backend")
diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt
index 0d044904..fa5d089c 100644
--- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt
+++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt
@@ -159,8 +159,9 @@ class OpenAiSettingsFragment : Fragment() {
* Long-press on [box]'s end icon shows [tag]'s tooltip.
*
* Separate from [wireTooltip] because the end icon is a clickable child that consumes the
- * long-press before the box sees it — without this the reveal control would be the one
- * contributed element with no tooltip of its own.
+ * long-press before the box sees it — without this every end icon on the pane, the reveal
+ * control and the dropdown chevrons alike, would be a contributed element with no tooltip of
+ * its own.
*/
private fun wireEndIconTooltip(box: TextInputLayout, tag: String) {
box.setEndIconOnLongClickListener { icon ->
@@ -198,6 +199,24 @@ class OpenAiSettingsFragment : Fragment() {
target.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
}
+ /**
+ * Put the dropdown chevron on [box]'s end icon.
+ *
+ * The drawable is set here rather than in the layout because Material 1.10 never reads
+ * `app:endIconDrawable`, so a custom end icon declared in XML draws blank — the same reason
+ * [SecretRevealController] sets the reveal icon in code. Without a visible chevron the field
+ * reads as a plain, read-only text box rather than a list to open.
+ */
+ private fun setupDropdownEndIcon(box: TextInputLayout) {
+ // Already the mode declared in the layout; set again so the drawable below cannot be the
+ // one a later mode switch discards.
+ box.endIconMode = TextInputLayout.END_ICON_CUSTOM
+ box.setEndIconDrawable(R.drawable.ic_dropdown)
+ // Nothing ever moves the icon's checked state, so TalkBack would read "not checked" over
+ // a control that is not a toggle.
+ box.isEndIconCheckable = false
+ }
+
// --- Server -------------------------------------------------------------------------------
private fun setupServerUi(view: View) {
@@ -208,6 +227,8 @@ class OpenAiSettingsFragment : Fragment() {
val statusText = view.findViewById(R.id.openai_server_status_text)
val serverLabel = view.findViewById(R.id.openai_server_label)
+ setupDropdownEndIcon(presetBox)
+
listOf(urlInput, saveButton, serverLabel, statusText)
.forEach { wireTooltip(it, OpenAiPlugin.TOOLTIP_TAG_SETTINGS_SERVER) }
wireTooltip(presetBox, OpenAiPlugin.TOOLTIP_TAG_SETTINGS_PRESET)
@@ -236,6 +257,7 @@ class OpenAiSettingsFragment : Fragment() {
// Tapping anywhere in the field opens the list; the end icon is only a second way in.
presetInput.setOnClickListener { presetInput.showDropDown() }
presetBox.setEndIconOnClickListener { presetInput.showDropDown() }
+ wireEndIconTooltip(presetBox, OpenAiPlugin.TOOLTIP_TAG_SETTINGS_PRESET)
presetInput.setOnItemClickListener { _, _, position, _ ->
// A preset only fills the field; the user still has to save it.
ServerPresets.ALL.getOrNull(position)?.url?.let { urlInput.setText(it) }
@@ -779,6 +801,8 @@ class OpenAiSettingsFragment : Fragment() {
val modelLabel = view.findViewById(picker.labelId)
val modelHint = view.findViewById(picker.hintId)
+ setupDropdownEndIcon(modelBox)
+
listOf(modelLabel, modelInput, modelHint)
.forEach { wireTooltip(it, picker.tooltipTag) }
@@ -806,6 +830,7 @@ class OpenAiSettingsFragment : Fragment() {
// Tapping the field opens the list; completionThreshold=0 alone waits for a keystroke.
modelInput.setOnClickListener { modelInput.showDropDown() }
modelBox.setEndIconOnClickListener { modelInput.showDropDown() }
+ wireEndIconTooltip(modelBox, picker.tooltipTag)
modelInput.setOnItemClickListener { _, _, _, _ -> commitTypedModel() }
// A server that does not offer the saved model retires it; the field must show what will
diff --git a/plugins/AI-Agent-OpenAI/src/main/res/layout/fragment_openai_settings.xml b/plugins/AI-Agent-OpenAI/src/main/res/layout/fragment_openai_settings.xml
index 2ed7c21d..d7ff6b24 100644
--- a/plugins/AI-Agent-OpenAI/src/main/res/layout/fragment_openai_settings.xml
+++ b/plugins/AI-Agent-OpenAI/src/main/res/layout/fragment_openai_settings.xml
@@ -22,7 +22,9 @@
android:text="@string/label_openai_server" />
+ list, and typing is still possible where it makes sense. The chevron is not declared here:
+ Material 1.10 never reads app:endIconDrawable, so the icon a layout asks for draws blank —
+ setupDropdownEndIcon() sets it on every box in code. -->
Date: Fri, 25 Sep 2026 08:16:52 -0500
Subject: [PATCH 2/2] fix(ai-agents): address #106 review feedback [ADFA-6119]
Reword endIconDrawable comments, let pick-only model fields wrap, drop unused radius_lg.
---
.../aiagentgemini/settings/GeminiSettingsFragment.kt | 6 +++---
.../plugins/aiagentgemini/ui/SecretRevealController.kt | 4 ++--
.../src/main/res/layout/fragment_gemini_settings.xml | 7 ++-----
plugins/AI-Agent-Gemini/src/main/res/values/dimens.xml | 1 -
.../plugins/aiagentmcp/ui/SecretRevealController.kt | 4 ++--
.../aiagentopenai/settings/OpenAiSettingsFragment.kt | 6 +++---
.../plugins/aiagentopenai/ui/SecretRevealController.kt | 4 ++--
.../src/main/res/layout/fragment_openai_settings.xml | 7 +++----
plugins/AI-Agent-OpenAI/src/main/res/values/dimens.xml | 1 -
9 files changed, 17 insertions(+), 23 deletions(-)
diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt
index f1368335..a7c7baca 100644
--- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt
+++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt
@@ -583,9 +583,9 @@ class GeminiSettingsFragment : Fragment() {
/**
* Put the dropdown chevron on [box]'s end icon.
*
- * The drawable is set here rather than in the layout because Material 1.10 never reads
- * `app:endIconDrawable`, so a custom end icon declared in XML draws blank — the same reason
- * [SecretRevealController] sets the reveal icon in code. Without a visible chevron the field
+ * The drawable is set here rather than in the layout because an end icon declared as
+ * `app:endIconDrawable` draws blank inside the host — the same reason [SecretRevealController]
+ * sets the reveal icon in code. Without a visible chevron the field
* reads as a plain, read-only text box rather than a list to open.
*/
private fun setupDropdownEndIcon(box: TextInputLayout) {
diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/ui/SecretRevealController.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/ui/SecretRevealController.kt
index 81d0d33c..ac8d5f63 100644
--- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/ui/SecretRevealController.kt
+++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/ui/SecretRevealController.kt
@@ -37,8 +37,8 @@ internal class SecretRevealController(
/**
* Take over [box]'s end icon and mask the field.
*
- * The drawable is set here rather than in the layout because Material 1.10 never reads
- * `app:endIconDrawable`, so a custom end icon declared in XML draws blank.
+ * The drawable is set here rather than in the layout because an end icon declared as
+ * `app:endIconDrawable` draws blank inside the host.
*/
fun attach() {
box.endIconMode = TextInputLayout.END_ICON_CUSTOM
diff --git a/plugins/AI-Agent-Gemini/src/main/res/layout/fragment_gemini_settings.xml b/plugins/AI-Agent-Gemini/src/main/res/layout/fragment_gemini_settings.xml
index 2eaa7bd4..688a4a51 100644
--- a/plugins/AI-Agent-Gemini/src/main/res/layout/fragment_gemini_settings.xml
+++ b/plugins/AI-Agent-Gemini/src/main/res/layout/fragment_gemini_settings.xml
@@ -40,8 +40,7 @@
sit beside the field: the icon has a real touch target, and it is the same control the
other AI plugins' credential fields carry. Nothing endIcon-related is declared here —
SecretRevealController sets the mode, drawable, description and listener in code, because
- Material 1.10 never reads app:endIconDrawable and a custom icon declared in XML draws
- blank.
+ an end icon declared as app:endIconDrawable here draws blank inside the host.
-->
@@ -179,7 +177,6 @@
android:layout_height="wrap_content"
android:completionThreshold="0"
android:inputType="none"
- android:maxLines="1"
android:popupBackground="@color/plugin_surface" />
diff --git a/plugins/AI-Agent-Gemini/src/main/res/values/dimens.xml b/plugins/AI-Agent-Gemini/src/main/res/values/dimens.xml
index bce1ab1c..8279555f 100644
--- a/plugins/AI-Agent-Gemini/src/main/res/values/dimens.xml
+++ b/plugins/AI-Agent-Gemini/src/main/res/values/dimens.xml
@@ -12,7 +12,6 @@
24dp
8dp
- 12dp
11sp
13sp
diff --git a/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/ui/SecretRevealController.kt b/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/ui/SecretRevealController.kt
index 3032f58a..098f08a9 100644
--- a/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/ui/SecretRevealController.kt
+++ b/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/ui/SecretRevealController.kt
@@ -37,8 +37,8 @@ internal class SecretRevealController(
/**
* Take over [box]'s end icon and mask the field.
*
- * The drawable is set here rather than in the layout because Material 1.10 never reads
- * `app:endIconDrawable`, so a custom end icon declared in XML draws blank.
+ * The drawable is set here rather than in the layout because an end icon declared as
+ * `app:endIconDrawable` draws blank inside the host.
*/
fun attach() {
// The box exists only to carry the end icon: the dialog's other fields are plain EditTexts,
diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt
index fa5d089c..4c82f89a 100644
--- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt
+++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt
@@ -202,9 +202,9 @@ class OpenAiSettingsFragment : Fragment() {
/**
* Put the dropdown chevron on [box]'s end icon.
*
- * The drawable is set here rather than in the layout because Material 1.10 never reads
- * `app:endIconDrawable`, so a custom end icon declared in XML draws blank — the same reason
- * [SecretRevealController] sets the reveal icon in code. Without a visible chevron the field
+ * The drawable is set here rather than in the layout because an end icon declared as
+ * `app:endIconDrawable` draws blank inside the host — the same reason [SecretRevealController]
+ * sets the reveal icon in code. Without a visible chevron the field
* reads as a plain, read-only text box rather than a list to open.
*/
private fun setupDropdownEndIcon(box: TextInputLayout) {
diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/ui/SecretRevealController.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/ui/SecretRevealController.kt
index 405d5d5a..227e9bf5 100644
--- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/ui/SecretRevealController.kt
+++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/ui/SecretRevealController.kt
@@ -37,8 +37,8 @@ internal class SecretRevealController(
/**
* Take over [box]'s end icon and mask the field.
*
- * The drawable is set here rather than in the layout because Material 1.10 never reads
- * `app:endIconDrawable`, so a custom end icon declared in XML draws blank.
+ * The drawable is set here rather than in the layout because an end icon declared as
+ * `app:endIconDrawable` draws blank inside the host.
*/
fun attach() {
box.endIconMode = TextInputLayout.END_ICON_CUSTOM
diff --git a/plugins/AI-Agent-OpenAI/src/main/res/layout/fragment_openai_settings.xml b/plugins/AI-Agent-OpenAI/src/main/res/layout/fragment_openai_settings.xml
index d7ff6b24..17dd2819 100644
--- a/plugins/AI-Agent-OpenAI/src/main/res/layout/fragment_openai_settings.xml
+++ b/plugins/AI-Agent-OpenAI/src/main/res/layout/fragment_openai_settings.xml
@@ -23,7 +23,7 @@
@@ -122,8 +121,8 @@
gets a real touch target, and the fragment still owns the click so the window can
be flagged secure while the key is legible. Nothing endIcon-related is declared
here — SecretRevealController sets the mode, drawable, description and listener in
- code, because Material 1.10 never reads app:endIconDrawable and the icon a layout
- asks for draws blank. -->
+ code, because an end icon declared as app:endIconDrawable here draws blank inside
+ the host. -->
24dp
8dp
- 12dp
11sp
13sp