Skip to content
Open
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
8 changes: 6 additions & 2 deletions ai-assistant/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ android {
}
}

testOptions {
unitTests.isReturnDefaultValues = true
}

packaging {
resources {
excludes += setOf(
Expand Down Expand Up @@ -77,10 +81,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 @@ -61,6 +63,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 @@ -69,6 +81,7 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension {

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

Expand All @@ -79,6 +92,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 @@ -10,9 +10,9 @@ import android.widget.Button
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.PopupMenu
import com.google.android.material.snackbar.Snackbar
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 Down Expand Up @@ -55,6 +55,10 @@ 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)

/** The "…" animation loop for this holder, so it can be cancelled (not left re-posting forever). */
val dotsHandler = android.os.Handler(android.os.Looper.getMainLooper())
var dotsRunnable: Runnable? = null
}

class SystemMessageViewHolder(view: View) : MessageViewHolder(view) {
Expand Down Expand Up @@ -119,7 +123,7 @@ class ChatAdapter(
MessageStatus.LOADING -> {
holder.loadingIndicator.visibility = View.VISIBLE
holder.messageContent.visibility = View.GONE
holder.generatingDots.visibility = View.GONE
stopGeneratingDots(holder)
}
MessageStatus.SENT -> {
holder.loadingIndicator.visibility = View.GONE
Expand All @@ -130,19 +134,19 @@ class ChatAdapter(
if (message.sender == Sender.AGENT && message.durationMs == null) {
animateGeneratingDots(holder)
} else {
holder.generatingDots.visibility = View.GONE
stopGeneratingDots(holder)
}
}
MessageStatus.COMPLETED -> {
holder.loadingIndicator.visibility = View.GONE
holder.messageContent.visibility = View.VISIBLE
holder.generatingDots.visibility = View.GONE
stopGeneratingDots(holder)
markwon.setMarkdown(holder.messageContent, payload.text)
}
MessageStatus.ERROR -> {
holder.loadingIndicator.visibility = View.GONE
holder.messageContent.visibility = View.VISIBLE
holder.generatingDots.visibility = View.GONE
stopGeneratingDots(holder)
holder.messageContent.text = payload.text
}
}
Expand Down Expand Up @@ -185,30 +189,30 @@ class ChatAdapter(
if (message.sender == Sender.AGENT && message.durationMs == null) {
animateGeneratingDots(holder)
} else {
holder.generatingDots.visibility = View.GONE
stopGeneratingDots(holder)
}
}
MessageStatus.COMPLETED -> {
holder.loadingIndicator.visibility = View.GONE
holder.messageContent.visibility = View.VISIBLE
holder.btnRetry.visibility = View.GONE
holder.generatingDots.visibility = View.GONE
stopGeneratingDots(holder)
markwon.setMarkdown(holder.messageContent, message.text)
updateMessageMetadata(holder, message)
}
MessageStatus.ERROR -> {
holder.loadingIndicator.visibility = View.GONE
holder.messageContent.visibility = View.VISIBLE
holder.btnRetry.visibility = View.VISIBLE
holder.generatingDots.visibility = View.GONE
stopGeneratingDots(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)
}
} 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 @@ -236,7 +240,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 @@ -247,21 +251,36 @@ class ChatAdapter(
}

private fun animateGeneratingDots(holder: DefaultMessageViewHolder) {
// Already animating: don't restart, or each streaming-token rebind would
// reset the loop to "." and churn a new Runnable. Torn down on complete/recycle.
if (holder.dotsRunnable != null) return
holder.generatingDots.visibility = View.VISIBLE
val dotStates = arrayOf(".", "..", "...")
var currentIndex = 0

val handler = android.os.Handler(android.os.Looper.getMainLooper())
val runnable = 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)
}
holder.generatingDots.text = dotStates[currentIndex]
currentIndex = (currentIndex + 1) % dotStates.size
holder.dotsHandler.postDelayed(this, 500)
}
}
handler.post(runnable)
holder.dotsRunnable = runnable
holder.dotsHandler.post(runnable)
}

/** Stop and clear this holder's "…" animation. Safe to call repeatedly. */
private fun stopGeneratingDots(holder: DefaultMessageViewHolder) {
holder.dotsRunnable?.let { holder.dotsHandler.removeCallbacks(it) }
holder.dotsRunnable = null
holder.generatingDots.apply { visibility = View.GONE }
}

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

private fun createPreview(rawText: String): String {
Expand Down Expand Up @@ -334,7 +353,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 @@ -73,9 +73,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 @@ -233,12 +233,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 @@ -257,7 +257,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 @@ -268,19 +268,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 @@ -328,25 +328,25 @@ class AiSettingsFragment : DialogFragment() {
if (timestamp > 0) {
val sdf = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
val savedDate = sdf.format(Date(timestamp))
statusTextView.text = "API Key saved on: $savedDate"
statusTextView.text = getString(R.string.api_key_saved_on, savedDate)
} else {
statusTextView.text = "API Key is saved"
statusTextView.text = getString(R.string.api_key_saved)
}
}

saveButton.setOnClickListener {
val apiKey = apiKeyInput.text.toString()
if (apiKey.isNotBlank()) {
viewModel.saveGeminiApiKey(apiKey)
Toast.makeText(requireContext(), "API Key saved", Toast.LENGTH_SHORT).show()
Toast.makeText(requireContext(), getString(R.string.api_key_saved_toast), Toast.LENGTH_SHORT).show()

updateUiState(isEditing = false)
val timestamp = viewModel.getGeminiApiKeySaveTimestamp()
val sdf = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
val savedDate = sdf.format(Date(timestamp))
statusTextView.text = "API Key saved on: $savedDate"
statusTextView.text = getString(R.string.api_key_saved_on, savedDate)
} else {
Toast.makeText(requireContext(), "API Key cannot be empty", Toast.LENGTH_SHORT).show()
Toast.makeText(requireContext(), getString(R.string.api_key_empty), Toast.LENGTH_SHORT).show()
}
}

Expand All @@ -358,7 +358,7 @@ class AiSettingsFragment : DialogFragment() {

clearButton.setOnClickListener {
viewModel.clearGeminiApiKey()
Toast.makeText(requireContext(), "API Key cleared", Toast.LENGTH_SHORT).show()
Toast.makeText(requireContext(), getString(R.string.api_key_cleared), Toast.LENGTH_SHORT).show()
updateUiState(isEditing = true)
apiKeyInput.setText("")
}
Expand Down Expand Up @@ -462,7 +462,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 @@ -478,8 +478,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
Loading