diff --git a/ai-assistant/README.md b/ai-assistant/README.md index 5a01ae6b..e3e09f7a 100644 --- a/ai-assistant/README.md +++ b/ai-assistant/README.md @@ -58,19 +58,40 @@ The build resolves `plugin-api.jar` from the repo-root `../libs/`. ## Security -- **Gemini API key at rest.** When you use the Gemini backend, the API key is - stored in this plugin's private `SharedPreferences` (`AgentSettings`). That - store is **app-sandboxed** — other apps cannot read it — but it is **not - encrypted at rest**, so it is recoverable on a rooted or otherwise compromised - device. Remove it any time from **AI Settings** (or `clearGeminiApiKey()`). - Encryption is deliberately not layered on here: the key is shared at runtime - with the sibling `ai-core` plugin (which performs the Gemini calls), and - `EncryptedSharedPreferences` would force both independent plugins to agree on a - master-key alias and crypto library version — a fragile cross-plugin coupling. - A host-provided secure-storage service is the correct long-term fix. +- **Gemini API key at rest.** The key is encrypted with AES/GCM under a + hardware-backed Android Keystore secret and only the ciphertext is written to + this plugin's private `SharedPreferences` (`AgentSettings`), as + `enc:v1:` + base64(iv‖ciphertext). A copied prefs file — root, `adb backup`, + forensic dump — is useless without this device's Keystore. Remove the key any + time from **AI Settings** (or `clearGeminiApiKey()`). + - A key stored before this plugin encrypted them is still plaintext on disk; + it is re-encrypted in place the first time it's read + (`SecureApiKeyStore.readAndMigrate`), so no user action is needed. + - The key is **not** bound to user authentication + (`setUserAuthenticationRequired` is not set), so it stays usable for + background inference and a lock-screen credential change does not invalidate + it. If the Keystore entry is lost anyway — the app's data is cleared, or an + OEM Keystore drops the alias — the stored key can no longer be decrypted and + must be re-entered; **Edit** says so instead of presenting an empty field. + Saving surfaces a failure message rather than silently storing nothing. + - Encryption at rest defends against *offline* recovery of the prefs file. It + does not defend against code already running as the host IDE's UID, which can + simply call `decrypt` — that would need a host-provided secure-storage + service with per-plugin isolation. +- **Shared crypto across two plugins.** `ai-core` performs the Gemini calls and + therefore has to read the same key. Both plugins run in the host IDE's process + (same UID) and so share one Android Keystore; each ships an identical copy of + `SecureApiKeyStore` pinned to the alias `cotg_ai_gemini_key_v1`. The two copies + must stay byte-identical apart from their package line — if `ALIAS`, + `TRANSFORM`, `IV_LEN` or `ENC_PREFIX` drift, `ai-core` silently fails to + decrypt and Gemini reports "backend not available". A host-provided + secure-storage service would remove the duplication. - **Gemini API key in transit.** The key is sent to Google as an `x-goog-api-key` request header (and via the SDK on the chat path), never in a URL query string. - **File tools are confined to the project root** — see the in-IDE help page. +- **The context-file picker is confined to the open project** and fails closed: + with no project open it refuses to open rather than falling back to a broader + root. ## License diff --git a/ai-assistant/src/main/AndroidManifest.xml b/ai-assistant/src/main/AndroidManifest.xml index f50acac3..0f0e60ea 100644 --- a/ai-assistant/src/main/AndroidManifest.xml +++ b/ai-assistant/src/main/AndroidManifest.xml @@ -33,13 +33,14 @@ + android:value="26.31" /> + diff --git a/ai-assistant/src/main/assets/docs/index.html b/ai-assistant/src/main/assets/docs/index.html index 6bef0c06..c17a60a8 100644 --- a/ai-assistant/src/main/assets/docs/index.html +++ b/ai-assistant/src/main/assets/docs/index.html @@ -64,6 +64,38 @@

What the agent can do

device. +

Attaching context files

+
    +
  • Tap the attach button beside the prompt to pick files whose + contents are sent with your next message.
  • +
  • The picker is rooted at the project you have open and cannot browse + above it. With no project open it declines to open at all.
  • +
  • Toggle All selects every file in the folder you are viewing; + Add Selected attaches them as chips above the prompt.
  • +
  • On the Gemini backend, attached file contents leave the device.
  • +
+ +

Your Gemini API key

+

The key is encrypted with AES/GCM under a hardware-backed Android Keystore + secret, and only the ciphertext is written to the plugin's private storage — + a copied preferences file is useless without this device's Keystore. Clear it + any time from Settings. If the Keystore entry is ever lost — clearing the + app's data, for example — the stored key can no longer be read and will need to + be entered again; tapping Edit tells you when that has happened.

