Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ai-assistant/ai-assistant.html
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ <h2>2. Core Functionality</h2>
and generate code from templates.</li>
<li><b>Dual inference backends</b> — fully offline on-device inference, or
Gemini in the cloud, selectable in Settings.</li>
<li><b>Direct commands</b> — explicit requests like "open MainActivity.java",
"list files", "read &lt;file&gt;" or "search &lt;query&gt;" run the tool
directly (resolving a bare filename to its path), so they work reliably on
any model.</li>
<li><b>Safety controls</b> — filesystem tools are confined to the project
root, and mutating tools require explicit user approval.</li>
</ul>
Expand Down
9 changes: 7 additions & 2 deletions ai-assistant/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ android {

buildFeatures {
viewBinding = true
buildConfig = true
}

buildTypes {
Expand All @@ -45,6 +46,10 @@ android {
}
}

testOptions {
unitTests.isReturnDefaultValues = true
}

packaging {
resources {
excludes += setOf(
Expand Down Expand Up @@ -77,10 +82,10 @@ dependencies {
// JSON serialization for session persistence
implementation("com.google.code.gson:gson:2.10.1")

// Plugin dependencies are loaded at runtime by the plugin manager
// No explicit compile-time dependency on the ai-core plugin needed
testImplementation(files("../libs/plugin-api.jar"))
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.8")
testImplementation("org.json:json:20231013")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("androidx.arch.core:core-testing:2.2.0")
}
3 changes: 1 addition & 2 deletions ai-assistant/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.itsaky.androidide.plugins.aiassistant">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application
android:label="AI Assistant Plugin"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import com.itsaky.androidide.plugins.extensions.MenuItem
import com.itsaky.androidide.plugins.extensions.PluginTooltipButton
import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry
import com.itsaky.androidide.plugins.extensions.TabItem
import com.itsaky.androidide.plugins.services.IdeProjectService
import com.itsaky.androidide.plugins.services.LlmInferenceService
import com.itsaky.androidide.plugins.services.SharedServices
import com.itsaky.androidide.plugins.aiassistant.fragments.ChatFragment
import com.itsaky.androidide.plugins.aiassistant.tool.handlers.PathGuard
import java.io.File

class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension {
Expand Down Expand Up @@ -76,6 +78,16 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension {
context.logger.info("LlmInferenceService available from SharedServices")
}

PathGuard.setProjectRootProvider {
try {
context.services.get(IdeProjectService::class.java)
?.getCurrentProject()?.rootDir?.absolutePath
} catch (e: Exception) {
context.logger.warn("Could not resolve project root from IdeProjectService", e)
null
}
}

// Migrate chat history and settings on first activation
migrateDataIfNeeded()

Expand All @@ -84,6 +96,7 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension {

override fun deactivate(): Boolean {
context.logger.info("AI Assistant Plugin deactivating...")
PathGuard.setProjectRootProvider(null)
return true
}

Expand All @@ -94,6 +107,7 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension {
// PluginContext (and everything it holds) can be garbage-collected when
// the plugin is unloaded.
SharedServices.unregister(PluginContext::class.java)
PathGuard.setProjectRootProvider(null)
pluginContext = null
llmService = null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import android.widget.LinearLayout
import android.widget.PopupMenu
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
Expand All @@ -21,6 +20,7 @@ import com.itsaky.androidide.plugins.aiassistant.R
import com.itsaky.androidide.plugins.aiassistant.models.ChatMessage
import com.itsaky.androidide.plugins.aiassistant.models.MessageStatus
import com.itsaky.androidide.plugins.aiassistant.models.Sender
import com.google.android.material.snackbar.Snackbar
import io.noties.markwon.Markwon
import java.text.DecimalFormat
import java.text.SimpleDateFormat
Expand All @@ -41,6 +41,7 @@ class ChatAdapter(
private val timeFormatter = SimpleDateFormat("h:mm a", Locale.getDefault())
private val decimalSecondsFormatter = DecimalFormat("0.0")
private val expandedMessageIds = mutableSetOf<String>()
private val animatingHolders = mutableSetOf<DefaultMessageViewHolder>()

companion object {
private const val VIEW_TYPE_DEFAULT = 0
Expand Down Expand Up @@ -219,15 +220,15 @@ class ChatAdapter(
hideGeneratingDots(holder)
holder.messageContent.text = message.text
if (message.sender == Sender.SYSTEM) {
holder.btnRetry.text = "Open AI Settings"
holder.btnRetry.text = holder.btnRetry.context.getString(R.string.action_open_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.text = holder.btnRetry.context.getString(R.string.action_retry)
holder.btnRetry.setOnClickListener {
onMessageAction(ACTION_RETRY, message)
}
Expand Down Expand Up @@ -257,7 +258,7 @@ class ChatAdapter(
private fun updateSystemMessageExpansion(holder: SystemMessageViewHolder, message: ChatMessage) {
val isExpanded = expandedMessageIds.contains(message.id)
if (isExpanded) {
holder.messageHeaderTitle.text = "System Log"
holder.messageHeaderTitle.text = holder.messageHeaderTitle.context.getString(R.string.system_log)
holder.messageContent.visibility = View.VISIBLE
holder.expandIcon.rotation = 180f
} else {
Expand All @@ -268,14 +269,14 @@ 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.
* Starts the "..." animation, or leaves an already-running one alone: restarting on every
* streaming rebind would reset the loop to "." and it would never visibly advance. 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)
if (holder.generatingDotsStep != null) return
holder.generatingDots.visibility = View.VISIBLE
val dotStates = arrayOf(".", "..", "...")
var currentIndex = 0
Expand All @@ -292,6 +293,7 @@ class ChatAdapter(
}
}
holder.generatingDotsStep = step
animatingHolders.add(holder)
holder.generatingDots.post(step)
}

Expand All @@ -304,15 +306,29 @@ class ChatAdapter(
private fun hideGeneratingDots(holder: DefaultMessageViewHolder) {
holder.generatingDotsStep?.let { holder.generatingDots.removeCallbacks(it) }
holder.generatingDotsStep = null
animatingHolders.remove(holder)
holder.generatingDots.visibility = View.GONE
}

/** Stops the dots animation of a row leaving the screen, so its step can't outlive the view. */
/**
* Stop every live "…" animation. Call from the host fragment's `onDestroyView`:
* a message still streaming when the tab closes never reaches a terminal status
* and its holder is never recycled, so nothing else cancels its Runnable.
*/
fun stopAllAnimations() {
animatingHolders.toList().forEach { hideGeneratingDots(it) }
}

override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
super.onViewRecycled(holder)
if (holder is DefaultMessageViewHolder) hideGeneratingDots(holder)
}

override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
super.onDetachedFromRecyclerView(recyclerView)
stopAllAnimations()
}

private fun createPreview(rawText: String): String {
val cleanedText = rawText
.replace(Regex("`{1,3}|\\*{1,2}|_"), "")
Expand Down Expand Up @@ -383,7 +399,7 @@ class ChatAdapter(
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("chat_message", message.text)
clipboard.setPrimaryClip(clip)
Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
Snackbar.make(view, view.context.getString(R.string.msg_copied), Snackbar.LENGTH_SHORT).show()
true
}
2 -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ class AiSettingsFragment : DialogFragment() {

val uriString = it.toString()
viewModel.loadModelFromUri(uriString, requireContext())
Toast.makeText(requireContext(), "Loading model...", Toast.LENGTH_SHORT).show()
Toast.makeText(requireContext(), getString(R.string.model_loading_toast), Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(requireContext(), "Error: ${e.message}", Toast.LENGTH_LONG).show()
Toast.makeText(requireContext(), getString(R.string.state_error, e.message), Toast.LENGTH_LONG).show()
}
}
}
Expand Down Expand Up @@ -247,12 +247,12 @@ class AiSettingsFragment : DialogFragment() {
viewModel.engineState.observe(viewLifecycleOwner) { state ->
when (state) {
is EngineState.Initializing, EngineState.Uninitialized -> {
engineStatusTextView.text = "Initializing engine..."
engineStatusTextView.text = getString(R.string.engine_initializing)
browseButton.isEnabled = false
loadSavedButton.isEnabled = false
}
is EngineState.Initialized -> {
engineStatusTextView.text = "Engine ready"
engineStatusTextView.text = getString(R.string.engine_ready)
browseButton.isEnabled = true
loadSavedButton.isEnabled = viewModel.savedModelPath.value != null
}
Expand All @@ -271,7 +271,7 @@ class AiSettingsFragment : DialogFragment() {
if (path != null) {
modelPathTextView.visibility = View.VISIBLE
val fileName = viewModel.getSavedModelName() ?: viewModel.fallbackDisplayName(path)
modelPathTextView.text = "Saved: $fileName"
modelPathTextView.text = getString(R.string.model_saved_path, fileName)
} else {
modelPathTextView.visibility = View.GONE
}
Expand All @@ -282,19 +282,19 @@ class AiSettingsFragment : DialogFragment() {
when (state) {
is ModelLoadingState.Idle -> {
modelStatusTextView.visibility = View.VISIBLE
modelStatusTextView.text = "No model is currently loaded"
modelStatusTextView.text = getString(R.string.model_none_loaded)
}
is ModelLoadingState.Loading -> {
modelStatusTextView.visibility = View.VISIBLE
modelStatusTextView.text = "Loading model, please wait..."
modelStatusTextView.text = getString(R.string.model_loading_wait)
}
is ModelLoadingState.Loaded -> {
modelStatusTextView.visibility = View.VISIBLE
modelStatusTextView.text = "✅ Model loaded: ${state.modelName}"
modelStatusTextView.text = getString(R.string.model_loaded, state.modelName)
}
is ModelLoadingState.Error -> {
modelStatusTextView.visibility = View.VISIBLE
modelStatusTextView.text = "❌ Error: ${state.message}"
modelStatusTextView.text = getString(R.string.model_load_error, state.message)
}
}
}
Expand Down Expand Up @@ -574,7 +574,7 @@ class AiSettingsFragment : DialogFragment() {
// Observe loading state
viewModel.geminiModelsLoading.observe(viewLifecycleOwner) { isLoading ->
refreshButton.isEnabled = !isLoading
refreshButton.text = if (isLoading) "Loading..." else "Refresh Models"
refreshButton.text = if (isLoading) getString(R.string.loading) else getString(R.string.refresh_models)
}

modelSpinner.setOnTouchListener { _, _ ->
Expand All @@ -590,8 +590,8 @@ class AiSettingsFragment : DialogFragment() {
val selectedModel = parent?.getItemAtPosition(position) as? String
if (selectedModel != null && selectedModel != viewModel.getGeminiModel()) {
viewModel.saveGeminiModel(selectedModel)
currentModelText?.text = "Current: $selectedModel"
Toast.makeText(requireContext(), "Model changed to $selectedModel", Toast.LENGTH_SHORT).show()
currentModelText?.text = getString(R.string.current_model, selectedModel)
Toast.makeText(requireContext(), getString(R.string.model_changed, selectedModel), Toast.LENGTH_SHORT).show()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar
import com.itsaky.androidide.plugins.PluginContext
import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin
import com.itsaky.androidide.plugins.aiassistant.BuildConfig
import com.itsaky.androidide.plugins.aiassistant.R
import com.itsaky.androidide.plugins.aiassistant.adapters.ChatAdapter
import com.itsaky.androidide.plugins.aiassistant.databinding.FragmentChatBinding
Expand Down Expand Up @@ -61,21 +62,6 @@ class ChatFragment : Fragment() {
}
}

companion object {
// Test prompt injection (for E2E testing via broadcast receiver)
@Volatile
private var pendingTestPrompt: String? = null

fun injectTestPrompt(prompt: String) {
pendingTestPrompt = prompt
}

fun getPendingTestPrompt(): String? {
return pendingTestPrompt?.also { pendingTestPrompt = null }
}
}


/**
* Route inflation through the host so the plugin's views resolve against a Context whose
* Configuration tracks the IDE's day/night setting — this is what lets values-night/ colors
Expand All @@ -99,6 +85,9 @@ class ChatFragment : Fragment() {
}

override fun onDestroyView() {
if (::chatAdapter.isInitialized) {
chatAdapter.stopAllAnimations()
}
super.onDestroyView()
viewModel.stopProcessing()
_binding = null
Expand Down Expand Up @@ -136,8 +125,12 @@ class ChatFragment : Fragment() {
/**
* Check for test prompt from broadcast receiver and auto-send if present.
* Uses SharedPreferences set by TestBroadcastReceiver for reliable communication.
*
* Debug builds only. This path auto-drives the agent — which owns file-mutating
* tools — without any user gesture, so it must not exist in a released plugin.
*/
private fun injectPendingTestPrompt() {
if (!BuildConfig.DEBUG) return
try {
// Check SharedPreferences for pending test prompt (set by TestBroadcastReceiver)
val context = requireContext()
Expand Down Expand Up @@ -320,33 +313,33 @@ class ChatFragment : Fragment() {
is AgentState.Idle -> {
binding.agentStatusContainer.isVisible = false
binding.sendButton.isEnabled = true
binding.sendButton.text = "Send"
binding.sendButton.text = getString(R.string.send)
}
is AgentState.Executing -> {
binding.agentStatusContainer.isVisible = true
binding.agentStatusMessage.text = state.formattedProgress
binding.agentStatusTimer.text = state.formattedTiming
binding.sendButton.isEnabled = true
binding.sendButton.text = "Stop"
binding.sendButton.text = getString(R.string.btn_stop)
viewModel.startStateTimer(state)
}
is AgentState.Processing -> {
binding.agentStatusContainer.isVisible = true
binding.agentStatusMessage.text = "Generating response..."
binding.agentStatusMessage.text = getString(R.string.generating_response)
binding.agentStatusTimer.text = ""
binding.sendButton.isEnabled = true
binding.sendButton.text = "Stop"
binding.sendButton.text = getString(R.string.btn_stop)
}
is AgentState.Error -> {
binding.agentStatusContainer.isVisible = false
binding.sendButton.isEnabled = true
binding.sendButton.text = "Send"
binding.sendButton.text = getString(R.string.send)
viewModel.stopStateTimer()
showErrorSnackbar(state.message)
}
else -> {
binding.sendButton.isEnabled = false
binding.sendButton.text = "Send"
binding.sendButton.text = getString(R.string.send)
}
}
}
Expand Down
Loading