+ +

Reading the conversation

+
    +
  • Retry appears on a message whose reply failed, and re-sends the + same prompt with the same attached files rather than adding a new one.
  • +
  • Open AI Settings appears instead when the agent has not been + configured yet — no local model chosen, or no Gemini key saved.
  • +
  • System log rows are collapsed records of the agent's internal + steps: tools run, files touched, backend errors. Tap the header to expand + one. They are saved with the session but are not sent to the model.
  • +
  • Long-press a message to copy its text, or to edit and re-send one of + your own.
  • +
+

Troubleshooting

  • "No model configured" — select a .gguf file (Local) diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/AiAssistantPlugin.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/AiAssistantPlugin.kt index 7138429b..813c7518 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/AiAssistantPlugin.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/AiAssistantPlugin.kt @@ -28,10 +28,25 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension { const val TOOLTIP_TAG_TAB = "agent_chat_tab" + // Tags for the interactive controls on the Agent chat screen (see ChatFragment). + const val TOOLTIP_TAG_CONTEXT_FILES = "agent_context_files" + const val TOOLTIP_TAG_CHAT_INPUT = "agent_chat_input" + const val TOOLTIP_TAG_CHAT_SEND = "agent_chat_send" + const val TOOLTIP_TAG_CHAT_MENU = "agent_chat_menu" + + // Tags for the controls rendered inside chat messages (see ChatAdapter). + const val TOOLTIP_TAG_MESSAGE_RETRY = "agent_message_retry" + const val TOOLTIP_TAG_MESSAGE_OPEN_SETTINGS = "agent_message_open_settings" + const val TOOLTIP_TAG_SYSTEM_LOG = "agent_system_log" + // Tags for the interactive controls on the AI Settings dialog (see AiSettingsFragment). + const val TOOLTIP_TAG_SETTINGS_BACK = "ai_settings_back" const val TOOLTIP_TAG_SETTINGS_BACKEND = "ai_settings_backend" const val TOOLTIP_TAG_SETTINGS_LOCAL_MODEL = "ai_settings_local_model" + const val TOOLTIP_TAG_SETTINGS_LOCAL_SHA = "ai_settings_local_model_sha" + const val TOOLTIP_TAG_SETTINGS_SIMPLE_PROMPT = "ai_settings_simple_prompt" const val TOOLTIP_TAG_SETTINGS_GEMINI_KEY = "ai_settings_gemini_key" + const val TOOLTIP_TAG_SETTINGS_GEMINI_MODEL = "ai_settings_gemini_model" @Volatile private var pluginContext: PluginContext? = null @@ -135,6 +150,126 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension { ) ) ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_CONTEXT_FILES, + summary = "Attach project files so the agent sees their contents with your next message.", + detail = """ +

    Opens a picker rooted at the currently open project; you + can browse subfolders but not above the project root, and the + picker won't open at all when no project is open.

    +

    Tap files to select them, Toggle All to select every file + in the folder you're viewing, then Add Selected. Attached + files appear as chips above the prompt — remove a chip to drop the + file again.

    +

    Contents are sent with your message, so on the Gemini backend + they leave the device.

    + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) + ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_CHAT_INPUT, + summary = "Type your request here — questions, or instructions to change the project.", + detail = """ +

    Ask a question ("what does this class do?") or give an + instruction ("add a Room dependency"). Plain read-only requests + such as open, read, list and search + are recognised directly and run without going through the model, + so they work on every backend.

    +

    Anything that writes to the project asks for your approval + first.

    + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) + ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_CHAT_SEND, + summary = "Send the prompt — turns into Stop while the agent is working.", + detail = """ +

    Sends your message to the selected backend. It stays disabled + until you type something.

    +

    While the agent is thinking or running tools this same button + becomes Stop: tapping it cancels the current turn, ends any + in-progress reply and discards the remaining tool steps.

    + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) + ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_CHAT_MENU, + summary = "Agent menu: open AI Settings or start a new chat session.", + detail = """ +

    Opens the Agent's overflow menu:

    +
      +
    • Settings — choose the backend (Local or Gemini), + pick a model and manage your Gemini API key.
    • +
    • Clear chat — starts a fresh session. The previous + conversation stays on disk in the plugin's own storage.
    • +
    + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) + ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_MESSAGE_RETRY, + summary = "Send that message again after a failed reply.", + detail = """ +

    Appears on a message whose reply failed — a dropped network + request, a model that wasn't loaded, or a turn you stopped.

    +

    Retry re-sends the same prompt with the same attached + context files; it does not add a new message to the conversation. + If it keeps failing, check the backend and model under + Settings.

    + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) + ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_MESSAGE_OPEN_SETTINGS, + summary = "Jump to AI Settings to fix the problem this message reports.", + detail = """ +

    Shown when the agent could not run because it is not configured + yet — no local model selected, or no Gemini API key saved.

    +

    Opens AI Settings so you can choose a backend, pick a + .gguf model or enter a key, then return and send your + message again.

    + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) + ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SYSTEM_LOG, + summary = "Collapsed system log — tap to expand the agent's internal steps.", + detail = """ +

    System entries record what the agent did behind the scenes: the + tools it ran, the files it touched and any errors the backend + reported.

    +

    They stay collapsed to keep the conversation readable — tap the + header to expand or collapse one. They are part of the saved + session, not messages sent to the model.

    + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) + ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SETTINGS_BACK, + summary = "Close AI Settings and return to the Agent chat.", + detail = """ +

    Closes this dialog. Every setting here is saved as you change + it, so there is nothing to confirm — the Agent picks up the new + backend and model as soon as you return.

    + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) + ) + ), PluginTooltipEntry( tag = TOOLTIP_TAG_SETTINGS_BACKEND, summary = "Choose which model powers the Agent: on-device Local (llama.cpp) or cloud Gemini.", @@ -166,14 +301,68 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension { PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) ) ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SETTINGS_LOCAL_SHA, + summary = "Optional SHA-256 of your .gguf file, checked when the model is loaded.", + detail = """ +

    Paste the expected SHA-256 hash of the model file. It is stored + with the model path and compared on load, so a truncated download + or a swapped file is reported instead of failing deep inside + llama.cpp.

    +

    Leave it empty to skip the check. The value is saved when the + field loses focus.

    + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) + ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SETTINGS_SIMPLE_PROMPT, + summary = "Send small local models a plainer prompt with no tool instructions.", + detail = """ +

    Small on-device models (roughly 1B parameters and under) tend to + ramble or echo the prompt when handed the full tool-calling system + prompt. With this on they get a short, plain instruction instead.

    +

    The trade-off: the model won't emit tool calls, so it answers + questions but won't edit your project. The direct + open/read/list/search commands still work either way. Turn + it off for a larger instruct model.

    + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) + ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SETTINGS_GEMINI_MODEL, + summary = "Pick which Gemini model to call; Refresh lists the ones your key can access.", + detail = """ +

    Refresh Models asks Google which models your API key can + actually use and fills the list from the response. Until then the + list shows a small built-in set of known-good defaults.

    +

    Selecting a model saves it immediately. If a previously saved + model has since been retired, refreshing moves you to the first + model in the live list rather than leaving a name that returns + 404.

    + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) + ) + ), PluginTooltipEntry( tag = TOOLTIP_TAG_SETTINGS_GEMINI_KEY, summary = "Enter your Google Gemini API key. It is stored only on this device.", detail = """

    Paste a Gemini API key to enable the cloud backend. The key is - kept in this plugin's private preferences on-device and is sent - only to Google's API over HTTPS. Requests (your prompts and - project context) leave the device when Gemini is selected.

    + encrypted with a key held in this device's hardware-backed Android + Keystore before it is written to this plugin's private preferences, + and is sent only to Google's API over HTTPS. Requests (your prompts + and project context) leave the device when Gemini is selected.

    +

    Use the eye button to check what you typed, Save to store + it, Edit to change it later and Clear to remove it + from the device.

    +

    If the Keystore entry is ever lost — clearing the app's data, + for instance — the stored key can no longer be decrypted and must + be re-entered here.

    """.trimIndent(), buttons = listOf( PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/adapters/ChatAdapter.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/adapters/ChatAdapter.kt index fd94569e..4aa61e8a 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/adapters/ChatAdapter.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/adapters/ChatAdapter.kt @@ -16,6 +16,7 @@ import android.widget.Toast import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView +import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin import com.itsaky.androidide.plugins.aiassistant.R import com.itsaky.androidide.plugins.aiassistant.models.ChatMessage import com.itsaky.androidide.plugins.aiassistant.models.MessageStatus @@ -26,8 +27,14 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +/** + * @param wireTooltip attaches this plugin's long-press help for a tag to a view. Supplied by + * ChatFragment, which owns the [com.itsaky.androidide.plugins.services.IdeTooltipService] + * lookup, so the adapter stays free of service plumbing. Defaults to a no-op for tests. + */ class ChatAdapter( private val markwon: Markwon, + private val wireTooltip: (View, String) -> Unit = { _, _ -> }, private val onMessageAction: (action: String, message: ChatMessage) -> Unit ) : ListAdapter(DiffCallback) { @@ -55,6 +62,13 @@ class ChatAdapter( val generatingDots: TextView = view.findViewById(R.id.generating_dots) val messageDuration: TextView = view.findViewById(R.id.message_duration) val btnRetry: Button = view.findViewById(R.id.btn_retry) + + /** + * Queued next step of the "..." animation, or null when it isn't running. Retained so + * [ChatAdapter.hideGeneratingDots] can cancel it: a Runnable left on the main looper + * would keep this holder, its views and their Context reachable after the row is gone. + */ + var generatingDotsStep: Runnable? = null } class SystemMessageViewHolder(view: View) : MessageViewHolder(view) { @@ -119,7 +133,7 @@ class ChatAdapter( MessageStatus.LOADING -> { holder.loadingIndicator.visibility = View.VISIBLE holder.messageContent.visibility = View.GONE - holder.generatingDots.visibility = View.GONE + hideGeneratingDots(holder) } MessageStatus.SENT -> { holder.loadingIndicator.visibility = View.GONE @@ -130,19 +144,19 @@ class ChatAdapter( if (message.sender == Sender.AGENT && message.durationMs == null) { animateGeneratingDots(holder) } else { - holder.generatingDots.visibility = View.GONE + hideGeneratingDots(holder) } } MessageStatus.COMPLETED -> { holder.loadingIndicator.visibility = View.GONE holder.messageContent.visibility = View.VISIBLE - holder.generatingDots.visibility = View.GONE + hideGeneratingDots(holder) markwon.setMarkdown(holder.messageContent, payload.text) } MessageStatus.ERROR -> { holder.loadingIndicator.visibility = View.GONE holder.messageContent.visibility = View.VISIBLE - holder.generatingDots.visibility = View.GONE + hideGeneratingDots(holder) holder.messageContent.text = payload.text } } @@ -173,6 +187,8 @@ class ChatAdapter( holder.messageContent.visibility = View.GONE holder.btnRetry.visibility = View.GONE holder.messageMetadataContainer.visibility = View.GONE + // A row that goes back to LOADING after SENT still had a live dots loop. + hideGeneratingDots(holder) } MessageStatus.SENT -> { holder.loadingIndicator.visibility = View.GONE @@ -185,14 +201,14 @@ class ChatAdapter( if (message.sender == Sender.AGENT && message.durationMs == null) { animateGeneratingDots(holder) } else { - holder.generatingDots.visibility = View.GONE + hideGeneratingDots(holder) } } MessageStatus.COMPLETED -> { holder.loadingIndicator.visibility = View.GONE holder.messageContent.visibility = View.VISIBLE holder.btnRetry.visibility = View.GONE - holder.generatingDots.visibility = View.GONE + hideGeneratingDots(holder) markwon.setMarkdown(holder.messageContent, message.text) updateMessageMetadata(holder, message) } @@ -200,18 +216,22 @@ class ChatAdapter( holder.loadingIndicator.visibility = View.GONE holder.messageContent.visibility = View.VISIBLE holder.btnRetry.visibility = View.VISIBLE - holder.generatingDots.visibility = View.GONE + hideGeneratingDots(holder) holder.messageContent.text = message.text if (message.sender == Sender.SYSTEM) { holder.btnRetry.text = "Open AI Settings" holder.btnRetry.setOnClickListener { onMessageAction(ACTION_OPEN_SETTINGS, message) } + // Re-wired per bind: the same recycled button plays both roles, so the + // tag has to follow the role it currently has. + wireTooltip(holder.btnRetry, AiAssistantPlugin.TOOLTIP_TAG_MESSAGE_OPEN_SETTINGS) } else { holder.btnRetry.text = "Retry" holder.btnRetry.setOnClickListener { onMessageAction(ACTION_RETRY, message) } + wireTooltip(holder.btnRetry, AiAssistantPlugin.TOOLTIP_TAG_MESSAGE_RETRY) } updateMessageMetadata(holder, message) } @@ -231,6 +251,7 @@ class ChatAdapter( notifyItemChanged(pos) } } + wireTooltip(holder.messageHeader, AiAssistantPlugin.TOOLTIP_TAG_SYSTEM_LOG) } private fun updateSystemMessageExpansion(holder: SystemMessageViewHolder, message: ChatMessage) { @@ -246,22 +267,50 @@ class ChatAdapter( } } + /** + * Starts — or restarts — the "..." animation, cancelling any step already queued for [holder] + * so repeated binds of one recycled row cannot stack loops. The step is posted on the dots + * view, not a bare main-looper Handler, so [hideGeneratingDots] can cancel it. + * + * @param holder the row whose dots should animate + */ private fun animateGeneratingDots(holder: DefaultMessageViewHolder) { + hideGeneratingDots(holder) holder.generatingDots.visibility = View.VISIBLE val dotStates = arrayOf(".", "..", "...") var currentIndex = 0 - val handler = android.os.Handler(android.os.Looper.getMainLooper()) - val runnable = object : Runnable { + val step = object : Runnable { override fun run() { - if (holder.generatingDots.visibility == View.VISIBLE) { - holder.generatingDots.text = dotStates[currentIndex] - currentIndex = (currentIndex + 1) % dotStates.size - handler.postDelayed(this, 500) + if (holder.generatingDots.visibility != View.VISIBLE) { + holder.generatingDotsStep = null + return } + holder.generatingDots.text = dotStates[currentIndex] + currentIndex = (currentIndex + 1) % dotStates.size + holder.generatingDots.postDelayed(this, 500) } } - handler.post(runnable) + holder.generatingDotsStep = step + holder.generatingDots.post(step) + } + + /** + * Hides the dots and cancels the animation. Visibility alone is not enough: the running step + * only notices it on its next tick, and never at all once the view is detached. + * + * @param holder the row whose dots should stop + */ + private fun hideGeneratingDots(holder: DefaultMessageViewHolder) { + holder.generatingDotsStep?.let { holder.generatingDots.removeCallbacks(it) } + holder.generatingDotsStep = null + holder.generatingDots.visibility = View.GONE + } + + /** Stops the dots animation of a row leaving the screen, so its step can't outlive the view. */ + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + super.onViewRecycled(holder) + if (holder is DefaultMessageViewHolder) hideGeneratingDots(holder) } private fun createPreview(rawText: String): String { diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt index 83c285c1..145deb02 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt @@ -5,13 +5,17 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle +import android.text.method.HideReturnsTransformationMethod +import android.text.method.PasswordTransformationMethod import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.WindowManager import android.widget.* import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import com.itsaky.androidide.plugins.PluginContext import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin import com.itsaky.androidide.plugins.aiassistant.R @@ -21,6 +25,7 @@ import com.itsaky.androidide.plugins.aiassistant.viewmodel.AiBackend import com.itsaky.androidide.plugins.aiassistant.viewmodel.AiSettingsViewModel import com.itsaky.androidide.plugins.aiassistant.viewmodel.EngineState import com.itsaky.androidide.plugins.aiassistant.viewmodel.ModelLoadingState +import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -52,6 +57,8 @@ class AiSettingsFragment : DialogFragment() { ?.get(IdeTooltipService::class.java) } catch (e: Exception) { // Tooltip help is optional; long-press simply shows nothing when it's unavailable. + AiAssistantPlugin.getContext()?.logger + ?.warn("AiSettingsFragment: tooltip service unavailable", e) } } @@ -141,6 +148,7 @@ class AiSettingsFragment : DialogFragment() { // Close the dialog dismiss() } + wireTooltip(backButton, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_BACK) } private fun setupBackendSelector() { @@ -212,6 +220,8 @@ class AiSettingsFragment : DialogFragment() { viewModel.loadModelFromUri(savedPath, requireContext()) } } + // Same concept as Browse — choosing which local model to run. + wireTooltip(loadSavedButton, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_LOCAL_MODEL) shaInput?.apply { setText(viewModel.getLocalModelSha256().orEmpty()) @@ -221,12 +231,16 @@ class AiSettingsFragment : DialogFragment() { } } } + // On the labelled wrapper, not the field: long-press there is the paste menu. + view.findViewById(R.id.local_model_sha_layout) + ?.let { wireTooltip(it, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_LOCAL_SHA) } simplePromptCheckbox?.apply { isChecked = viewModel.isUseSimpleLocalPromptEnabled() setOnCheckedChangeListener { _, isChecked -> viewModel.setUseSimpleLocalPrompt(isChecked) } + wireTooltip(this, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_SIMPLE_PROMPT) } // Observe engine state @@ -290,14 +304,15 @@ class AiSettingsFragment : DialogFragment() { private fun setupGeminiApiUi(view: View) { val apiKeyLayout = view.findViewById(R.id.gemini_api_key_layout) val apiKeyInput = view.findViewById(R.id.gemini_api_key_input) + val toggleVisibilityButton = view.findViewById(R.id.btn_toggle_api_key_visibility) val saveButton = view.findViewById