diff --git a/.gitignore b/.gitignore
index 18138a95..a518bfca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
**/*.iml
**/.DS_Store
**/*.keystore
+**/*.cgp
**/release.properties
**/RecentProject_database*
**/agent/
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..ad9dd4f3
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,4 @@
+[submodule "ai-core/subprojects/llama.cpp"]
+ path = ai-core/subprojects/llama.cpp
+ url = https://github.com/appdevforall/llama.cpp.git
+ branch = androidide-custom
diff --git a/README.md b/README.md
index ce14550d..99b5b0ea 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,8 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego
| [`compose-preview/`](compose-preview/) | Renders Jetpack Compose `@Preview` functions on-device — no full app build or run. |
| [`ai-literacy-course/`](ai-literacy-course/) | Bundles Learn AI Anywhere's offline "Introduction to AI" course (26 videos + interactive activities) and plays it full-screen, fully offline. |
| [`layout-editor/`](layout-editor/) | Visual drag-and-drop editor for Android XML layouts. |
+| [`ai-core/`](ai-core/) | Shared on-device LLM inference backend (bundled llama.cpp AAR) plus a Gemini API backend, exposed to other plugins as a runtime service. |
+| [`ai-assistant/`](ai-assistant/) | In-IDE AI chat assistant with tool calling; talks to `ai-core` for inference over local or Gemini models. |
## Building a plugin
diff --git a/ai-assistant/.gitignore b/ai-assistant/.gitignore
new file mode 100644
index 00000000..5380c6d5
--- /dev/null
+++ b/ai-assistant/.gitignore
@@ -0,0 +1,3 @@
+**/.cxx/
+build-output.log
+**/.kotlin/
diff --git a/ai-assistant/README.md b/ai-assistant/README.md
new file mode 100644
index 00000000..54a729ef
--- /dev/null
+++ b/ai-assistant/README.md
@@ -0,0 +1,61 @@
+# AI Assistant plugin for CodeOnTheGo
+
+An in-IDE AI chat assistant with tool calling. It provides the chat UI, settings,
+and the agent tool-loop (read/write files, list/search the project, add
+dependencies, run Gradle sync, etc.), and delegates all model inference to the
+sibling [`ai-core`](../ai-core/) plugin.
+
+> This is the **frontend** plugin. It has **no compile-time dependency** on
+> `ai-core`; the two communicate at runtime through the CodeOnTheGo plugin
+> manager (SharedServices). Install **`ai-core` first**, then `ai-assistant`.
+
+## Architecture
+
+```
+┌──────────────────────────┐
+│ ai-assistant (this) │ ← Chat UI, settings, agent tool-loop
+└────────────┬─────────────┘
+ │ SharedServices (runtime)
+ ▼
+┌──────────────────────────┐
+│ ai-core │ ← LLM inference: local (llama.cpp) + Gemini
+└──────────────────────────┘
+```
+
+## Features
+
+- Chat UI (RecyclerView + Markdown rendering via Markwon)
+- Agent tool-loop with per-tool approval dialogs
+- Session persistence
+- Works with either backend exposed by `ai-core` (on-device GGUF or Gemini API)
+
+## Building
+
+Prerequisites: Android SDK (API 33+), JDK 17. Create `local.properties` with
+`sdk.dir=...`. This plugin needs no NDK, submodule, or native toolchain.
+
+```bash
+cd ai-assistant
+./gradlew assemblePlugin # release -> build/plugin/ai-assistant.cgp
+./gradlew assemblePluginDebug # debug variant
+```
+
+The build resolves `plugin-api.jar` from the repo-root `../libs/`.
+
+## Installation
+
+1. Build and install **`ai-core` first** (see [`../ai-core/README.md`](../ai-core/README.md)).
+2. Build this plugin, copy `build/plugin/ai-assistant.cgp` to the device.
+3. Install via CodeOnTheGo's Plugin Manager, then restart the IDE.
+4. Open **AI Settings** to pick a local model or configure a Gemini API key.
+
+## Key classes
+
+- `AiAssistantPlugin.kt` — plugin entry point / lifecycle
+- `fragments/ChatFragment.kt`, `viewmodel/ChatViewModel.kt` — chat UI + state
+- `fragments/AiSettingsFragment.kt`, `viewmodel/AiSettingsViewModel.kt` — model/backend config
+- `tool/` — the agent tool-loop (executor, router, per-tool handlers, approval)
+
+## License
+
+GPL-3.0 — same as AndroidIDE / CodeOnTheGo.
diff --git a/ai-assistant/ai-assistant.html b/ai-assistant/ai-assistant.html
new file mode 100644
index 00000000..2d52facc
--- /dev/null
+++ b/ai-assistant/ai-assistant.html
@@ -0,0 +1,129 @@
+
+
+
+
+
+AI Assistant Plugin — Documentation
+
+
+
+ AI Assistant Plugin
+ An AI coding assistant for Code on the Go (CoGo), with
+ on-device LLM inference and an optional Google Gemini cloud backend.
+
+ 1. Executive Overview
+ The AI Assistant brings an interactive coding agent into the CoGo IDE. It
+ adds an Agent tab where you chat with a language model that can read,
+ search, and edit files in your project through an approval-gated tool loop.
+ It ships as two cooperating plugins :
+
+ AI Core — the inference engine. Provides an
+ LlmInferenceService with two backends: a local, on-device
+ model (llama.cpp) and Google Gemini. Install this first.
+ AI Assistant — the user interface. Contributes the Agent tab, the
+ editor context-menu actions, and the agent tool-loop that drives the
+ model.
+
+ The two plugins are bridged by a shared service registry
+ (SharedServices), so the UI plugin discovers the inference
+ service the core plugin registers at runtime.
+
+ 2. Core Functionality
+
+ Conversational agent — chat with streaming responses in the
+ Agent tab.
+ Project-aware tools — read files, search the project, create and
+ update files, trigger a Gradle sync, read build output, add dependencies,
+ and generate code from templates.
+ Dual inference backends — fully offline on-device inference, or
+ Gemini in the cloud, selectable in Settings.
+ Editor context actions — Explain Code and
+ Generate Code from the editor selection.
+ Safety controls — filesystem tools are confined to the project
+ root, and mutating tools require explicit user approval.
+
+
+ 3. Technical Architecture
+ ai-assistant (UI, agent tool-loop)
+ │ discovers via SharedServices
+ ▼
+ai-core (LlmInferenceService)
+ ├── LocalLlmBackend ──► llama-impl ──► llama.cpp (native, JNI)
+ └── GeminiBackend ──► Google GenAI SDK (HTTPS)
+
+ llama-impl / llama-api — Android modules wrapping the native
+ llama.cpp build via JNI; compiled from the
+ subprojects/llama.cpp git submodule.
+ Network — only AI Core holds the
+ network.access permission and INTERNET, because
+ only its Gemini backend performs network calls.
+ Lifecycle — on dispose, the native model is unloaded and all
+ background coroutine scopes are cancelled.
+
+
+ 4. Usage
+ Build
+ ai-core and ai-assistant are independent Gradle builds; a
+ normal build needs no submodule or NDK (the llama.cpp AAR ships prebuilt).
+ cd ai-core && ./gradlew assemblePlugin
+cd ../ai-assistant && ./gradlew assemblePlugin
+ Outputs land at ai-core/build/plugin/ai-core.cgp and
+ ai-assistant/build/plugin/ai-assistant.cgp. See
+ ../ai-core/BUILDING.md for full prerequisites.
+ Install & configure
+
+ Copy both .cgp files to the device and install via the CoGo
+ Plugin Manager — AI Core first , then AI Assistant. Restart the IDE.
+ Open Settings and either select a .gguf model (Local)
+ or enter a Gemini API key (Gemini).
+ Open the Agent tab and start chatting.
+
+
+ 5. Key Benefits
+
+ Benefit Why it matters
+ Offline capable On-device inference works with no network and keeps code private.
+ Cloud option Gemini backend offers higher-quality responses when connectivity is available.
+ Project-aware The agent operates on your actual files through IDE services, not copy-paste.
+ Safe by default Path containment plus approval prompts keep file edits scoped and consented.
+
+
+
+ Author: AndroidIDE / appdevforall. See README.md for developer
+ details and the in-IDE Agent-tab tooltip for quick help.
+
+
diff --git a/ai-assistant/build.gradle.kts b/ai-assistant/build.gradle.kts
new file mode 100644
index 00000000..b376da7c
--- /dev/null
+++ b/ai-assistant/build.gradle.kts
@@ -0,0 +1,86 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.itsaky.androidide.plugins.build")
+}
+
+pluginBuilder {
+ pluginName = "ai-assistant"
+}
+
+android {
+ namespace = "com.itsaky.androidide.plugins.aiassistant"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.itsaky.androidide.plugins.aiassistant"
+ minSdk = 33
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0.0"
+ }
+
+ buildFeatures {
+ viewBinding = true
+ }
+
+ buildTypes {
+ release {
+ // Disable minification to avoid lambda obfuscation issues with ClassLoader isolation
+ isMinifyEnabled = false
+ isShrinkResources = false
+ signingConfig = signingConfigs.getByName("debug")
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
+ }
+
+ packaging {
+ resources {
+ excludes += setOf(
+ "META-INF/DEPENDENCIES",
+ "META-INF/LICENSE",
+ "META-INF/LICENSE.txt",
+ "META-INF/NOTICE",
+ "META-INF/NOTICE.txt"
+ )
+ }
+ }
+}
+
+dependencies {
+ compileOnly(files("../libs/plugin-api.jar"))
+
+ // Use 'implementation' (not 'compileOnly') for androidx libraries.
+ // This is required for XML layouts: AAPT2 needs these dependencies at compile-time to process
+ // resource attributes and resolve xmlns declarations. This is standard across all CoGo plugins
+ // with XML layouts (random-xkcd, sketch-to-ui-plugin, Beepy). See investigation in Task 4.
+ implementation("androidx.appcompat:appcompat:1.6.1")
+ implementation("androidx.fragment:fragment-ktx:1.6.2")
+ implementation("com.google.android.material:material:1.10.0")
+ implementation("androidx.recyclerview:recyclerview:1.3.2")
+ implementation("androidx.constraintlayout:constraintlayout:2.1.4")
+
+ // Markdown rendering - plugin-specific library
+ implementation("io.noties.markwon:core:4.6.2")
+
+ // 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("junit:junit:4.13.2")
+ testImplementation("io.mockk:mockk:1.13.8")
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
+ testImplementation("androidx.arch.core:core-testing:2.2.0")
+}
diff --git a/ai-assistant/gradle.properties b/ai-assistant/gradle.properties
new file mode 100644
index 00000000..fcd58cda
--- /dev/null
+++ b/ai-assistant/gradle.properties
@@ -0,0 +1,10 @@
+android.enableJetifier=false
+android.jetifier.ignorelist=common-30.2.2.jar
+android.nonTransitiveRClass=false
+android.useAndroidX=true
+org.gradle.caching=true
+org.gradle.configureondemand=true
+org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M"
+org.gradle.parallel=true
+
+kotlin.code.style=official
diff --git a/ai-assistant/gradle/wrapper/gradle-wrapper.jar b/ai-assistant/gradle/wrapper/gradle-wrapper.jar
new file mode 100755
index 00000000..d64cd491
Binary files /dev/null and b/ai-assistant/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/ai-assistant/gradle/wrapper/gradle-wrapper.properties b/ai-assistant/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..692c2dc2
--- /dev/null
+++ b/ai-assistant/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-all.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
\ No newline at end of file
diff --git a/ai-assistant/gradlew b/ai-assistant/gradlew
new file mode 100755
index 00000000..1aa94a42
--- /dev/null
+++ b/ai-assistant/gradlew
@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/ai-assistant/gradlew.bat b/ai-assistant/gradlew.bat
new file mode 100755
index 00000000..93e3f59f
--- /dev/null
+++ b/ai-assistant/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/ai-assistant/proguard-rules.pro b/ai-assistant/proguard-rules.pro
new file mode 100644
index 00000000..f53bb79d
--- /dev/null
+++ b/ai-assistant/proguard-rules.pro
@@ -0,0 +1,53 @@
+# Keep plugin main class
+-keep class com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin {
+ public ();
+ public boolean initialize(com.itsaky.androidide.plugins.PluginContext);
+ public boolean activate();
+ public boolean deactivate();
+ public void dispose();
+ public java.util.List getEditorTabs();
+ public java.util.List getContextMenuItems(com.itsaky.androidide.plugins.extensions.ContextMenuContext);
+ public java.util.List getMainMenuItems();
+}
+
+# Keep Fragment classes
+-keep class com.itsaky.androidide.plugins.aiassistant.fragments.ChatFragment {
+ public ();
+}
+
+# Keep plugin API related classes
+-keep interface com.itsaky.androidide.plugins.IPlugin {
+ *;
+}
+
+-keep interface com.itsaky.androidide.plugins.extensions.UIExtension {
+ *;
+}
+
+-keep class com.itsaky.androidide.plugins.extensions.TabItem {
+ *;
+}
+
+-keep class com.itsaky.androidide.plugins.extensions.MenuItem {
+ *;
+}
+
+# Keep annotations
+-keepattributes *Annotation*
+-keepattributes Signature
+-keepattributes Exceptions
+
+# Keep Kotlin lambdas and function types - CRITICAL for TabItem.fragmentFactory
+-keep class kotlin.jvm.functions.** { *; }
+-keep class kotlin.jvm.internal.** { *; }
+-keepclassmembers class ** {
+ kotlin.jvm.functions.Function0 fragmentFactory;
+}
+
+# Don't obfuscate lambda implementations
+-keep class **$$Lambda$* { *; }
+
+# Keep synthetic methods (lambdas)
+-keepclassmembers class * {
+ synthetic ;
+}
diff --git a/ai-assistant/settings.gradle.kts b/ai-assistant/settings.gradle.kts
new file mode 100644
index 00000000..de2975f0
--- /dev/null
+++ b/ai-assistant/settings.gradle.kts
@@ -0,0 +1,35 @@
+@file:Suppress("UnstableApiUsage")
+
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
+
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ google()
+ mavenCentral()
+ }
+}
+
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath(files("../libs/plugin-api.jar"))
+ classpath(files("../libs/gradle-plugin.jar"))
+ classpath("com.android.tools.build:gradle:8.13.2")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.0")
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven { url = uri("https://jitpack.io") }
+ }
+}
+
+rootProject.name = "ai-assistant"
diff --git a/ai-assistant/src/main/AndroidManifest.xml b/ai-assistant/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..8b593826
--- /dev/null
+++ b/ai-assistant/src/main/AndroidManifest.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ai-assistant/src/main/assets/docs/index.html b/ai-assistant/src/main/assets/docs/index.html
new file mode 100644
index 00000000..fb09f85f
--- /dev/null
+++ b/ai-assistant/src/main/assets/docs/index.html
@@ -0,0 +1,80 @@
+
+
+
+
+
+AI Assistant — Guide
+
+
+
+ AI Assistant — Agent Guide
+
+ The Agent tab is a chat assistant that can read, search, and edit
+ your project through an approval-gated tool loop. Inference is served by the
+ companion AI Core plugin, so install AI Core first .
+
+ Choosing a backend
+
+ Local (on-device) — runs a .gguf model via
+ llama.cpp. Open Settings , pick a model file from your Downloads
+ folder. Nothing leaves the device.
+ Gemini (cloud) — enter a Gemini API key in Settings .
+ Prompts and any file contents the agent reads are sent to Google over
+ HTTPS.
+
+
+ What the agent can do
+
+ Read and search files within the current project .
+ Create and update files (asks for approval before writing).
+ Trigger a Gradle sync and read build output.
+ Add dependencies and generate code from templates.
+
+
+
+ File tools are confined to the project root: paths that resolve outside it
+ are rejected, so the model cannot read or overwrite arbitrary files on the
+ device.
+
+
+ Context-menu actions
+ Select code in the editor and open the context menu for
+ Explain Code and Generate Code shortcuts.
+
+ Troubleshooting
+
+ "No model configured" — select a .gguf file (Local)
+ or set an API key (Gemini) in Settings.
+ Gemini errors — check the API key, network connection, and quota.
+ Agent tab missing — confirm both AI Core and AI Assistant are
+ installed and the IDE was restarted.
+
+
+
diff --git a/ai-assistant/src/main/assets/icon_day.png b/ai-assistant/src/main/assets/icon_day.png
new file mode 100644
index 00000000..4cb1dc35
Binary files /dev/null and b/ai-assistant/src/main/assets/icon_day.png differ
diff --git a/ai-assistant/src/main/assets/icon_night.png b/ai-assistant/src/main/assets/icon_night.png
new file mode 100644
index 00000000..355a5c0e
Binary files /dev/null and b/ai-assistant/src/main/assets/icon_night.png differ
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
new file mode 100644
index 00000000..92cd5220
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/AiAssistantPlugin.kt
@@ -0,0 +1,243 @@
+package com.itsaky.androidide.plugins.aiassistant
+
+import com.itsaky.androidide.plugins.IPlugin
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.extensions.UIExtension
+import com.itsaky.androidide.plugins.extensions.ContextMenuContext
+import com.itsaky.androidide.plugins.extensions.DocumentationExtension
+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.LlmInferenceService
+import com.itsaky.androidide.plugins.services.SharedServices
+import com.itsaky.androidide.plugins.aiassistant.fragments.ChatFragment
+import java.io.File
+
+class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension {
+
+ private lateinit var context: PluginContext
+ private var llmService: LlmInferenceService? = null
+
+ companion object {
+ const val TOOLTIP_TAG_TAB = "agent_chat_tab"
+
+ @Volatile
+ private var pluginContext: PluginContext? = null
+
+ fun getContext(): PluginContext? = pluginContext
+ }
+
+ override fun initialize(context: PluginContext): Boolean {
+ this.context = context
+ pluginContext = context // Store for ChatFragment access
+
+ // Also store in SharedServices so ai-core can access preferences
+ SharedServices.register(PluginContext::class.java, context)
+
+ context.logger.info("AI Assistant Plugin initializing...")
+ return true
+ }
+
+ override fun activate(): Boolean {
+ // Get LlmInferenceService from SharedServices
+ llmService = SharedServices.get(LlmInferenceService::class.java)
+
+ if (llmService == null) {
+ context.logger.warn("LlmInferenceService not available - LOCAL_LLM backend disabled")
+ context.logger.warn("Install AI Core plugin to enable local LLM support")
+ } else {
+ context.logger.info("LlmInferenceService available from SharedServices")
+ }
+
+ // Migrate chat history and settings on first activation
+ migrateDataIfNeeded()
+
+ return true
+ }
+
+ override fun deactivate(): Boolean {
+ context.logger.info("AI Assistant Plugin deactivating...")
+ return true
+ }
+
+ override fun dispose() {
+ context.logger.info("AI Assistant Plugin disposing...")
+
+ // Release the shared references set up in initialize() so the plugin's
+ // PluginContext (and everything it holds) can be garbage-collected when
+ // the plugin is unloaded.
+ SharedServices.unregister(PluginContext::class.java)
+ pluginContext = null
+ llmService = null
+ }
+
+ // Register Agent tab
+ override fun getEditorTabs(): List {
+ return listOf(
+ TabItem(
+ id = "agent_chat",
+ title = "Agent",
+ order = 100,
+ fragmentFactory = { ChatFragment() },
+ isEnabled = true,
+ isVisible = true,
+ tooltipTag = TOOLTIP_TAG_TAB
+ )
+ )
+ }
+
+ override fun getContextMenuItems(menuContext: ContextMenuContext): List {
+ val selectedText = menuContext.selectedText
+ if (selectedText.isNullOrBlank()) {
+ return emptyList()
+ }
+
+ return listOf(
+ MenuItem(
+ id = "ai_explain_code",
+ title = "Explain Code",
+ isEnabled = true,
+ isVisible = true,
+ action = { context.logger.info("Explain Code clicked") }
+ ),
+ MenuItem(
+ id = "ai_generate_code",
+ title = "Generate Code",
+ isEnabled = true,
+ isVisible = true,
+ action = { context.logger.info("Generate Code clicked") }
+ )
+ )
+ }
+
+ override fun getMainMenuItems(): List = emptyList()
+
+ // --- DocumentationExtension: three-tier tooltip help for the Agent tab ---
+ //
+ // Tier 1 = `summary` (one-liner shown on long-press)
+ // Tier 2 = `detail` (HTML paragraph behind "See More")
+ // Tier 3 = `buttons[].uri` (offline HTML page served from
+ // src/main/assets/docs/ at localhost)
+
+ override fun getTooltipCategory(): String = "plugin_ai_assistant"
+
+ override fun getTooltipEntries(): List = listOf(
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_TAB,
+ summary = "AI Agent: chat with an on-device or Gemini model that can read, search and edit your project.",
+ detail = """
+ The Agent tab opens a chat assistant backed by the
+ AI Core plugin. It can answer questions and run an
+ agentic tool-loop over your project.
+ Backends:
+
+ Local — on-device inference via llama.cpp (select a
+ .gguf model in Settings).
+ Gemini — Google's cloud API (needs an API key in
+ Settings; requests leave the device over HTTPS).
+
+ File-editing tools are confined to the current project and
+ ask for approval before writing.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(
+ description = "AI Assistant guide",
+ uri = "index.html", // resolves to plugin//index.html
+ order = 0
+ )
+ )
+ )
+ )
+
+ /** Subdirectory under src/main/assets/ holding the Tier 3 offline docs. */
+ override fun getTier3DocsAssetPath(): String = "docs"
+
+ private fun migrateDataIfNeeded() {
+ migrateChatHistory()
+ migrateSettings()
+ }
+
+ private fun migrateChatHistory() {
+ try {
+ val appChatDir = File(context.getAppFilesDir(), "chat_sessions")
+ val pluginChatDir = File(context.getPluginFilesDir(), "chat_sessions")
+
+ if (appChatDir.exists() && !pluginChatDir.exists()) {
+ context.logger.info("Migrating chat history from app to plugin storage")
+ pluginChatDir.mkdirs()
+
+ var migratedCount = 0
+ appChatDir.listFiles()?.forEach { file ->
+ val targetFile = File(pluginChatDir, file.name)
+ if (!targetFile.exists()) {
+ file.copyTo(targetFile, overwrite = false)
+ migratedCount++
+ }
+ }
+
+ context.logger.info("Migrated $migratedCount chat session files")
+ // Keep original files (don't delete)
+ } else if (pluginChatDir.exists()) {
+ context.logger.info("Chat history already migrated")
+ }
+ } catch (e: Exception) {
+ context.logger.error("Failed to migrate chat history", e)
+ }
+ }
+
+ private fun migrateSettings() {
+ try {
+ val appPrefs = context.getAppSharedPreferences("LlamaPrefs")
+ if (appPrefs == null) {
+ context.logger.info("App preferences not found, skipping settings migration")
+ return
+ }
+
+ val pluginPrefs = context.getPluginSharedPreferences("AgentSettings")
+
+ val PREF_KEY_AI_BACKEND = "ai_backend_preference"
+ val PREF_KEY_LOCAL_MODEL_PATH = "local_llm_model_path"
+ val PREF_KEY_LOCAL_MODEL_SHA256 = "local_llm_model_sha256"
+
+ var migratedCount = 0
+
+ // Migrate backend preference
+ if (!pluginPrefs.contains(PREF_KEY_AI_BACKEND)) {
+ val backend = appPrefs.getString(PREF_KEY_AI_BACKEND, null)
+ if (backend != null) {
+ pluginPrefs.edit().putString(PREF_KEY_AI_BACKEND, backend).apply()
+ migratedCount++
+ }
+ }
+
+ // Migrate model path
+ if (!pluginPrefs.contains(PREF_KEY_LOCAL_MODEL_PATH)) {
+ val modelPath = appPrefs.getString(PREF_KEY_LOCAL_MODEL_PATH, null)
+ if (modelPath != null) {
+ pluginPrefs.edit().putString(PREF_KEY_LOCAL_MODEL_PATH, modelPath).apply()
+ migratedCount++
+ }
+ }
+
+ // Migrate model SHA256
+ if (!pluginPrefs.contains(PREF_KEY_LOCAL_MODEL_SHA256)) {
+ val sha256 = appPrefs.getString(PREF_KEY_LOCAL_MODEL_SHA256, null)
+ if (sha256 != null) {
+ pluginPrefs.edit().putString(PREF_KEY_LOCAL_MODEL_SHA256, sha256).apply()
+ migratedCount++
+ }
+ }
+
+ // Note: Encrypted Gemini API key migration handled by EncryptedPrefs
+
+ if (migratedCount > 0) {
+ context.logger.info("Migrated $migratedCount settings from app to plugin")
+ } else {
+ context.logger.info("Settings already migrated")
+ }
+ } catch (e: Exception) {
+ context.logger.error("Failed to migrate settings", e)
+ }
+ }
+}
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
new file mode 100644
index 00000000..e9df31bc
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/adapters/ChatAdapter.kt
@@ -0,0 +1,377 @@
+package com.itsaky.androidide.plugins.aiassistant.adapters
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.ImageView
+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
+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 io.noties.markwon.Markwon
+import java.text.DecimalFormat
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class ChatAdapter(
+ private val pluginContext: Context,
+ private val markwon: Markwon,
+ private val onMessageAction: (action: String, message: ChatMessage) -> Unit
+) : ListAdapter(DiffCallback) {
+
+ private val timeFormatter = SimpleDateFormat("h:mm a", Locale.getDefault())
+ private val decimalSecondsFormatter = DecimalFormat("0.0")
+ private val expandedMessageIds = mutableSetOf()
+
+ companion object {
+ private const val VIEW_TYPE_DEFAULT = 0
+ private const val VIEW_TYPE_SYSTEM = 1
+
+ const val ACTION_EDIT = "edit"
+ const val ACTION_RETRY = "retry"
+ const val ACTION_OPEN_SETTINGS = "open_settings"
+ }
+
+ sealed class MessageViewHolder(view: View) : RecyclerView.ViewHolder(view)
+
+ class DefaultMessageViewHolder(view: View) : MessageViewHolder(view) {
+ val messageSender: TextView = view.findViewById(R.id.message_sender)
+ val loadingIndicator: ProgressBar = view.findViewById(R.id.loading_indicator)
+ val messageContent: TextView = view.findViewById(R.id.message_content)
+ val messageMetadataContainer: LinearLayout = view.findViewById(R.id.message_metadata_container)
+ val messageTimestamp: TextView = view.findViewById(R.id.message_timestamp)
+ 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)
+ }
+
+ class SystemMessageViewHolder(view: View) : MessageViewHolder(view) {
+ val messageHeader: LinearLayout = view.findViewById(R.id.message_header)
+ val messageHeaderTitle: TextView = view.findViewById(R.id.message_header_title)
+ val expandIcon: ImageView = view.findViewById(R.id.expand_icon)
+ val messageContent: TextView = view.findViewById(R.id.message_content)
+ }
+
+ override fun getItemCount(): Int {
+ val count = super.getItemCount()
+ android.util.Log.d("ChatAdapter", "getItemCount() = $count")
+ return count
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ val message = getItem(position)
+ return if (message.sender == Sender.SYSTEM && message.status == MessageStatus.ERROR) {
+ VIEW_TYPE_DEFAULT
+ } else if (message.sender == Sender.SYSTEM) {
+ VIEW_TYPE_SYSTEM
+ } else {
+ VIEW_TYPE_DEFAULT
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ android.util.Log.d("ChatAdapter", "onCreateViewHolder called, viewType=$viewType")
+ // Use plugin context for inflating layouts to access plugin resources
+ val inflater = LayoutInflater.from(pluginContext)
+ return when (viewType) {
+ VIEW_TYPE_SYSTEM -> {
+ val view = inflater.inflate(R.layout.list_item_chat_system_message, parent, false)
+ SystemMessageViewHolder(view)
+ }
+ else -> {
+ val view = inflater.inflate(R.layout.list_item_chat_message, parent, false)
+ DefaultMessageViewHolder(view)
+ }
+ }
+ }
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+ val message = getItem(position)
+ when (holder) {
+ is DefaultMessageViewHolder -> bindDefaultMessage(holder, message)
+ is SystemMessageViewHolder -> bindSystemMessage(holder, message)
+ }
+ }
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList) {
+ if (payloads.isEmpty()) {
+ // No payload, do full bind
+ onBindViewHolder(holder, position)
+ } else {
+ // Handle payload update
+ val payload = payloads[0]
+ if (payload is TextUpdatePayload && holder is DefaultMessageViewHolder) {
+ val message = getItem(position)
+ // Only update the text content and status, don't rebind everything
+ when (payload.status) {
+ MessageStatus.LOADING -> {
+ holder.loadingIndicator.visibility = View.VISIBLE
+ holder.messageContent.visibility = View.GONE
+ holder.generatingDots.visibility = View.GONE
+ }
+ MessageStatus.SENT -> {
+ holder.loadingIndicator.visibility = View.GONE
+ holder.messageContent.visibility = View.VISIBLE
+ markwon.setMarkdown(holder.messageContent, payload.text)
+
+ // Show dots animation for AGENT messages being generated
+ if (message.sender == Sender.AGENT && message.durationMs == null) {
+ animateGeneratingDots(holder)
+ } else {
+ holder.generatingDots.visibility = View.GONE
+ }
+ }
+ MessageStatus.COMPLETED -> {
+ holder.loadingIndicator.visibility = View.GONE
+ holder.messageContent.visibility = View.VISIBLE
+ holder.generatingDots.visibility = View.GONE
+ markwon.setMarkdown(holder.messageContent, payload.text)
+ }
+ MessageStatus.ERROR -> {
+ holder.loadingIndicator.visibility = View.GONE
+ holder.messageContent.visibility = View.VISIBLE
+ holder.generatingDots.visibility = View.GONE
+ holder.messageContent.text = payload.text
+ }
+ }
+ } else if (payload is TextUpdatePayload && holder is SystemMessageViewHolder) {
+ markwon.setMarkdown(holder.messageContent, payload.text)
+ updateSystemMessageExpansion(holder, getItem(position))
+ } else {
+ // Unknown payload, do full bind
+ onBindViewHolder(holder, position)
+ }
+ }
+ }
+
+ private fun bindDefaultMessage(holder: DefaultMessageViewHolder, message: ChatMessage) {
+ holder.messageSender.text = message.sender.name.lowercase(Locale.getDefault())
+ .replaceFirstChar { it.titlecase(Locale.getDefault()) }
+
+ holder.itemView.setOnLongClickListener { view ->
+ if (message.status == MessageStatus.SENT) {
+ showContextMenu(view, message)
+ }
+ true
+ }
+
+ when (message.status) {
+ MessageStatus.LOADING -> {
+ holder.loadingIndicator.visibility = View.VISIBLE
+ holder.messageContent.visibility = View.GONE
+ holder.btnRetry.visibility = View.GONE
+ holder.messageMetadataContainer.visibility = View.GONE
+ }
+ MessageStatus.SENT -> {
+ holder.loadingIndicator.visibility = View.GONE
+ holder.messageContent.visibility = View.VISIBLE
+ holder.btnRetry.visibility = View.GONE
+ markwon.setMarkdown(holder.messageContent, message.text)
+ updateMessageMetadata(holder, message)
+
+ // Show dots animation for AGENT messages being generated
+ if (message.sender == Sender.AGENT && message.durationMs == null) {
+ animateGeneratingDots(holder)
+ } else {
+ holder.generatingDots.visibility = View.GONE
+ }
+ }
+ MessageStatus.COMPLETED -> {
+ holder.loadingIndicator.visibility = View.GONE
+ holder.messageContent.visibility = View.VISIBLE
+ holder.btnRetry.visibility = View.GONE
+ holder.generatingDots.visibility = View.GONE
+ 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
+ holder.messageContent.text = message.text
+ if (message.sender == Sender.SYSTEM) {
+ holder.btnRetry.text = "Open AI Settings"
+ holder.btnRetry.setOnClickListener {
+ onMessageAction(ACTION_OPEN_SETTINGS, message)
+ }
+ } else {
+ holder.btnRetry.text = "Retry"
+ holder.btnRetry.setOnClickListener {
+ onMessageAction(ACTION_RETRY, message)
+ }
+ }
+ updateMessageMetadata(holder, message)
+ }
+ }
+ }
+
+ private fun bindSystemMessage(holder: SystemMessageViewHolder, message: ChatMessage) {
+ markwon.setMarkdown(holder.messageContent, message.text)
+ updateSystemMessageExpansion(holder, message)
+
+ holder.messageHeader.setOnClickListener {
+ if (!expandedMessageIds.remove(message.id)) {
+ expandedMessageIds.add(message.id)
+ }
+ val pos = holder.bindingAdapterPosition
+ if (pos != RecyclerView.NO_POSITION) {
+ notifyItemChanged(pos)
+ }
+ }
+ }
+
+ private fun updateSystemMessageExpansion(holder: SystemMessageViewHolder, message: ChatMessage) {
+ val isExpanded = expandedMessageIds.contains(message.id)
+ if (isExpanded) {
+ holder.messageHeaderTitle.text = "System Log"
+ holder.messageContent.visibility = View.VISIBLE
+ holder.expandIcon.rotation = 180f
+ } else {
+ holder.messageHeaderTitle.text = createPreview(message.text)
+ holder.messageContent.visibility = View.GONE
+ holder.expandIcon.rotation = 0f
+ }
+ }
+
+ private fun animateGeneratingDots(holder: DefaultMessageViewHolder) {
+ 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)
+ }
+ }
+ }
+ handler.post(runnable)
+ }
+
+ private fun createPreview(rawText: String): String {
+ val cleanedText = rawText
+ .replace(Regex("`{1,3}|\\*{1,2}|_"), "")
+ .replace(Regex("\\s+"), " ")
+ .trim()
+ return "Log: $cleanedText"
+ }
+
+ private fun updateMessageMetadata(holder: DefaultMessageViewHolder, message: ChatMessage) {
+ val timestampText = formatTimestamp(message.timestamp)
+ val durationText = formatDuration(message.durationMs)
+
+ val hasTimestamp = timestampText != null
+ val hasDuration = durationText != null
+
+ if (!hasTimestamp && !hasDuration) {
+ holder.messageMetadataContainer.visibility = View.GONE
+ return
+ }
+
+ holder.messageMetadataContainer.visibility = View.VISIBLE
+
+ if (hasTimestamp) {
+ holder.messageTimestamp.text = timestampText
+ holder.messageTimestamp.visibility = View.VISIBLE
+ } else {
+ holder.messageTimestamp.visibility = View.GONE
+ }
+
+ if (hasDuration) {
+ holder.messageDuration.text = durationText
+ holder.messageDuration.visibility = View.VISIBLE
+ } else {
+ holder.messageDuration.visibility = View.GONE
+ }
+ }
+
+ private fun formatTimestamp(timestamp: Long): String? {
+ if (timestamp <= 0L) return null
+ return synchronized(timeFormatter) {
+ timeFormatter.format(Date(timestamp))
+ }
+ }
+
+ private fun formatDuration(durationMs: Long?): String? {
+ if (durationMs == null || durationMs <= 0) return null
+ val seconds = durationMs / 1000.0
+ return if (seconds < 60) {
+ "took ${decimalSecondsFormatter.format(seconds)}s"
+ } else {
+ val minutes = seconds / 60.0
+ "took ${decimalSecondsFormatter.format(minutes)}m"
+ }
+ }
+
+ private fun showContextMenu(view: View, message: ChatMessage) {
+ val context = view.context
+ val popup = PopupMenu(context, view)
+
+ popup.menu.add(0, 1, 0, "Copy Text")
+ if (message.sender == Sender.USER) {
+ popup.menu.add(0, 2, 0, "Edit Message")
+ }
+
+ popup.setOnMenuItemClickListener { item ->
+ when (item.itemId) {
+ 1 -> {
+ 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()
+ true
+ }
+ 2 -> {
+ onMessageAction(ACTION_EDIT, message)
+ true
+ }
+ else -> false
+ }
+ }
+ popup.show()
+ }
+
+ override fun onCurrentListChanged(previousList: MutableList, currentList: MutableList) {
+ super.onCurrentListChanged(previousList, currentList)
+ expandedMessageIds.clear()
+ }
+
+ object DiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: ChatMessage, newItem: ChatMessage): Boolean {
+ return oldItem.id == newItem.id
+ }
+
+ override fun areContentsTheSame(oldItem: ChatMessage, newItem: ChatMessage): Boolean {
+ return oldItem == newItem
+ }
+
+ override fun getChangePayload(oldItem: ChatMessage, newItem: ChatMessage): Any? {
+ // If only the text or status changed, return a payload to avoid full rebind
+ if (oldItem.id == newItem.id &&
+ (oldItem.text != newItem.text || oldItem.status != newItem.status)) {
+ return TextUpdatePayload(newItem.text, newItem.status)
+ }
+ return null
+ }
+ }
+
+ // Payload for partial updates
+ data class TextUpdatePayload(val text: String, val status: MessageStatus)
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/data/ChatStorageManager.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/data/ChatStorageManager.kt
new file mode 100644
index 00000000..621f8640
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/data/ChatStorageManager.kt
@@ -0,0 +1,41 @@
+package com.itsaky.androidide.plugins.aiassistant.data
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import com.itsaky.androidide.plugins.aiassistant.models.ChatSession
+
+class ChatStorageManager(context: Context) {
+ private val prefs: SharedPreferences =
+ context.getSharedPreferences("ai_assistant_chats", Context.MODE_PRIVATE)
+ private val gson = Gson()
+
+ companion object {
+ private const val KEY_SESSIONS = "chat_sessions"
+ private const val KEY_CURRENT_SESSION_ID = "current_session_id"
+ }
+
+ fun saveSessions(sessions: List) {
+ val json = gson.toJson(sessions)
+ prefs.edit().putString(KEY_SESSIONS, json).apply()
+ }
+
+ fun loadSessions(): List {
+ val json = prefs.getString(KEY_SESSIONS, null) ?: return emptyList()
+ val type = object : TypeToken>() {}.type
+ return try {
+ gson.fromJson(json, type)
+ } catch (e: Exception) {
+ emptyList()
+ }
+ }
+
+ fun saveCurrentSessionId(sessionId: String?) {
+ prefs.edit().putString(KEY_CURRENT_SESSION_ID, sessionId).apply()
+ }
+
+ fun loadCurrentSessionId(): String? {
+ return prefs.getString(KEY_CURRENT_SESSION_ID, null)
+ }
+}
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
new file mode 100644
index 00000000..7ed32d68
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt
@@ -0,0 +1,450 @@
+package com.itsaky.androidide.plugins.aiassistant.fragments
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.*
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.ViewModelProvider
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiassistant.R
+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 java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class AiSettingsFragment : DialogFragment() {
+
+ private lateinit var viewModel: AiSettingsViewModel
+ private lateinit var settingsToolbar: LinearLayout
+ private lateinit var backButton: ImageButton
+ private lateinit var backendSpinner: Spinner
+ private lateinit var backendSpecificContainer: FrameLayout
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // Disable Material transitions to avoid resource loading issues
+ // Plugin uses compileOnly dependencies, so Material transition resources aren't bundled
+ enterTransition = null
+ exitTransition = null
+ }
+
+ private val filePickerLauncher =
+ registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
+ uri?.let {
+ try {
+ val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION
+ requireContext().contentResolver.takePersistableUriPermission(it, takeFlags)
+
+ val uriString = it.toString()
+ viewModel.loadModelFromUri(uriString, requireContext())
+ Toast.makeText(requireContext(), "Loading model...", Toast.LENGTH_SHORT).show()
+ } catch (e: Exception) {
+ Toast.makeText(requireContext(), "Error: ${e.message}", Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ // Get plugin context to ensure proper resource inflation
+ val pluginContext = getPluginContext()?.androidContext ?: requireContext()
+
+ // Create inflater with plugin context
+ val pluginInflater = inflater.cloneInContext(pluginContext)
+
+ return pluginInflater.inflate(R.layout.fragment_ai_settings, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initializeViewModel()
+ initializeViews(view)
+ setupToolbar()
+ setupBackendSelector()
+ }
+
+ private fun initializeViewModel() {
+ viewModel = ViewModelProvider(
+ this,
+ AiSettingsViewModelFactory { getPluginContext() }
+ )[AiSettingsViewModel::class.java]
+ }
+
+ private fun getPluginContext(): PluginContext? {
+ return com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin.getContext()
+ }
+
+ private fun initializeViews(view: View) {
+ settingsToolbar = view.findViewById(R.id.settings_toolbar)
+ backButton = view.findViewById(R.id.toolbar_back_button)
+ backendSpinner = view.findViewById(R.id.backend_autocomplete)
+ backendSpecificContainer = view.findViewById(R.id.backend_specific_settings_container)
+ }
+
+ private fun setupToolbar() {
+ backButton.setOnClickListener {
+ // Close the dialog
+ dismiss()
+ }
+ }
+
+ private fun setupBackendSelector() {
+ val backends = viewModel.getAvailableBackends()
+ val backendNames = backends.map { it.displayName }
+ val adapter = ArrayAdapter(
+ requireContext(),
+ android.R.layout.simple_spinner_item,
+ backendNames
+ )
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
+ backendSpinner.adapter = adapter
+
+ val currentBackend = viewModel.getCurrentBackend()
+ backendSpinner.setSelection(backends.indexOf(currentBackend))
+ updateBackendSpecificUi(currentBackend)
+
+ backendSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
+ val selectedBackend = backends[position]
+ viewModel.saveBackend(selectedBackend)
+ updateBackendSpecificUi(selectedBackend)
+ }
+
+ override fun onNothingSelected(parent: AdapterView<*>?) {}
+ }
+ }
+
+ private fun updateBackendSpecificUi(backend: AiBackend) {
+ backendSpecificContainer.removeAllViews()
+
+ // Use plugin context for inflating layouts
+ val pluginContext = getPluginContext()?.androidContext ?: requireContext()
+
+ when (backend) {
+ AiBackend.LOCAL_LLM -> {
+ val localLlmView = LayoutInflater.from(pluginContext)
+ .inflate(R.layout.layout_settings_local_llm, backendSpecificContainer, false)
+ backendSpecificContainer.addView(localLlmView)
+ setupLocalLlmUi(localLlmView)
+ }
+ AiBackend.GEMINI -> {
+ val geminiApiView = LayoutInflater.from(pluginContext)
+ .inflate(R.layout.layout_settings_gemini_api, backendSpecificContainer, false)
+ backendSpecificContainer.addView(geminiApiView)
+ setupGeminiApiUi(geminiApiView)
+ }
+ }
+ }
+
+ private fun setupLocalLlmUi(view: View) {
+ val modelPathTextView = view.findViewById(R.id.selected_model_path)
+ val browseButton = view.findViewById(R.id.btn_browse_model)
+ val loadSavedButton = view.findViewById(R.id.loadSavedButton)
+ val modelStatusTextView = view.findViewById(R.id.model_status_text_view)
+ val engineStatusTextView = view.findViewById(R.id.engine_status_text)
+ val simplePromptCheckbox = view.findViewById(R.id.switch_simple_local_prompt)
+ val shaInput = view.findViewById(R.id.local_model_sha_input)
+
+ browseButton.setOnClickListener {
+ filePickerLauncher.launch(arrayOf("*/*"))
+ }
+
+ loadSavedButton.setOnClickListener {
+ val savedPath = viewModel.savedModelPath.value
+ if (savedPath != null) {
+ viewModel.loadModelFromUri(savedPath, requireContext())
+ }
+ }
+
+ shaInput?.apply {
+ setText(viewModel.getLocalModelSha256().orEmpty())
+ setOnFocusChangeListener { _, hasFocus ->
+ if (!hasFocus) {
+ viewModel.saveLocalModelSha256(text?.toString())
+ }
+ }
+ }
+
+ simplePromptCheckbox?.apply {
+ isChecked = viewModel.isUseSimpleLocalPromptEnabled()
+ setOnCheckedChangeListener { _, isChecked ->
+ viewModel.setUseSimpleLocalPrompt(isChecked)
+ }
+ }
+
+ // Observe engine state
+ viewModel.engineState.observe(viewLifecycleOwner) { state ->
+ when (state) {
+ is EngineState.Initializing, EngineState.Uninitialized -> {
+ engineStatusTextView.text = "Initializing engine..."
+ browseButton.isEnabled = false
+ loadSavedButton.isEnabled = false
+ }
+ is EngineState.Initialized -> {
+ engineStatusTextView.text = "Engine ready"
+ browseButton.isEnabled = true
+ loadSavedButton.isEnabled = viewModel.savedModelPath.value != null
+ }
+ is EngineState.Error -> {
+ engineStatusTextView.text = state.message
+ browseButton.isEnabled = false
+ loadSavedButton.isEnabled = false
+ }
+ }
+ }
+
+ // Observe saved model path
+ viewModel.savedModelPath.observe(viewLifecycleOwner) { path ->
+ loadSavedButton.isEnabled = path != null && viewModel.engineState.value is EngineState.Initialized
+
+ if (path != null) {
+ modelPathTextView.visibility = View.VISIBLE
+ val fileName = path.substringAfterLast("/")
+ modelPathTextView.text = "Saved: $fileName"
+ } else {
+ modelPathTextView.visibility = View.GONE
+ }
+ }
+
+ // Observe model loading state
+ viewModel.modelLoadingState.observe(viewLifecycleOwner) { state ->
+ when (state) {
+ is ModelLoadingState.Idle -> {
+ modelStatusTextView.visibility = View.VISIBLE
+ modelStatusTextView.text = "No model is currently loaded"
+ }
+ is ModelLoadingState.Loading -> {
+ modelStatusTextView.visibility = View.VISIBLE
+ modelStatusTextView.text = "Loading model, please wait..."
+ }
+ is ModelLoadingState.Loaded -> {
+ modelStatusTextView.visibility = View.VISIBLE
+ modelStatusTextView.text = "✅ Model loaded: ${state.modelName}"
+ }
+ is ModelLoadingState.Error -> {
+ modelStatusTextView.visibility = View.VISIBLE
+ modelStatusTextView.text = "❌ Error: ${state.message}"
+ }
+ }
+ }
+ }
+
+ @SuppressLint("SetTextI18n")
+ 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 saveButton = view.findViewById(R.id.btn_save_api_key)
+ val editButton = view.findViewById(R.id.btn_edit_api_key)
+ val clearButton = view.findViewById(R.id.btn_clear_api_key)
+ val statusTextView = view.findViewById(R.id.gemini_api_key_status_text)
+
+ // Create model selection container
+ val modelContainer = createModelSelectionUi(view)
+
+ fun updateUiState(isEditing: Boolean) {
+ if (isEditing) {
+ statusTextView.visibility = View.GONE
+ apiKeyLayout.visibility = View.VISIBLE
+ saveButton.visibility = View.VISIBLE
+ editButton.visibility = View.GONE
+ clearButton.visibility = View.GONE
+ } else {
+ statusTextView.visibility = View.VISIBLE
+ apiKeyLayout.visibility = View.GONE
+ saveButton.visibility = View.GONE
+ editButton.visibility = View.VISIBLE
+ clearButton.visibility = View.VISIBLE
+ }
+ }
+
+ val savedApiKey = viewModel.getGeminiApiKey()
+ if (savedApiKey.isNullOrBlank()) {
+ updateUiState(isEditing = true)
+ apiKeyInput.setText("")
+ } else {
+ updateUiState(isEditing = false)
+ val timestamp = viewModel.getGeminiApiKeySaveTimestamp()
+ if (timestamp > 0) {
+ val sdf = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
+ val savedDate = sdf.format(Date(timestamp))
+ statusTextView.text = "API Key saved on: $savedDate"
+ } else {
+ statusTextView.text = "API Key is saved"
+ }
+ }
+
+ saveButton.setOnClickListener {
+ val apiKey = apiKeyInput.text.toString()
+ if (apiKey.isNotBlank()) {
+ viewModel.saveGeminiApiKey(apiKey)
+ Toast.makeText(requireContext(), "API Key saved", 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"
+ } else {
+ Toast.makeText(requireContext(), "API Key cannot be empty", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ editButton.setOnClickListener {
+ updateUiState(isEditing = true)
+ apiKeyInput.setText("••••••••••••••••")
+ apiKeyInput.requestFocus()
+ }
+
+ clearButton.setOnClickListener {
+ viewModel.clearGeminiApiKey()
+ Toast.makeText(requireContext(), "API Key cleared", Toast.LENGTH_SHORT).show()
+ updateUiState(isEditing = true)
+ apiKeyInput.setText("")
+ }
+
+ // Setup model selection
+ setupGeminiModelSelection(modelContainer)
+ }
+
+ private fun createModelSelectionUi(parent: View): LinearLayout {
+ val context = requireContext()
+ val container = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(0, 32, 0, 0)
+ }
+
+ // Add title
+ val titleText = TextView(context).apply {
+ text = "Gemini Model"
+ textSize = 16f
+ setPadding(0, 0, 0, 16)
+ }
+ container.addView(titleText)
+
+ // Add current model display
+ val currentModelText = TextView(context).apply {
+ id = View.generateViewId()
+ text = "Current: ${viewModel.getGeminiModel()}"
+ setPadding(0, 0, 0, 8)
+ }
+ container.addView(currentModelText)
+
+ // Add model spinner
+ val modelSpinner = Spinner(context).apply {
+ id = View.generateViewId()
+ }
+ container.addView(modelSpinner)
+
+ // Add refresh button
+ val refreshButton = Button(context).apply {
+ id = View.generateViewId()
+ text = "Refresh Models"
+ }
+ container.addView(refreshButton)
+
+ // Find the parent container and add this
+ if (parent is ViewGroup) {
+ parent.addView(container)
+ }
+
+ // Tag the views for later reference
+ container.tag = "model_container"
+ currentModelText.tag = "current_model_text"
+ modelSpinner.tag = "model_spinner"
+ refreshButton.tag = "refresh_button"
+
+ return container
+ }
+
+ private fun setupGeminiModelSelection(container: View) {
+ val currentModelText = container.findViewWithTag("current_model_text")
+ val modelSpinner = container.findViewWithTag("model_spinner")
+ val refreshButton = container.findViewWithTag("refresh_button")
+
+ if (modelSpinner == null || refreshButton == null) return
+
+ // Setup spinner
+ fun updateModelSpinner(models: List) {
+ val adapter = ArrayAdapter(
+ requireContext(),
+ android.R.layout.simple_spinner_item,
+ models
+ )
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
+ modelSpinner.adapter = adapter
+
+ // Set current selection
+ val currentModel = viewModel.getGeminiModel()
+ val currentIndex = models.indexOf(currentModel)
+ if (currentIndex >= 0) {
+ modelSpinner.setSelection(currentIndex)
+ }
+ }
+
+ // Observe models
+ viewModel.geminiModels.observe(viewLifecycleOwner) { models ->
+ if (models.isNotEmpty()) {
+ updateModelSpinner(models)
+ }
+ }
+
+ // Observe loading state
+ viewModel.geminiModelsLoading.observe(viewLifecycleOwner) { isLoading ->
+ refreshButton.isEnabled = !isLoading
+ refreshButton.text = if (isLoading) "Loading..." else "Refresh Models"
+ }
+
+ // Handle model selection
+ modelSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
+ 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()
+ }
+ }
+
+ override fun onNothingSelected(parent: AdapterView<*>?) {}
+ }
+
+ // Handle refresh button
+ refreshButton.setOnClickListener {
+ viewModel.fetchGeminiModels()
+ }
+
+ // Initial fetch
+ if (viewModel.geminiModels.value.isNullOrEmpty()) {
+ viewModel.fetchGeminiModels()
+ }
+ }
+}
+
+/**
+ * Factory for creating AiSettingsViewModel with PluginContext dependency.
+ */
+class AiSettingsViewModelFactory(
+ private val getContext: () -> PluginContext?
+) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ if (modelClass.isAssignableFrom(AiSettingsViewModel::class.java)) {
+ return AiSettingsViewModel(getContext) as T
+ }
+ throw IllegalArgumentException("Unknown ViewModel class")
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ApprovalDialogFragment.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ApprovalDialogFragment.kt
new file mode 100644
index 00000000..f904c6ce
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ApprovalDialogFragment.kt
@@ -0,0 +1,99 @@
+package com.itsaky.androidide.plugins.aiassistant.fragments
+
+import android.app.Dialog
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.itsaky.androidide.plugins.aiassistant.R
+import com.itsaky.androidide.plugins.aiassistant.tool.ApprovalRequest
+import com.itsaky.androidide.plugins.aiassistant.tool.ApprovalResult
+import org.json.JSONObject
+
+/**
+ * Dialog for approving tool execution.
+ */
+class ApprovalDialogFragment : DialogFragment() {
+
+ private var onApprovalDecision: ((ApprovalResult) -> Unit)? = null
+
+ companion object {
+ private const val ARG_TOOL_NAME = "tool_name"
+ private const val ARG_DESCRIPTION = "description"
+ private const val ARG_ARGS = "args"
+
+ fun newInstance(
+ request: ApprovalRequest,
+ onDecision: (ApprovalResult) -> Unit
+ ): ApprovalDialogFragment {
+ return ApprovalDialogFragment().apply {
+ arguments = Bundle().apply {
+ putString(ARG_TOOL_NAME, request.toolName)
+ putString(ARG_DESCRIPTION, request.description)
+ putString(ARG_ARGS, formatArgs(request.args))
+ }
+ onApprovalDecision = onDecision
+ }
+ }
+
+ private fun formatArgs(args: Map): String {
+ if (args.isEmpty()) return "{}"
+ return try {
+ val json = JSONObject()
+ args.forEach { (key, value) ->
+ json.put(key, value)
+ }
+ json.toString(2)
+ } catch (e: Exception) {
+ args.toString()
+ }
+ }
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val toolName = arguments?.getString(ARG_TOOL_NAME) ?: "unknown"
+ val description = arguments?.getString(ARG_DESCRIPTION) ?: ""
+ val argsText = arguments?.getString(ARG_ARGS) ?: "{}"
+
+ val message = buildString {
+ append("🔒 Tool Approval Required\n\n")
+ append(description)
+ append("\n\n")
+ append(getString(R.string.approval_args))
+ append("\n")
+ append(argsText)
+ append("\n\n")
+ append("Please choose an option below:")
+ }
+
+ val dialog = MaterialAlertDialogBuilder(requireContext())
+ .setTitle("⚠️ Confirm: $toolName")
+ .setMessage(message)
+ .setPositiveButton("✓ Run Now") { _, _ ->
+ onApprovalDecision?.invoke(ApprovalResult.APPROVED_ONCE)
+ dismiss()
+ }
+ .setNeutralButton("✓ Always Allow") { _, _ ->
+ onApprovalDecision?.invoke(ApprovalResult.APPROVED_FOR_SESSION)
+ dismiss()
+ }
+ .setNegativeButton("✗ Deny") { _, _ ->
+ onApprovalDecision?.invoke(ApprovalResult.DENIED)
+ dismiss()
+ }
+ .setOnCancelListener {
+ onApprovalDecision?.invoke(ApprovalResult.DENIED)
+ }
+ .create()
+
+ // Prevent accidental dismissal
+ dialog.setCancelable(false)
+ dialog.setCanceledOnTouchOutside(false)
+
+ return dialog
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ onApprovalDecision = null
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt
new file mode 100644
index 00000000..cbabc4ba
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt
@@ -0,0 +1,388 @@
+package com.itsaky.androidide.plugins.aiassistant.fragments
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.isVisible
+import androidx.core.widget.doAfterTextChanged
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.chip.Chip
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiassistant.adapters.ChatAdapter
+import com.itsaky.androidide.plugins.aiassistant.databinding.FragmentChatBinding
+import com.itsaky.androidide.plugins.aiassistant.models.AgentState
+import com.itsaky.androidide.plugins.aiassistant.viewmodel.ChatViewModel
+import io.noties.markwon.Markwon
+import kotlinx.coroutines.launch
+import java.io.File
+
+/**
+ * ChatFragment for Agent chat UI.
+ * Provides a full chat interface with LLM integration.
+ */
+class ChatFragment : Fragment() {
+ private var _binding: FragmentChatBinding? = null
+ private val binding get() = _binding!!
+
+ private lateinit var viewModel: ChatViewModel
+ private lateinit var chatAdapter: ChatAdapter
+ private lateinit var markwon: Markwon
+ private val contextFiles = mutableListOf()
+
+ 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 }
+ }
+ }
+
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ // Get plugin context to ensure proper resource inflation
+ val pluginContext = getPluginContext()?.androidContext ?: requireContext()
+
+ // Create inflater with plugin context
+ val pluginInflater = inflater.cloneInContext(pluginContext)
+
+ _binding = FragmentChatBinding.inflate(pluginInflater, container, false)
+ return binding.root
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ viewModel.stopProcessing()
+ _binding = null
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initializeMarkwon()
+ initializeViewModel()
+ if (!viewModel.isStorageInitialized()) {
+ viewModel.initializeStorage(requireContext())
+ }
+ setupToolbar()
+ setupRecyclerView()
+ setupInputArea()
+ setupStatusBar()
+ setupBackendIndicator()
+ observeViewModel()
+
+ // Check for test prompt from broadcast receiver (E2E testing)
+ injectPendingTestPrompt()
+ }
+
+ /**
+ * Check for test prompt from broadcast receiver and auto-send if present.
+ * Uses SharedPreferences set by TestBroadcastReceiver for reliable communication.
+ */
+ private fun injectPendingTestPrompt() {
+ try {
+ // Check SharedPreferences for pending test prompt (set by TestBroadcastReceiver)
+ val context = requireContext()
+ val prefs = context.getSharedPreferences("test_ai_prefs", android.content.Context.MODE_PRIVATE)
+ val pendingPrompt = prefs.getString("pending_prompt", null)
+ val shouldAutoSend = prefs.getBoolean("auto_send", false)
+
+ if (!pendingPrompt.isNullOrBlank() && shouldAutoSend) {
+ android.util.Log.d("ChatFragment", "📝 Found pending test prompt: '$pendingPrompt'")
+
+ // Inject into input field
+ binding.promptInputEdittext.setText(pendingPrompt)
+ android.util.Log.d("ChatFragment", "✅ Prompt injected into input field")
+
+ // Auto-send after a short delay to ensure UI is ready
+ binding.promptInputEdittext.post {
+ android.util.Log.d("ChatFragment", "🚀 Sending prompt automatically...")
+ binding.sendButton.performClick()
+
+ // Clear the SharedPreferences after sending
+ prefs.edit().apply {
+ remove("pending_prompt")
+ remove("auto_send")
+ remove("auto_approve")
+ remove("timestamp")
+ apply()
+ }
+ android.util.Log.d("ChatFragment", "🧹 Cleared pending prompt from preferences")
+ }
+ }
+ } catch (e: Exception) {
+ android.util.Log.e("ChatFragment", "Error checking for pending test prompt: ${e.message}")
+ }
+ }
+
+ private fun initializeMarkwon() {
+ markwon = Markwon.create(requireContext())
+ }
+
+ override fun onResume() {
+ super.onResume()
+ // Check backend availability when fragment becomes visible
+ // This ensures we check after all plugins have loaded
+ viewModel.checkBackendAvailability()
+ }
+
+ private fun initializeViewModel() {
+ // Pass plugin context getter instead of service directly
+ // This allows ViewModel to get service lazily
+ viewModel = ViewModelProvider(
+ this,
+ ChatViewModelFactory { getPluginContext() }
+ )[ChatViewModel::class.java]
+ }
+
+ private fun getPluginContext(): PluginContext? {
+ // Access the plugin context via the companion object
+ return com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin.getContext()
+ }
+
+ private fun setupRecyclerView() {
+ // Use plugin context for adapter to ensure proper resource inflation
+ val pluginContext = getPluginContext()?.androidContext ?: requireContext()
+ chatAdapter = ChatAdapter(pluginContext, markwon) { action, message ->
+ onMessageAction(action, message)
+ }
+ binding.chatRecyclerView.apply {
+ adapter = chatAdapter
+ layoutManager = LinearLayoutManager(requireContext()).apply {
+ stackFromEnd = true
+ }
+ }
+ }
+
+ private fun setupToolbar() {
+ binding.btnOverflowMenu.setOnClickListener { view ->
+ // Use plugin context for PopupMenu to access menu resources
+ val pluginContext = getPluginContext()?.androidContext ?: requireContext()
+ val popup = android.widget.PopupMenu(pluginContext, view)
+ popup.menuInflater.inflate(com.itsaky.androidide.plugins.aiassistant.R.menu.chat_overflow_menu, popup.menu)
+
+ popup.setOnMenuItemClickListener { menuItem ->
+ when (menuItem.itemId) {
+ com.itsaky.androidide.plugins.aiassistant.R.id.menu_settings -> {
+ openSettingsFragment()
+ true
+ }
+ com.itsaky.androidide.plugins.aiassistant.R.id.menu_clear_chat -> {
+ viewModel.createNewSession()
+ true
+ }
+ else -> false
+ }
+ }
+ popup.show()
+ }
+ }
+
+ private fun setupInputArea() {
+ binding.promptInputEdittext.doAfterTextChanged { text ->
+ binding.sendButton.isEnabled = !text.isNullOrBlank()
+ }
+
+ binding.sendButton.setOnClickListener {
+ val currentAgentState = viewModel.agentState.value
+ when (currentAgentState) {
+ is AgentState.Executing, is AgentState.Processing -> {
+ viewModel.stopProcessing()
+ }
+ else -> {
+ val message = binding.promptInputEdittext.text?.toString() ?: return@setOnClickListener
+ if (message.isNotBlank()) {
+ hideKeyboard()
+ viewModel.sendMessage(message)
+ binding.promptInputEdittext.text?.clear()
+ }
+ }
+ }
+ }
+
+ binding.btnAddContext.setOnClickListener {
+ showFilePicker()
+ }
+ }
+
+ private fun setupStatusBar() {
+ binding.agentStatusContainer.isVisible = false
+ }
+
+ private fun setupBackendIndicator() {
+ binding.backendStatusText.text = "Gemini"
+ }
+
+ private fun observeViewModel() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ launch { observeMessages() }
+ launch { observeAgentState() }
+ launch { observePendingApprovalRequest() }
+ }
+ }
+ }
+
+ private suspend fun observeMessages() {
+ android.util.Log.d("ChatFragment", "observeMessages: Starting to collect messages")
+ viewModel.messages.collect { messages ->
+ android.util.Log.d("ChatFragment", "observeMessages: Received ${messages.size} messages")
+ messages.forEachIndexed { index, msg ->
+ android.util.Log.d("ChatFragment", " Message $index: sender=${msg.sender}, text=${msg.text.take(50)}")
+ }
+ binding.emptyChatView.isVisible = messages.isEmpty()
+ android.util.Log.d("ChatFragment", "observeMessages: Calling submitList with ${messages.size} messages")
+ chatAdapter.submitList(messages) {
+ android.util.Log.d("ChatFragment", "observeMessages: submitList callback - scrolling to ${messages.size - 1}")
+ if (messages.isNotEmpty()) {
+ binding.chatRecyclerView.scrollToPosition(messages.size - 1)
+ }
+ }
+ }
+ }
+
+ private suspend fun observeAgentState() {
+ viewModel.agentState.collect { state ->
+ when (state) {
+ is AgentState.Idle -> {
+ binding.agentStatusContainer.isVisible = false
+ binding.sendButton.isEnabled = true
+ binding.sendButton.text = "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"
+ viewModel.startStateTimer(state)
+ }
+ is AgentState.Processing -> {
+ binding.agentStatusContainer.isVisible = true
+ binding.agentStatusMessage.text = "Generating response..."
+ binding.agentStatusTimer.text = ""
+ binding.sendButton.isEnabled = true
+ binding.sendButton.text = "Stop"
+ }
+ is AgentState.Error -> {
+ binding.agentStatusContainer.isVisible = false
+ binding.sendButton.isEnabled = true
+ binding.sendButton.text = "Send"
+ viewModel.stopStateTimer()
+ }
+ else -> {
+ binding.sendButton.isEnabled = false
+ binding.sendButton.text = "Send"
+ }
+ }
+ }
+ }
+
+ private suspend fun observePendingApprovalRequest() {
+ viewModel.pendingApprovalRequest.collect { request ->
+ if (request != null) {
+ showApprovalDialog(request)
+ }
+ }
+ }
+
+ private fun showApprovalDialog(request: com.itsaky.androidide.plugins.aiassistant.tool.ApprovalRequest) {
+ val dialog = ApprovalDialogFragment.newInstance(request) { result ->
+ viewModel.submitApproval(result)
+ }
+ dialog.show(parentFragmentManager, "approval_dialog")
+ }
+
+ private fun showFilePicker() {
+ // Start from AndroidIDE projects directory by default
+ val startPath = "/storage/emulated/0/AndroidIDEProjects"
+
+ val dialog = FilePickerDialogFragment.newInstance(startPath) { files ->
+ addContextFiles(files)
+ }
+ dialog.show(parentFragmentManager, "file_picker")
+ }
+
+ private fun addContextFiles(files: List) {
+ files.forEach { file ->
+ if (!contextFiles.contains(file)) {
+ contextFiles.add(file)
+ addChipForFile(file)
+ }
+ }
+ viewModel.setContextFiles(contextFiles)
+ }
+
+ private fun addChipForFile(file: File) {
+ val chip = Chip(requireContext()).apply {
+ text = file.name
+ isCloseIconVisible = true
+ setOnCloseIconClickListener {
+ contextFiles.remove(file)
+ binding.contextChipGroup.removeView(this)
+ viewModel.setContextFiles(contextFiles)
+ }
+ }
+ binding.contextChipGroup.addView(chip)
+ }
+
+ private fun onMessageAction(action: String, message: com.itsaky.androidide.plugins.aiassistant.models.ChatMessage) {
+ when (action) {
+ ChatAdapter.ACTION_EDIT -> {
+ // Show dialog to edit message
+ binding.promptInputEdittext.setText(message.text)
+ binding.promptInputEdittext.requestFocus()
+ }
+ ChatAdapter.ACTION_RETRY -> {
+ // Resend the message
+ viewModel.sendMessage(message.text)
+ }
+ ChatAdapter.ACTION_OPEN_SETTINGS -> {
+ // Open settings fragment
+ openSettingsFragment()
+ }
+ }
+ }
+
+ private fun hideKeyboard() {
+ val imm = requireContext().getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
+ imm.hideSoftInputFromWindow(binding.promptInputEdittext.windowToken, 0)
+ }
+
+ private fun openSettingsFragment() {
+ val settingsFragment = AiSettingsFragment()
+ // Show as dialog fragment to avoid overlapping with chat UI
+ settingsFragment.show(parentFragmentManager, "ai_settings")
+ }
+
+}
+
+/**
+ * Factory for creating ChatViewModel with PluginContext dependency.
+ */
+class ChatViewModelFactory(
+ private val getContext: () -> PluginContext?
+) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ if (modelClass.isAssignableFrom(ChatViewModel::class.java)) {
+ return ChatViewModel(getContext) as T
+ }
+ throw IllegalArgumentException("Unknown ViewModel class")
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/FilePickerDialogFragment.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/FilePickerDialogFragment.kt
new file mode 100644
index 00000000..bc7e2dfa
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/FilePickerDialogFragment.kt
@@ -0,0 +1,143 @@
+package com.itsaky.androidide.plugins.aiassistant.fragments
+
+import android.app.Dialog
+import android.os.Bundle
+import android.widget.ArrayAdapter
+import android.widget.ListView
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import java.io.File
+
+/**
+ * Dialog for picking files to add as context.
+ */
+class FilePickerDialogFragment : DialogFragment() {
+
+ private var onFilesSelected: ((List) -> Unit)? = null
+ private var currentDirectory: File? = null
+ private val selectedFiles = mutableSetOf()
+
+ companion object {
+ private const val ARG_START_PATH = "start_path"
+
+ fun newInstance(
+ startPath: String? = null,
+ onSelected: (List) -> Unit
+ ): FilePickerDialogFragment {
+ return FilePickerDialogFragment().apply {
+ arguments = Bundle().apply {
+ // Use provided path or default to storage root
+ val path = startPath ?: "/storage/emulated/0"
+ putString(ARG_START_PATH, path)
+ }
+ onFilesSelected = onSelected
+ }
+ }
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val startPath = arguments?.getString(ARG_START_PATH) ?: "/storage/emulated/0"
+ currentDirectory = File(startPath)
+
+ if (!currentDirectory!!.exists()) {
+ // Fallback to root if start path doesn't exist
+ currentDirectory = File("/storage/emulated/0")
+ }
+
+ if (!currentDirectory!!.exists()) {
+ return MaterialAlertDialogBuilder(requireContext())
+ .setTitle("Error")
+ .setMessage("Storage directory not found")
+ .setPositiveButton("OK") { _, _ -> dismiss() }
+ .create()
+ }
+
+ return showDirectoryPicker(currentDirectory!!)
+ }
+
+ private fun showDirectoryPicker(directory: File): Dialog {
+ val files = directory.listFiles()?.sortedWith(
+ compareBy { !it.isDirectory }
+ .thenBy { it.name }
+ ) ?: emptyList()
+
+ val items = mutableListOf()
+ val fileList = mutableListOf()
+
+ // Add parent directory option if not at root
+ if (directory.parentFile != null && directory.parentFile!!.exists()) {
+ items.add("📁 ..")
+ fileList.add(directory.parentFile!!)
+ }
+
+ // Add directories and files
+ files.forEach { file ->
+ val prefix = if (file.isDirectory) "📁 " else "📄 "
+ val suffix = if (selectedFiles.contains(file)) " ✓" else ""
+ items.add("$prefix${file.name}$suffix")
+ fileList.add(file)
+ }
+
+ val adapter = ArrayAdapter(
+ requireContext(),
+ android.R.layout.simple_list_item_1,
+ items
+ )
+
+ val listView = ListView(requireContext()).apply {
+ this.adapter = adapter
+ choiceMode = ListView.CHOICE_MODE_MULTIPLE
+ }
+
+ return MaterialAlertDialogBuilder(requireContext())
+ .setTitle("Select Files: ${directory.name}")
+ .setView(listView)
+ .setPositiveButton("Add Selected") { _, _ ->
+ onFilesSelected?.invoke(selectedFiles.toList())
+ dismiss()
+ }
+ .setNegativeButton("Cancel") { _, _ -> dismiss() }
+ .setNeutralButton("Toggle All") { _, _ ->
+ // Toggle all files in current directory
+ val currentFiles = files.filter { it.isFile }
+ if (selectedFiles.containsAll(currentFiles)) {
+ selectedFiles.removeAll(currentFiles.toSet())
+ } else {
+ selectedFiles.addAll(currentFiles)
+ }
+ // Refresh dialog
+ dismiss()
+ showDirectoryPicker(directory).show()
+ }
+ .create().apply {
+ listView.setOnItemClickListener { _, _, position, _ ->
+ val file = fileList[position]
+
+ if (file.isDirectory) {
+ // Navigate to directory
+ dismiss()
+ showDirectoryPicker(file).show()
+ } else {
+ // Toggle file selection
+ if (selectedFiles.contains(file)) {
+ selectedFiles.remove(file)
+ } else {
+ selectedFiles.add(file)
+ }
+ // Refresh display
+ items[position] = if (selectedFiles.contains(file)) {
+ "📄 ${file.name} ✓"
+ } else {
+ "📄 ${file.name}"
+ }
+ adapter.notifyDataSetChanged()
+ }
+ }
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ onFilesSelected = null
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/AgentState.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/AgentState.kt
new file mode 100644
index 00000000..65b32808
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/AgentState.kt
@@ -0,0 +1,78 @@
+package com.itsaky.androidide.plugins.aiassistant.models
+
+/**
+ * Represents the current state of the AI agent.
+ */
+sealed class AgentState {
+ /**
+ * Agent is idle and ready to accept new messages.
+ */
+ object Idle : AgentState()
+
+ /**
+ * Agent is initializing (loading model, preparing context, etc.).
+ */
+ data class Initializing(val message: String) : AgentState()
+
+ /**
+ * Agent is thinking/reasoning about the user's request.
+ */
+ data class Thinking(val thought: String) : AgentState()
+
+ /**
+ * Agent is executing a tool or action.
+ */
+ data class Executing(
+ val currentStepIndex: Int,
+ val totalSteps: Int,
+ val description: String,
+ val startTime: Long = System.currentTimeMillis(),
+ val elapsedMillis: Long = 0
+ ) : AgentState() {
+ val formattedProgress: String
+ get() = "Step ${currentStepIndex + 1} of $totalSteps: $description"
+
+ val formattedTiming: String
+ get() {
+ val elapsed = formatTime(elapsedMillis)
+ // Estimate total time based on average time per step
+ val estimatedTotal = if (currentStepIndex > 0) {
+ val avgPerStep = elapsedMillis / (currentStepIndex + 1)
+ avgPerStep * totalSteps
+ } else {
+ elapsedMillis * totalSteps
+ }
+ val total = formatTime(estimatedTotal)
+ return "($elapsed of $total)"
+ }
+
+ private fun formatTime(millis: Long): String {
+ if (millis < 0) return "0.0s"
+ val minutes = java.util.concurrent.TimeUnit.MILLISECONDS.toMinutes(millis)
+ val seconds = java.util.concurrent.TimeUnit.MILLISECONDS.toSeconds(millis) -
+ java.util.concurrent.TimeUnit.MINUTES.toSeconds(minutes)
+ val remainingMillis = millis % 1000
+ val totalSeconds = seconds + (remainingMillis / 1000.0)
+ return if (minutes > 0) {
+ String.format(java.util.Locale.US, "%dm %.1fs", minutes, totalSeconds)
+ } else {
+ String.format(java.util.Locale.US, "%.1fs", totalSeconds)
+ }
+ }
+ }
+
+ /**
+ * Agent is processing/generating a response.
+ */
+ data class Processing(val message: String) : AgentState()
+
+ /**
+ * A cancellation has been requested.
+ */
+ object Cancelling : AgentState()
+
+ /**
+ * Agent encountered an error.
+ */
+ data class Error(val message: String) : AgentState()
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/ChatMessage.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/ChatMessage.kt
new file mode 100644
index 00000000..f8cce0fe
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/ChatMessage.kt
@@ -0,0 +1,43 @@
+package com.itsaky.androidide.plugins.aiassistant.models
+
+import java.util.UUID
+
+/**
+ * Status of a chat message.
+ */
+enum class MessageStatus {
+ /** Message successfully sent/received */
+ SENT,
+ /** Message is being generated */
+ LOADING,
+ /** Message generation failed */
+ ERROR,
+ /** Message generation completed */
+ COMPLETED
+}
+
+/**
+ * Sender of a chat message.
+ */
+enum class Sender {
+ /** Message from the user */
+ USER,
+ /** Message from the AI agent */
+ AGENT,
+ /** System notification message */
+ SYSTEM,
+ /** Tool execution message */
+ TOOL
+}
+
+/**
+ * Represents a single chat message in the conversation.
+ */
+data class ChatMessage(
+ val id: String = UUID.randomUUID().toString(),
+ val text: String,
+ val sender: Sender,
+ var status: MessageStatus = MessageStatus.SENT,
+ val timestamp: Long = System.currentTimeMillis(),
+ val durationMs: Long? = null
+)
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/ChatSession.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/ChatSession.kt
new file mode 100644
index 00000000..b5be97bc
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/ChatSession.kt
@@ -0,0 +1,18 @@
+package com.itsaky.androidide.plugins.aiassistant.models
+
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.UUID
+
+data class ChatSession(
+ val id: String = UUID.randomUUID().toString(),
+ val createdAt: Long = System.currentTimeMillis(),
+ val messages: MutableList = mutableListOf()
+) {
+ val title: String
+ get() = messages.firstOrNull { it.sender == Sender.USER }?.text ?: "New Chat"
+
+ val formattedDate: String
+ get() = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(Date(createdAt))
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/ToolResult.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/ToolResult.kt
new file mode 100644
index 00000000..59aba20a
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/ToolResult.kt
@@ -0,0 +1,30 @@
+package com.itsaky.androidide.plugins.aiassistant.models
+
+/**
+ * Result from executing a tool/function call.
+ */
+data class ToolResult(
+ val success: Boolean,
+ val message: String,
+ val data: String? = null,
+ val error_details: String? = null
+) {
+ companion object {
+ fun success(message: String, data: String? = null): ToolResult {
+ return ToolResult(true, message, data)
+ }
+
+ fun failure(message: String, error_details: String? = null): ToolResult {
+ return ToolResult(false, message, error_details = error_details)
+ }
+ }
+
+ fun toResultMap(): Map {
+ return mapOf(
+ "success" to success,
+ "message" to message,
+ "data" to (data ?: ""),
+ "error_details" to (error_details ?: "")
+ )
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/Executor.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/Executor.kt
new file mode 100644
index 00000000..147435b0
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/Executor.kt
@@ -0,0 +1,148 @@
+package com.itsaky.androidide.plugins.aiassistant.tool
+
+import android.util.Log
+import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.PathGuard
+import com.itsaky.androidide.plugins.aiassistant.utils.ToolExecutionTracker
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+
+/**
+ * Executes tool calls, handling parallel vs sequential execution.
+ */
+class Executor(
+ private val toolRouter: ToolRouter,
+ private val approvalManager: ToolApprovalManager,
+ private val toolExecutionTracker: ToolExecutionTracker? = null
+) {
+ private val TAG = "Executor"
+
+ companion object {
+ // Tools that only read state and can be executed in parallel safely
+ private val PARALLEL_SAFE_TOOLS = setOf(
+ "read_file",
+ "list_files",
+ "search_project",
+ "get_current_datetime"
+ )
+
+ /**
+ * Get required arguments for a tool.
+ */
+ fun requiredArgsForTool(toolName: String): List {
+ return when (toolName) {
+ "read_file" -> listOf("file_path")
+ "list_files" -> emptyList() // directory is optional, defaults to "."
+ "search_project" -> listOf("query")
+ "create_file" -> listOf("file_path", "content")
+ "update_file" -> listOf("file_path", "content")
+ else -> emptyList()
+ }
+ }
+ }
+
+ /**
+ * Execute a list of tool calls.
+ * Read-only tools are executed in parallel, write tools sequentially.
+ */
+ suspend fun execute(toolCalls: List): List = coroutineScope {
+ Log.i(TAG, "Executing ${toolCalls.size} tool call(s)...")
+
+ val results = arrayOfNulls(toolCalls.size)
+
+ // Read-only tools run concurrently.
+ val parallelJobs = toolCalls.mapIndexedNotNull { index, call ->
+ if (call.name in PARALLEL_SAFE_TOOLS) {
+ async { results[index] = executeCall(call, "Parallel") }
+ } else null
+ }
+
+ // Write tools run one at a time, in input order.
+ toolCalls.forEachIndexed { index, call ->
+ if (call.name !in PARALLEL_SAFE_TOOLS) {
+ results[index] = executeCall(call, "Sequential")
+ }
+ }
+
+ parallelJobs.awaitAll()
+ results.requireNoNulls().toList()
+ }
+
+ /**
+ * Execute a single tool call.
+ */
+ private suspend fun executeCall(call: ToolCall, executionMode: String): ToolResult {
+ val toolName = call.name
+ val args = call.args
+
+ if (toolName.isBlank()) {
+ Log.e(TAG, "($executionMode): Encountered unnamed function call.")
+ return ToolResult.failure("Unnamed function call")
+ }
+
+ val handler = toolRouter.getHandler(toolName)
+ if (handler == null) {
+ Log.e(TAG, "($executionMode): Unknown function requested: $toolName")
+ return ToolResult.failure("Unknown function '$toolName'")
+ }
+
+ // Normalize arg keys for read_file: if "path" is present but "file_path" is missing, remap
+ val normalizedArgs = args.toMutableMap()
+ if (toolName == "read_file" && normalizedArgs.containsKey("path") && !normalizedArgs.containsKey("file_path")) {
+ normalizedArgs["path"]?.let { normalizedArgs["file_path"] = it }
+ if (normalizedArgs.containsKey("file_path")) {
+ Log.d(TAG, "($executionMode): Remapped 'path' → 'file_path' for read_file tool")
+ }
+ }
+
+ // Check required arguments
+ val missingArgs = requiredArgsForTool(toolName).filter { key ->
+ val value = normalizedArgs[key]?.toString()?.trim().orEmpty()
+ value.isBlank()
+ }
+ if (missingArgs.isNotEmpty()) {
+ val message = "Missing required argument(s): ${missingArgs.joinToString(", ")}"
+ Log.i(TAG, "($executionMode): Tool '$toolName' missing args: $missingArgs")
+ return ToolResult.failure(message)
+ }
+
+ for (key in handler.pathArgs) {
+ val raw = normalizedArgs[key]?.toString()?.trim()
+ if (!raw.isNullOrEmpty() && PathGuard.resolveWithin(raw) == null) {
+ Log.w(TAG, "($executionMode): '$toolName' arg '$key' escapes project root: $raw")
+ return ToolResult.failure("Path '$raw' is outside the project directory")
+ }
+ }
+
+ // Check approval
+ val approvalResponse = approvalManager.ensureApproved(toolName, handler, normalizedArgs)
+ if (!approvalResponse.approved) {
+ val message = approvalResponse.denialMessage ?: "Action denied by user."
+ Log.i(TAG, "($executionMode): Tool '$toolName' denied. $message")
+ return ToolResult.failure(message)
+ }
+
+ Log.d(TAG, "($executionMode): Dispatching '$toolName' with args: $normalizedArgs")
+
+ // Before tool execution
+ val toolStartTime = System.currentTimeMillis()
+
+ val result = toolRouter.dispatch(toolName, normalizedArgs)
+
+ // After tool execution
+ val toolDuration = System.currentTimeMillis() - toolStartTime
+ toolExecutionTracker?.logToolCall(toolName, toolDuration)
+
+ Log.i(TAG, "($executionMode): Result: ${result.toResultMap()}")
+ return result
+ }
+}
+
+/**
+ * Represents a tool call to be executed.
+ */
+data class ToolCall(
+ val name: String,
+ val args: Map
+)
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolApprovalManager.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolApprovalManager.kt
new file mode 100644
index 00000000..6fa89098
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolApprovalManager.kt
@@ -0,0 +1,172 @@
+package com.itsaky.androidide.plugins.aiassistant.tool
+
+import android.util.Log
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.withTimeoutOrNull
+
+/**
+ * Manages user approval for tool execution.
+ * Tools that modify system state require explicit user approval.
+ */
+class ToolApprovalManager {
+ private val TAG = "ToolApprovalManager"
+
+ // Approval request timeout: 5 minutes
+ private val APPROVAL_TIMEOUT_MS = 5 * 60 * 1000L
+
+ // Tools that don't require approval (read-only and safe)
+ private val autoApprovedTools = setOf(
+ "read_file",
+ "list_files",
+ "search_project",
+ "open_file",
+ "read_build_output",
+ "gradle_sync",
+ "generate_from_template",
+ "get_current_datetime"
+ )
+
+ // Tools approved for this session
+ private val sessionApprovedTools = mutableSetOf()
+
+ // Pending approval request
+ private var pendingApproval: CompletableDeferred? = null
+ private var currentApprovalRequest: ApprovalRequest? = null
+
+ /**
+ * Check if a tool needs approval and request it if needed.
+ * @return ApprovalResponse with approved status and optional denial message
+ */
+ suspend fun ensureApproved(
+ toolName: String,
+ handler: ToolHandler,
+ args: Map
+ ): ApprovalResponse {
+ // Check if tool doesn't require approval
+ if (!handler.requiresApproval || autoApprovedTools.contains(toolName)) {
+ return ApprovalResponse(approved = true)
+ }
+
+ // Check if already approved for this session
+ if (sessionApprovedTools.contains(toolName)) {
+ return ApprovalResponse(approved = true)
+ }
+
+ // Request user approval
+ val request = ApprovalRequest(
+ toolName = toolName,
+ args = args,
+ description = handler.description
+ )
+
+ currentApprovalRequest = request
+ pendingApproval = CompletableDeferred()
+
+ Log.d(TAG, "Requesting approval for $toolName (timeout: ${APPROVAL_TIMEOUT_MS}ms)")
+
+ // Wait for user decision with timeout
+ val result = withTimeoutOrNull(APPROVAL_TIMEOUT_MS) {
+ pendingApproval!!.await()
+ }
+
+ currentApprovalRequest = null
+ pendingApproval = null
+
+ // Handle timeout or decision
+ return when (result) {
+ ApprovalResult.APPROVED_ONCE -> {
+ Log.d(TAG, "Approval granted (once) for $toolName")
+ ApprovalResponse(approved = true)
+ }
+ ApprovalResult.APPROVED_FOR_SESSION -> {
+ Log.d(TAG, "Approval granted (session) for $toolName")
+ sessionApprovedTools.add(toolName)
+ ApprovalResponse(approved = true)
+ }
+ ApprovalResult.DENIED -> {
+ Log.d(TAG, "Approval denied for $toolName")
+ ApprovalResponse(
+ approved = false,
+ denialMessage = "User denied permission to execute $toolName"
+ )
+ }
+ null -> {
+ // Timeout occurred
+ Log.w(TAG, "Approval request timed out after ${APPROVAL_TIMEOUT_MS}ms for $toolName")
+ ApprovalResponse(
+ approved = false,
+ denialMessage = "Approval request timed out (no response within 5 minutes). Please try again."
+ )
+ }
+ }
+ }
+
+ /**
+ * Get the current pending approval request, if any.
+ */
+ fun getCurrentApprovalRequest(): ApprovalRequest? {
+ return currentApprovalRequest
+ }
+
+ /**
+ * Submit user's approval decision.
+ */
+ fun submitApproval(result: ApprovalResult) {
+ if (pendingApproval?.isActive == true) {
+ pendingApproval?.complete(result)
+ Log.d(TAG, "Approval decision submitted: $result")
+ }
+ }
+
+ /**
+ * Cancel the pending approval request.
+ * Useful when user wants to stop waiting for approval.
+ */
+ fun cancelPendingApproval() {
+ if (pendingApproval?.isActive == true) {
+ pendingApproval?.complete(ApprovalResult.DENIED)
+ Log.d(TAG, "Pending approval cancelled by user")
+ }
+ }
+
+ /**
+ * Check if there's a pending approval request.
+ */
+ fun hasPendingApproval(): Boolean {
+ return currentApprovalRequest != null && pendingApproval?.isActive == true
+ }
+
+ /**
+ * Clear all session-approved tools.
+ */
+ fun clearSessionApprovals() {
+ sessionApprovedTools.clear()
+ Log.d(TAG, "Session approvals cleared")
+ }
+}
+
+/**
+ * Result of an approval request.
+ */
+data class ApprovalResponse(
+ val approved: Boolean,
+ val denialMessage: String? = null
+)
+
+/**
+ * Pending approval request.
+ */
+data class ApprovalRequest(
+ val toolName: String,
+ val args: Map,
+ val description: String
+)
+
+/**
+ * User's approval decision.
+ */
+enum class ApprovalResult {
+ APPROVED_ONCE,
+ APPROVED_FOR_SESSION,
+ DENIED
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractor.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractor.kt
new file mode 100644
index 00000000..1471ab8e
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractor.kt
@@ -0,0 +1,312 @@
+package com.itsaky.androidide.plugins.aiassistant.tool
+
+import android.util.Log
+import org.json.JSONObject
+
+/**
+ * Extracts tool calls from LLM responses using multiple strategies:
+ * 1. Explicit XML tags: {"tool":"name",...}
+ * 2. JSON blocks: {"tool":"name",...}
+ * 3. Implicit actions: Detects from natural language patterns
+ *
+ * Works with both cloud (Gemini) and local LLMs.
+ */
+class ToolCallExtractor {
+ companion object {
+ private const val TAG = "ToolCallExtractor"
+
+ /**
+ * Extract all tool calls from response text using multiple strategies.
+ */
+ fun extractToolCalls(text: String): List {
+ val toolCalls = mutableListOf()
+
+ Log.d(TAG, "Extracting tool calls from response (${text.length} chars)")
+ Log.d(TAG, "Response preview: ${text.take(300)}")
+
+ // Strategy 1: Explicit XML tags
+ toolCalls.addAll(extractFromXmlTags(text))
+
+ // Strategy 2: Bare JSON objects if no XML found
+ if (toolCalls.isEmpty()) {
+ toolCalls.addAll(extractFromJsonObjects(text))
+ }
+
+ // Strategy 3: Implicit actions from natural language
+ if (toolCalls.isEmpty()) {
+ toolCalls.addAll(extractImplicitActions(text))
+ }
+
+ Log.d(TAG, "Extracted ${toolCalls.size} tool calls from response (${text.length} chars)")
+
+ // Warn if we found incomplete tool calls
+ if (text.contains("") && text.count { it == '<' } > text.count { it == '>' }) {
+ Log.w(TAG, "WARNING: Found incomplete tool call tags in response. Response may have been truncated.")
+ Log.w(TAG, "Full response: $text")
+ }
+
+ return toolCalls
+ }
+
+ /**
+ * Strategy 1: Extract explicit tool calls from XML tags.
+ * Format: {"tool":"name","args":{...}}
+ */
+ private fun extractFromXmlTags(text: String): List {
+ val toolCalls = mutableListOf()
+ val regex = Regex("""\s*(.+?)\s* """, RegexOption.DOT_MATCHES_ALL)
+ val matches = regex.findAll(text)
+
+ Log.d(TAG, "Strategy 1 (XML tags): Found ${matches.count()} matches")
+
+ for (match in matches) {
+ val parsed = parseToolJson(match.groupValues[1].trim())
+ if (parsed != null) {
+ toolCalls.add(parsed)
+ }
+ }
+
+ return toolCalls
+ }
+
+ /**
+ * Strategy 2: Extract tool calls from bare JSON objects.
+ * Format: {"tool":"name","args":{...}}
+ * Uses brace-balanced extraction to handle nested args objects.
+ */
+ private fun extractFromJsonObjects(text: String): List {
+ val toolCalls = mutableListOf()
+ var found = 0
+
+ // Find JSON objects with balanced braces containing "tool" field
+ var i = 0
+ while (i < text.length) {
+ if (text[i] == '{') {
+ // Try to extract a balanced JSON object
+ var braceCount = 0
+ var j = i
+ var hasToolField = false
+
+ while (j < text.length) {
+ if (text[j] == '{') braceCount++
+ else if (text[j] == '}') braceCount--
+
+ // Check if this substring contains "tool"
+ if (!hasToolField && text.substring(i, minOf(j + 1, text.length)).contains("\"tool\"")) {
+ hasToolField = true
+ }
+
+ j++
+
+ if (braceCount == 0) {
+ // Found complete object
+ if (hasToolField) {
+ val jsonStr = text.substring(i, j)
+ val parsed = parseToolJson(jsonStr)
+ if (parsed != null) {
+ toolCalls.add(parsed)
+ found++
+ }
+ }
+ break
+ }
+ }
+
+ i = j
+ } else {
+ i++
+ }
+ }
+
+ Log.d(TAG, "Strategy 2 (JSON objects): Found $found matches")
+
+ return toolCalls
+ }
+
+ /**
+ * Strategy 3: Extract implicit tool calls from natural language.
+ * Detects action keywords and converts to tool calls.
+ */
+ private fun extractImplicitActions(text: String): List {
+ val toolCalls = mutableListOf()
+ val lowerText = text.lowercase()
+
+ Log.d(TAG, "Strategy 3 (Implicit actions): Analyzing for action keywords")
+
+ // Patterns for list_files
+ if (matchesPattern(lowerText, listOf("list", "show"), listOf("file", "directory", "folder"))) {
+ val directory = extractDirectory(text) ?: "."
+ toolCalls.add(ToolCall("list_files", mapOf("directory" to directory)))
+ Log.d(TAG, "Detected: list_files action")
+ }
+
+ // Patterns for read_file
+ if (matchesPattern(lowerText, listOf("read", "open", "view", "show"), listOf("file"))) {
+ val path = extractFilePath(text)
+ if (path != null) {
+ toolCalls.add(ToolCall("read_file", mapOf("path" to path)))
+ Log.d(TAG, "Detected: read_file action for $path")
+ }
+ }
+
+ // Patterns for search_project
+ if (matchesPattern(lowerText, listOf("search", "find", "grep"), listOf("file", "code", "project"))) {
+ val query = extractSearchQuery(text)
+ if (query != null) {
+ toolCalls.add(ToolCall("search_project", mapOf("query" to query)))
+ Log.d(TAG, "Detected: search_project action for query: $query")
+ }
+ }
+
+ // Patterns for create_file
+ if (matchesPattern(lowerText, listOf("create", "write", "make"), listOf("file"))) {
+ // Requires more context, generally not auto-triggered
+ Log.d(TAG, "Detected: create_file action (requires confirmation)")
+ }
+
+ // Patterns for run_app
+ if (matchesPattern(lowerText, listOf("run", "launch", "build", "start"), listOf("app", "application"))) {
+ toolCalls.add(ToolCall("run_app", emptyMap()))
+ Log.d(TAG, "Detected: run_app action")
+ }
+
+ return toolCalls
+ }
+
+ /**
+ * Parse tool JSON and extract tool name and arguments.
+ */
+ private fun parseToolJson(jsonStr: String): ToolCall? {
+ return try {
+ val json = JSONObject(jsonStr)
+ val toolName = json.optString("tool").ifEmpty { json.optString("name") }
+ if (toolName.isEmpty()) {
+ Log.w(TAG, "Tool JSON has neither 'tool' nor 'name': $jsonStr")
+ return null
+ }
+ val argsJson = json.optJSONObject("args") ?: json.optJSONObject("arguments") ?: JSONObject()
+
+ val args = mutableMapOf()
+ val keys = argsJson.keys()
+ while (keys.hasNext()) {
+ val key = keys.next()
+ args[key] = argsJson.get(key)
+ }
+
+ Log.d(TAG, "Parsed tool call: $toolName with args: $args")
+ ToolCall(toolName, args)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to parse tool JSON: $jsonStr", e)
+ null
+ }
+ }
+
+ /**
+ * Check if text matches action pattern (verb + object).
+ */
+ private fun matchesPattern(text: String, verbs: List, objects: List): Boolean {
+ val hasVerb = verbs.any { text.contains(it) }
+ val hasObject = objects.any { text.contains(it) }
+ return hasVerb && hasObject
+ }
+
+ /**
+ * Extract directory path from natural language.
+ * Looks for explicit paths or common directory names.
+ */
+ private fun extractDirectory(text: String): String? {
+ val lowerText = text.lowercase()
+
+ // Check for common project directories
+ val commonDirs = mapOf(
+ "src" to "src",
+ "source" to "src",
+ "source code" to "src",
+ "main" to "src/main",
+ "java" to "src/main/java",
+ "kotlin" to "src/main/kotlin",
+ "resources" to "src/main/resources",
+ "test" to "src/test",
+ "root" to ".",
+ "project" to ".",
+ "current" to "."
+ )
+
+ for ((keyword, dir) in commonDirs) {
+ if (lowerText.contains(keyword)) {
+ Log.d(TAG, "Detected directory from keyword '$keyword': $dir")
+ return dir
+ }
+ }
+
+ // Look for patterns like "in src", "in ./src", "in /path/to/dir", etc.
+ val patterns = listOf(
+ Regex("""(?:in|from)\s+(?:the\s+)?["`']?([/.\-\w]+)["`']?"""),
+ Regex("""directory\s+(?:of\s+)?["`']?([/.\-\w]+)["`']?"""),
+ Regex("""folder\s+["`']?([/.\-\w]+)["`']?"""),
+ Regex("""path\s+["`']?([/.\-\w]+)["`']?""")
+ )
+
+ for (pattern in patterns) {
+ val match = pattern.find(text)
+ if (match != null) {
+ val dir = match.groupValues[1]
+ if (dir.isNotEmpty() && dir.length > 1 && !dir.contains("the")) {
+ Log.d(TAG, "Extracted directory from pattern: $dir")
+ return dir
+ }
+ }
+ }
+
+ // Default to current directory if no specific directory mentioned
+ Log.d(TAG, "No specific directory found, defaulting to current directory (.)")
+ return null // Will default to "." in ListFilesHandler
+ }
+
+ /**
+ * Extract file path from natural language.
+ */
+ private fun extractFilePath(text: String): String? {
+ // Look for patterns like "MainActivity.kt", "src/main/MainActivity.kt", etc.
+ val patterns = listOf(
+ Regex("""["`']([^"`'\s]+\.kt)["`']"""),
+ Regex("""file\s+["`']?([^"`'\s]+\.kt)["`']?"""),
+ Regex("""read\s+["`']?([^"`'\s]+)["`']?""")
+ )
+
+ for (pattern in patterns) {
+ val match = pattern.find(text)
+ if (match != null) {
+ val path = match.groupValues[1]
+ if (path.isNotEmpty() && !path.contains("the")) {
+ return path
+ }
+ }
+ }
+
+ return null
+ }
+
+ /**
+ * Extract search query from natural language.
+ */
+ private fun extractSearchQuery(text: String): String? {
+ val patterns = listOf(
+ Regex("""(?:search|find|grep)\s+(?:for\s+)?["`']([^"`']+)["`']"""),
+ Regex("""search\s+(?:for\s+)?([^\.?!]+)""")
+ )
+
+ for (pattern in patterns) {
+ val match = pattern.find(text)
+ if (match != null) {
+ val query = match.groupValues[1].trim()
+ if (query.isNotEmpty() && query.length > 2) {
+ return query
+ }
+ }
+ }
+
+ return null
+ }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolHandler.kt
new file mode 100644
index 00000000..6e85aef3
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolHandler.kt
@@ -0,0 +1,40 @@
+package com.itsaky.androidide.plugins.aiassistant.tool
+
+import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
+
+/**
+ * Interface for handling tool execution.
+ */
+interface ToolHandler {
+ /**
+ * The unique name of this tool.
+ */
+ val toolName: String
+
+ /**
+ * Description of what this tool does.
+ */
+ val description: String
+
+ /**
+ * Execute the tool with the given arguments.
+ *
+ * @param args Map of argument names to values
+ * @return ToolResult indicating success or failure
+ */
+ suspend fun execute(args: Map): ToolResult
+
+ /**
+ * Whether this tool requires user approval before execution.
+ */
+ val requiresApproval: Boolean
+ get() = false
+
+ /**
+ * Arg keys whose values are filesystem paths. The Executor verifies each of
+ * these resolves within the project root before the tool runs, so no handler
+ * can forget the containment check. Empty by default (non-filesystem tools).
+ */
+ val pathArgs: List
+ get() = emptyList()
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolRouter.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolRouter.kt
new file mode 100644
index 00000000..2d8a5c19
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolRouter.kt
@@ -0,0 +1,54 @@
+package com.itsaky.androidide.plugins.aiassistant.tool
+
+import android.util.Log
+import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
+
+/**
+ * Routes tool calls to appropriate handlers.
+ */
+class ToolRouter(
+ private val handlers: List
+) {
+ private val TAG = "ToolRouter"
+ private val handlerMap: Map = handlers.associateBy { it.toolName }
+
+ /**
+ * Get the handler for a given tool name.
+ */
+ fun getHandler(toolName: String): ToolHandler? {
+ return handlerMap[toolName]
+ }
+
+ /**
+ * Dispatch a tool call to its handler.
+ */
+ suspend fun dispatch(toolName: String, args: Map): ToolResult {
+ val handler = getHandler(toolName)
+ if (handler == null) {
+ Log.e(TAG, "No handler found for tool: $toolName")
+ return ToolResult.failure("Unknown tool: $toolName")
+ }
+
+ return try {
+ Log.d(TAG, "Dispatching $toolName with args: $args")
+ handler.execute(args)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error executing tool $toolName", e)
+ ToolResult.failure("Error executing $toolName: ${e.message}", e.stackTraceToString())
+ }
+ }
+
+ /**
+ * Get all available tool names.
+ */
+ fun getAvailableTools(): List {
+ return handlerMap.keys.toList()
+ }
+
+ /**
+ * Get all registered tool handlers.
+ */
+ fun getAllHandlers(): List {
+ return handlers
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/AddDependencyHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/AddDependencyHandler.kt
new file mode 100644
index 00000000..46d71f03
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/AddDependencyHandler.kt
@@ -0,0 +1,59 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers
+
+import android.util.Log
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolHandler
+import com.itsaky.androidide.plugins.services.IdeProjectManipulationService
+
+/**
+ * Handler for adding dependencies to the project build file.
+ */
+class AddDependencyHandler(
+ private val pluginContext: PluginContext
+) : ToolHandler {
+ override val toolName = "add_dependency"
+ override val description = "Add a Maven dependency to the project build file"
+ override val requiresApproval = true
+
+ override suspend fun execute(args: Map): ToolResult {
+ val dependency = args["dependency"]?.toString()?.trim()
+ if (dependency.isNullOrBlank()) {
+ return ToolResult.failure("dependency is required (e.g., 'com.squareup.retrofit2:retrofit:2.9.0')")
+ }
+
+ val buildFile = args["build_file"]?.toString()?.trim()
+ ?: "app/build.gradle.kts" // Default to app module
+
+ Log.d("AddDependencyHandler", "Adding dependency: $dependency to $buildFile")
+
+ return try {
+ val service = pluginContext.services.get(IdeProjectManipulationService::class.java)
+ if (service == null) {
+ Log.w("AddDependencyHandler", "IdeProjectManipulationService not available")
+ return ToolResult.failure("Project manipulation service not available")
+ }
+
+ val success = service.addDependency(dependency, buildFile)
+ if (success) {
+ Log.d("AddDependencyHandler", "Dependency added successfully: $dependency")
+ ToolResult.success(
+ message = "Added dependency: $dependency",
+ data = "Dependency added to $buildFile. Run gradle_sync to reload."
+ )
+ } else {
+ Log.w("AddDependencyHandler", "Failed to add dependency: $dependency")
+ ToolResult.failure(
+ "Failed to add dependency",
+ "Could not add $dependency to $buildFile. Check the build file format."
+ )
+ }
+ } catch (e: Exception) {
+ Log.e("AddDependencyHandler", "Error adding dependency", e)
+ ToolResult.failure(
+ "Error adding dependency",
+ "${e.message ?: "Unknown error"}\n\n${e.stackTraceToString()}"
+ )
+ }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/CreateFileHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/CreateFileHandler.kt
new file mode 100644
index 00000000..da8b7f68
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/CreateFileHandler.kt
@@ -0,0 +1,67 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers
+
+import android.util.Log
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolHandler
+
+/**
+ * Handler for creating new files.
+ */
+class CreateFileHandler(
+ private val pluginContext: PluginContext
+) : ToolHandler {
+ override val toolName = "create_file"
+ override val description = "Create a new file with given content"
+ override val requiresApproval = true // Requires approval for file creation
+ override val pathArgs = listOf("file_path")
+
+ override suspend fun execute(args: Map): ToolResult {
+ var filePath = args["file_path"]?.toString()?.trim()
+ val content = args["content"]?.toString() ?: ""
+
+ if (filePath.isNullOrBlank()) {
+ return ToolResult.failure("file_path is required")
+ }
+
+ return try {
+ Log.d("CreateFileHandler", "Creating file: $filePath")
+
+ // Security: resolve against the project root and reject any escape.
+ val file = PathGuard.resolveWithin(filePath)
+ ?: return ToolResult.failure("File path must be within project directory")
+
+ Log.d("CreateFileHandler", "Resolved path: ${file.absolutePath}")
+
+ if (file.exists()) {
+ Log.w("CreateFileHandler", "File already exists: ${file.absolutePath}")
+ return ToolResult.failure("File already exists: $filePath")
+ }
+
+ // Create parent directories if needed
+ val parentDir = file.parentFile
+ if (parentDir != null && !parentDir.exists()) {
+ Log.d("CreateFileHandler", "Creating parent directories: ${parentDir.absolutePath}")
+ val created = parentDir.mkdirs()
+ Log.d("CreateFileHandler", "Parent directories creation result: $created")
+ }
+
+ // Write content
+ Log.d("CreateFileHandler", "Writing ${content.length} characters to file")
+ file.writeText(content)
+
+ Log.d("CreateFileHandler", "File created successfully: ${file.absolutePath}")
+
+ ToolResult.success(
+ message = "Created file: $filePath (${content.length} characters)",
+ data = file.absolutePath
+ )
+ } catch (e: Exception) {
+ Log.e("CreateFileHandler", "Error creating file at $filePath", e)
+ ToolResult.failure(
+ "Error creating file: ${e.message}",
+ "Path: $filePath\nError: ${e.stackTraceToString()}"
+ )
+ }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/GenerateFromTemplateHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/GenerateFromTemplateHandler.kt
new file mode 100644
index 00000000..a5c9f75b
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/GenerateFromTemplateHandler.kt
@@ -0,0 +1,91 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers
+
+import android.util.Log
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolHandler
+import com.itsaky.androidide.plugins.services.IdeTemplateService
+import org.json.JSONObject
+
+/**
+ * Handler for generating files from Pebble templates.
+ */
+class GenerateFromTemplateHandler(
+ private val pluginContext: PluginContext
+) : ToolHandler {
+ override val toolName = "generate_from_template"
+ override val description = "Generate files from Pebble templates with variable substitution"
+ override val requiresApproval = false
+
+ override suspend fun execute(args: Map): ToolResult {
+ val templateName = args["template_name"]?.toString()?.trim()
+ if (templateName.isNullOrBlank()) {
+ return ToolResult.failure(
+ "template_name is required",
+ "Provide a template name like 'activity', 'fragment', 'recycler_item', etc."
+ )
+ }
+
+ // Variables is a map for substitution
+ @Suppress("UNCHECKED_CAST")
+ val variables = (args["variables"] as? Map) ?: emptyMap()
+
+ Log.d("GenerateFromTemplateHandler", "Generating from template: $templateName with ${variables.size} variables")
+
+ return try {
+ val templateService = pluginContext.services.get(IdeTemplateService::class.java)
+ if (templateService == null) {
+ Log.w("GenerateFromTemplateHandler", "IdeTemplateService not available")
+ return ToolResult.failure(
+ "Template service not available",
+ "The IDE template service is not available. Templates may not be registered."
+ )
+ }
+
+ // Get available templates
+ val registeredTemplates = templateService.getRegisteredTemplates()
+ Log.d("GenerateFromTemplateHandler", "Available templates: $registeredTemplates")
+
+ if (registeredTemplates.isEmpty()) {
+ return ToolResult.failure(
+ "No templates available",
+ "No Pebble templates are registered. Check template configuration."
+ )
+ }
+
+ // Look for matching template file
+ val matchingTemplate = registeredTemplates.firstOrNull { it.contains(templateName, ignoreCase = true) }
+ if (matchingTemplate == null) {
+ return ToolResult.failure(
+ "Template not found: $templateName",
+ "Available templates: ${registeredTemplates.joinToString(", ")}"
+ )
+ }
+
+ Log.d("GenerateFromTemplateHandler", "Found template: $matchingTemplate")
+
+ // Note: Actual template execution requires CgtTemplateBuilder integration
+ // For now, return a note that user should use the template directly from Plugin Manager
+ ToolResult.success(
+ message = "Template located: $matchingTemplate",
+ data = buildString {
+ append("Template '$templateName' is available.\n\n")
+ append("To use this template:\n")
+ append("1. Go to Plugin Manager → Templates\n")
+ append("2. Select template: $matchingTemplate\n")
+ append("3. Provide variables:\n")
+ variables.forEach { (k, v) ->
+ append(" - $k = $v\n")
+ }
+ append("\nAlternatively, use create_file to generate content directly.")
+ }
+ )
+ } catch (e: Exception) {
+ Log.e("GenerateFromTemplateHandler", "Error generating from template", e)
+ ToolResult.failure(
+ "Error with template",
+ "${e.message ?: "Unknown error"}\n\n${e.stackTraceToString()}"
+ )
+ }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/GradleSyncHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/GradleSyncHandler.kt
new file mode 100644
index 00000000..d55b70a3
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/GradleSyncHandler.kt
@@ -0,0 +1,76 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers
+
+import android.util.Log
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolHandler
+import com.itsaky.androidide.plugins.services.GradleSyncCallback
+import com.itsaky.androidide.plugins.services.IdeBuildService
+import java.util.concurrent.CompletableFuture
+import kotlinx.coroutines.delay
+
+/**
+ * Handler for triggering Gradle project sync.
+ */
+class GradleSyncHandler(
+ private val pluginContext: PluginContext
+) : ToolHandler {
+ override val toolName = "gradle_sync"
+ override val description = "Sync the Gradle project (reload dependencies and rebuild cache)"
+ override val requiresApproval = false
+
+ override suspend fun execute(args: Map): ToolResult {
+ Log.d("GradleSyncHandler", "Gradle sync requested")
+
+ return try {
+ val buildService = pluginContext.services.get(IdeBuildService::class.java)
+ if (buildService == null) {
+ Log.w("GradleSyncHandler", "IdeBuildService not available")
+ return ToolResult.failure(
+ "Build service not available",
+ "The IDE build service is not available."
+ )
+ }
+
+ // Track both success flag and output message
+ val syncComplete = CompletableFuture>()
+
+ Log.d("GradleSyncHandler", "Triggering gradle sync...")
+ buildService.triggerGradleSync(object : GradleSyncCallback {
+ override fun onComplete(success: Boolean, output: String) {
+ Log.d("GradleSyncHandler", "Sync completed: success=$success, output length=${output.length}")
+ val message = if (success) {
+ output.ifEmpty { "Gradle sync completed successfully" }
+ } else {
+ "Gradle sync failed:\n$output"
+ }
+ syncComplete.complete(Pair(success, message))
+ }
+ })
+
+ // Wait for sync to complete (with timeout of 2 minutes)
+ val (syncSuccess, result) = try {
+ syncComplete.get(120_000, java.util.concurrent.TimeUnit.MILLISECONDS)
+ } catch (e: Exception) {
+ Log.w("GradleSyncHandler", "Gradle sync timed out or failed: ${e.message}")
+ Pair(false, "Gradle sync in progress or timed out (may take 1-2 minutes)")
+ }
+
+ // Return success or failure based on actual sync result
+ if (syncSuccess) {
+ ToolResult.success(
+ message = "Gradle sync completed",
+ data = result
+ )
+ } else {
+ ToolResult.failure(result)
+ }
+ } catch (e: Exception) {
+ Log.e("GradleSyncHandler", "Error triggering gradle sync", e)
+ ToolResult.failure(
+ "Error triggering sync",
+ "${e.message ?: "Unknown error"}\n\n${e.stackTraceToString()}"
+ )
+ }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ListFilesHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ListFilesHandler.kt
new file mode 100644
index 00000000..9e6e24b1
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ListFilesHandler.kt
@@ -0,0 +1,122 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers
+
+import android.util.Log
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolHandler
+import java.io.File
+
+/**
+ * Handler for listing files in a directory.
+ */
+class ListFilesHandler(
+ private val pluginContext: PluginContext
+) : ToolHandler {
+ override val toolName = "list_files"
+ override val description = "List files and directories in a given path"
+ override val requiresApproval = false
+
+ override suspend fun execute(args: Map): ToolResult {
+ var directory = args["directory"]?.toString()?.trim()?.takeIf { it.isNotBlank() }
+
+ // Get project root for containment check
+ val projectRoot = System.getProperty("project.dir")
+ ?: System.getProperty("user.dir")
+ ?: "/storage/emulated/0/AndroidIDEProjects"
+ val projectRootCanonical = File(projectRoot).canonicalPath
+
+ // If no directory specified, use project root
+ if (directory.isNullOrBlank()) {
+ directory = projectRoot
+ Log.d("ListFilesHandler", "No directory specified, using project root: $directory")
+ }
+
+ Log.d("ListFilesHandler", "Listing files in directory: $directory")
+
+ return try {
+ // Resolve path against project root if relative
+ val dir = if (directory.startsWith("/")) {
+ File(directory).absoluteFile
+ } else {
+ File(projectRoot, directory).absoluteFile
+ }
+ Log.d("ListFilesHandler", "Absolute path: ${dir.absolutePath}")
+
+ // Security: Verify directory is within project root
+ val dirCanonical = dir.canonicalPath
+ if (!dirCanonical.startsWith(projectRootCanonical + File.separator) && dirCanonical != projectRootCanonical) {
+ Log.e("ListFilesHandler", "Path escape attempt: $dirCanonical is outside project root $projectRootCanonical")
+ return ToolResult.failure("Directory path must be within project directory")
+ }
+
+ if (!dir.exists()) {
+ Log.w("ListFilesHandler", "Directory does not exist: ${dir.absolutePath}")
+ return ToolResult.failure("Directory does not exist: ${dir.absolutePath}")
+ }
+
+ if (!dir.isDirectory) {
+ Log.w("ListFilesHandler", "Path is not a directory: ${dir.absolutePath}")
+ return ToolResult.failure("Path is not a directory: ${dir.absolutePath}")
+ }
+
+ val fileList = dir.listFiles()?.sortedBy { it.name } ?: emptyList()
+
+ Log.d("ListFilesHandler", "Found ${fileList.size} items")
+
+ if (fileList.isEmpty()) {
+ Log.w("ListFilesHandler", "Directory is empty: ${dir.absolutePath}")
+ return ToolResult.success(
+ message = "Directory is empty: ${dir.absolutePath}",
+ data = "(no files or directories)"
+ )
+ }
+
+ // Format files and directories separately for better organization
+ val directories = fileList.filter { it.isDirectory }
+ val regularFiles = fileList.filter { it.isFile }
+
+ val formatted = buildString {
+ appendLine("📁 DIRECTORIES (${directories.size}):")
+ directories.forEach { dir ->
+ appendLine(" ├── ${dir.name}/")
+ }
+
+ if (directories.isNotEmpty() && regularFiles.isNotEmpty()) {
+ appendLine()
+ }
+
+ appendLine("📄 FILES (${regularFiles.size}):")
+ regularFiles.forEach { file ->
+ val size = formatSize(file.length())
+ val sizeStr = String.format("%-8s", size) // Right-pad for alignment
+ appendLine(" ├── ${file.name} ($sizeStr)")
+ }
+ }
+
+ ToolResult.success(
+ message = "Found ${fileList.size} items in ${dir.absolutePath}",
+ data = formatted
+ )
+ } catch (e: Exception) {
+ Log.e("ListFilesHandler", "Error listing files in $directory", e)
+ ToolResult.failure("Error listing files: ${e.message}", e.stackTraceToString())
+ }
+ }
+
+ private fun formatSize(bytes: Long): String {
+ return when {
+ bytes <= 0 -> "0 B"
+ bytes < 1024 -> "$bytes B"
+ bytes < 1024 * 1024 -> "${bytes / 1024} KB"
+ bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB"
+ else -> "${bytes / (1024 * 1024 * 1024)} GB"
+ }
+ }
+
+ private fun findDefaultDirectory(): String {
+ // Return project root only for security
+ return System.getProperty("project.dir")
+ ?: System.getProperty("user.dir")
+ ?: "/storage/emulated/0/AndroidIDEProjects"
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/OpenFileHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/OpenFileHandler.kt
new file mode 100644
index 00000000..1ffaeb08
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/OpenFileHandler.kt
@@ -0,0 +1,78 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers
+
+import android.util.Log
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolHandler
+import com.itsaky.androidide.plugins.services.IdeEditorService
+
+/**
+ * Handler for opening files in the IDE editor.
+ */
+class OpenFileHandler(
+ private val pluginContext: PluginContext
+) : ToolHandler {
+ override val toolName = "open_file"
+ override val description = "Open a file in the IDE editor"
+ override val requiresApproval = false
+ override val pathArgs = listOf("file_path")
+
+ override suspend fun execute(args: Map): ToolResult {
+ val filePath = args["file_path"]?.toString()?.trim()
+ if (filePath.isNullOrBlank()) {
+ return ToolResult.failure("file_path is required")
+ }
+
+ Log.d("OpenFileHandler", "Opening file: $filePath")
+
+ return try {
+ val file = PathGuard.resolveWithin(filePath)
+ ?: return ToolResult.failure("File path must be within project directory")
+ if (!file.exists()) {
+ Log.w("OpenFileHandler", "File does not exist: $filePath")
+ return ToolResult.failure(
+ "File not found",
+ "File does not exist: $filePath"
+ )
+ }
+
+ if (!file.isFile) {
+ Log.w("OpenFileHandler", "Path is not a file: $filePath")
+ return ToolResult.failure(
+ "Not a file",
+ "Path is a directory, not a file: $filePath"
+ )
+ }
+
+ val editorService = pluginContext.services.get(IdeEditorService::class.java)
+ if (editorService == null) {
+ Log.w("OpenFileHandler", "IdeEditorService not available")
+ return ToolResult.failure(
+ "Editor service not available",
+ "The IDE editor service is not available."
+ )
+ }
+
+ val success = editorService.openFile(file)
+ if (success) {
+ Log.d("OpenFileHandler", "File opened successfully: $filePath")
+ ToolResult.success(
+ message = "Opened file in editor",
+ data = filePath
+ )
+ } else {
+ Log.w("OpenFileHandler", "Failed to open file: $filePath")
+ ToolResult.failure(
+ "Failed to open file",
+ "Could not open $filePath in the editor."
+ )
+ }
+ } catch (e: Exception) {
+ Log.e("OpenFileHandler", "Error opening file", e)
+ ToolResult.failure(
+ "Error opening file",
+ "${e.message ?: "Unknown error"}\n\n${e.stackTraceToString()}"
+ )
+ }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/PathGuard.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/PathGuard.kt
new file mode 100644
index 00000000..ea8cfbcf
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/PathGuard.kt
@@ -0,0 +1,42 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers
+
+import android.util.Log
+import java.io.File
+
+/**
+ * Shared path-containment guard for filesystem tool handlers.
+ *
+ * The LLM-driven tools resolve paths supplied by the model, so every handler
+ * that touches the filesystem must confine those paths to the current project
+ * root to prevent a prompt-injected model from reading or writing arbitrary
+ * files. Previously this check lived (duplicated) only in CreateFileHandler and
+ * ReadFileHandler; it is now centralized here and applied to every handler.
+ */
+object PathGuard {
+
+ private const val TAG = "PathGuard"
+
+ /** Best-effort resolution of the active project root. */
+ fun projectRoot(): String =
+ System.getProperty("project.dir")
+ ?: System.getProperty("user.dir")
+ ?: "/storage/emulated/0/AndroidIDEProjects"
+
+ /**
+ * Resolve [path] against the project root and verify the canonical result is
+ * inside that root.
+ *
+ * @return the resolved [File] when it is contained in the project root, or
+ * `null` when [path] escapes it (caller should reject the request).
+ */
+ fun resolveWithin(path: String): File? {
+ val root = File(projectRoot()).canonicalPath
+ val file = if (path.startsWith("/")) File(path) else File(projectRoot(), path)
+ val canonical = file.canonicalPath
+ val contained = canonical == root || canonical.startsWith(root + File.separator)
+ if (!contained) {
+ Log.e(TAG, "Path escape attempt: $canonical is outside project root $root")
+ }
+ return if (contained) file else null
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ReadBuildOutputHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ReadBuildOutputHandler.kt
new file mode 100644
index 00000000..9bc66f0a
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ReadBuildOutputHandler.kt
@@ -0,0 +1,61 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers
+
+import android.util.Log
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolHandler
+import com.itsaky.androidide.plugins.services.IdeBuildService
+
+/**
+ * Handler for reading the current build output.
+ */
+class ReadBuildOutputHandler(
+ private val pluginContext: PluginContext
+) : ToolHandler {
+ override val toolName = "read_build_output"
+ override val description = "Read the current build output and status"
+ override val requiresApproval = false
+
+ override suspend fun execute(args: Map): ToolResult {
+ Log.d("ReadBuildOutputHandler", "Reading build output")
+
+ return try {
+ val buildService = pluginContext.services.get(IdeBuildService::class.java)
+ if (buildService == null) {
+ Log.w("ReadBuildOutputHandler", "IdeBuildService not available")
+ return ToolResult.failure(
+ "Build service not available",
+ "The IDE build service is not available."
+ )
+ }
+
+ val output = buildService.getBuildOutput()
+ if (output.isNullOrBlank()) {
+ Log.d("ReadBuildOutputHandler", "No build output available")
+ ToolResult.success(
+ message = "No build output available",
+ data = "(No recent build output)"
+ )
+ } else {
+ // Truncate to last 2000 chars to avoid overwhelming the LLM
+ val truncated = if (output.length > 2000) {
+ "...[truncated]...\n" + output.takeLast(2000)
+ } else {
+ output
+ }
+
+ Log.d("ReadBuildOutputHandler", "Read ${truncated.length} chars of build output")
+ ToolResult.success(
+ message = "Build output (last ${truncated.length} characters)",
+ data = truncated
+ )
+ }
+ } catch (e: Exception) {
+ Log.e("ReadBuildOutputHandler", "Error reading build output", e)
+ ToolResult.failure(
+ "Error reading build output",
+ "${e.message ?: "Unknown error"}\n\n${e.stackTraceToString()}"
+ )
+ }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ReadFileHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ReadFileHandler.kt
new file mode 100644
index 00000000..b4502d99
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ReadFileHandler.kt
@@ -0,0 +1,49 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers
+
+import android.util.Log
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolHandler
+
+/**
+ * Handler for reading file contents.
+ */
+class ReadFileHandler(
+ private val pluginContext: PluginContext
+) : ToolHandler {
+ override val toolName = "read_file"
+ override val description = "Read the contents of a file"
+ override val requiresApproval = false
+ override val pathArgs = listOf("file_path", "path")
+
+ override suspend fun execute(args: Map): ToolResult {
+ // Accept both "file_path" (standardized) and "path" (legacy LLM responses)
+ val filePath = (args["file_path"] ?: args["path"])?.toString()?.trim()
+ if (filePath.isNullOrBlank()) {
+ return ToolResult.failure("file_path argument is required (or 'path' as fallback)")
+ }
+
+ return try {
+ // Security: resolve against the project root and reject any escape.
+ val file = PathGuard.resolveWithin(filePath)
+ ?: return ToolResult.failure("File path must be within project directory")
+
+ if (!file.exists()) {
+ ToolResult.failure("File does not exist: $filePath")
+ } else if (!file.isFile) {
+ ToolResult.failure("Path is not a file: $filePath")
+ } else if (!file.canRead()) {
+ ToolResult.failure("Cannot read file: $filePath")
+ } else {
+ val content = file.readText()
+ ToolResult.success(
+ message = "Read ${content.length} characters from $filePath",
+ data = content
+ )
+ }
+ } catch (e: Exception) {
+ Log.e("ReadFileHandler", "Error reading file", e)
+ ToolResult.failure("Error reading file: ${e.message}", e.stackTraceToString())
+ }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/RunAppHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/RunAppHandler.kt
new file mode 100644
index 00000000..bfbe7e7c
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/RunAppHandler.kt
@@ -0,0 +1,103 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers
+
+import android.util.Log
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolHandler
+import com.itsaky.androidide.plugins.services.BuildAndLaunchCallback
+import com.itsaky.androidide.plugins.services.IdeBuildService
+import kotlinx.coroutines.delay
+
+/**
+ * Handler for running/building the app.
+ */
+class RunAppHandler(
+ private val pluginContext: PluginContext
+) : ToolHandler {
+ override val toolName = "run_app"
+ override val description = "Build and run the Android app on the connected device or emulator"
+ // Build operation requires approval for safety
+ override val requiresApproval = true
+
+ override suspend fun execute(args: Map): ToolResult {
+ return try {
+ Log.d("RunAppHandler", "Run app tool called - v3 with retry")
+
+ val buildService = pluginContext.services.get(IdeBuildService::class.java)
+ if (buildService == null) {
+ Log.w("RunAppHandler", "IdeBuildService not available - service is null")
+ return ToolResult.failure(
+ "Build service not available",
+ "The IDE build service is not available. This IDE instance may not support build operations."
+ )
+ }
+
+ Log.d("RunAppHandler", "BuildService obtained successfully")
+
+ val buildInProgress = buildService.isBuildInProgress()
+ Log.d("RunAppHandler", "Build in progress: $buildInProgress")
+
+ if (buildInProgress) {
+ return ToolResult.failure(
+ "Build already running",
+ "A build is already in progress. Please wait for it to complete before running again."
+ )
+ }
+
+ // Retry waiting for tooling server with exponential backoff
+ val maxRetries = 10
+ var toolingStarted = false
+ var totalWaitTime = 0L
+ for (attempt in 1..maxRetries) {
+ toolingStarted = buildService.isToolingServerStarted()
+ Log.d("RunAppHandler", "Tooling server check (attempt $attempt/$maxRetries): $toolingStarted")
+
+ if (toolingStarted) {
+ Log.d("RunAppHandler", "Tooling server is now ready after $totalWaitTime ms")
+ break
+ }
+
+ if (attempt < maxRetries) {
+ val delayMs = 300L * attempt // 300ms, 600ms, 900ms, 1.2s, 1.5s, 1.8s, 2.1s, 2.4s, 2.7s
+ Log.d("RunAppHandler", "Tooling server not ready, waiting ${delayMs}ms before retry...")
+ delay(delayMs)
+ totalWaitTime += delayMs
+ }
+ }
+
+ if (!toolingStarted) {
+ Log.w("RunAppHandler", "Tooling server did not initialize within ${totalWaitTime + 300}ms, proceeding anyway (may fail)")
+ // Try to proceed anyway - the service might initialize during the build
+ Log.d("RunAppHandler", "Attempting to run app despite tooling server not being ready...")
+ }
+
+ Log.d("RunAppHandler", "Triggering app build and launch...")
+ return try {
+ // Trigger the build - fire and forget pattern
+ buildService.runApp(object : BuildAndLaunchCallback {
+ override fun onComplete(success: Boolean, message: String) {
+ Log.i("RunAppHandler", "Build callback: success=$success, message=$message")
+ }
+ })
+
+ Log.d("RunAppHandler", "Build triggered, returning success")
+ ToolResult.success(
+ message = "Build triggered successfully",
+ data = "The app build is now running in the background. Output will appear in the IDE's build panel."
+ )
+ } catch (e: Exception) {
+ Log.e("RunAppHandler", "Failed to trigger build: ${e.message}", e)
+ ToolResult.failure(
+ "Failed to trigger build",
+ "Error: ${e.message ?: "Unknown error"}"
+ )
+ }
+ } catch (e: Exception) {
+ Log.e("RunAppHandler", "Exception in run app tool", e)
+ return ToolResult.failure(
+ "Error: ${e.javaClass.simpleName}",
+ "${e.message ?: "Unknown error"}\n\n${e.stackTraceToString()}"
+ )
+ }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/SearchProjectHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/SearchProjectHandler.kt
new file mode 100644
index 00000000..652ef38d
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/SearchProjectHandler.kt
@@ -0,0 +1,112 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers
+
+import android.util.Log
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolHandler
+import java.io.File
+
+/**
+ * Handler for searching files in the project.
+ */
+class SearchProjectHandler(
+ private val pluginContext: PluginContext
+) : ToolHandler {
+ override val toolName = "search_project"
+ override val description = "Search for files by name or content in the project"
+ override val requiresApproval = false
+ override val pathArgs = listOf("project_dir")
+
+ override suspend fun execute(args: Map): ToolResult {
+ val query = args["query"]?.toString()?.trim()
+ if (query.isNullOrBlank()) {
+ return ToolResult.failure("query is required")
+ }
+
+ val projectDir = args["project_dir"]?.toString()?.trim()
+ val searchInContents = args["search_in_contents"]?.toString()?.toBoolean() ?: false
+
+ // Confine the search to the project root. Default to it, and reject any
+ // explicit project_dir that resolves outside it, so a prompt-injected
+ // model can't read/exfiltrate arbitrary files on external storage.
+ val searchRoot = if (projectDir.isNullOrBlank()) {
+ File(PathGuard.projectRoot())
+ } else {
+ PathGuard.resolveWithin(projectDir)
+ ?: return ToolResult.failure("Search directory must be within the project directory")
+ }
+
+ return try {
+ if (!searchRoot.exists() || !searchRoot.isDirectory) {
+ return ToolResult.failure("Invalid project directory: ${searchRoot.absolutePath}")
+ }
+
+ val results = mutableListOf()
+ searchFilesRecursive(searchRoot, query, results, searchInContents, maxResults = 100)
+
+ if (results.isEmpty()) {
+ ToolResult.success("No ${if (searchInContents) "content" else "files"} found matching '$query'", "")
+ } else {
+ ToolResult.success(
+ message = "Found ${results.size} match(es) for '$query'",
+ data = results.joinToString("\n")
+ )
+ }
+ } catch (e: Exception) {
+ Log.e("SearchProjectHandler", "Error searching project", e)
+ ToolResult.failure("Error searching project: ${e.message}", e.stackTraceToString())
+ }
+ }
+
+ private fun searchFilesRecursive(
+ dir: File,
+ query: String,
+ results: MutableList,
+ searchInContents: Boolean,
+ maxResults: Int,
+ depth: Int = 0
+ ) {
+ if (depth > 10 || results.size >= maxResults) return
+
+ dir.listFiles()?.forEach { file ->
+ if (results.size >= maxResults) return
+
+ if (file.isDirectory && !file.name.startsWith(".")) {
+ searchFilesRecursive(file, query, results, searchInContents, maxResults, depth + 1)
+ } else if (file.isFile) {
+ // First: check filename
+ if (file.name.contains(query, ignoreCase = true)) {
+ results.add("📄 [FILE] ${file.absolutePath}")
+ return@forEach
+ }
+
+ // Second: check file contents if requested
+ if (searchInContents && isSearchableFile(file)) {
+ try {
+ val content = file.readText()
+ if (content.contains(query, ignoreCase = true)) {
+ // Find matching lines with context
+ val lines = content.split("\n")
+ lines.forEachIndexed { index, line ->
+ if (line.contains(query, ignoreCase = true) && results.size < maxResults) {
+ val lineNum = index + 1
+ val context = line.take(80)
+ results.add("📄 ${file.absolutePath}:$lineNum → $context")
+ }
+ }
+ }
+ } catch (e: Exception) {
+ // Skip files that can't be read as text
+ Log.d("SearchProjectHandler", "Skipped non-text file: ${file.name}")
+ }
+ }
+ }
+ }
+ }
+
+ private fun isSearchableFile(file: File): Boolean {
+ val name = file.name.lowercase()
+ val binaryExtensions = listOf(".apk", ".dex", ".so", ".zip", ".jar", ".class", ".bin")
+ return !binaryExtensions.any { name.endsWith(it) }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/UpdateFileHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/UpdateFileHandler.kt
new file mode 100644
index 00000000..92e5e23c
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/UpdateFileHandler.kt
@@ -0,0 +1,51 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers
+
+import android.util.Log
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolHandler
+
+/**
+ * Handler for updating existing files.
+ */
+class UpdateFileHandler(
+ private val pluginContext: PluginContext
+) : ToolHandler {
+ override val toolName = "update_file"
+ override val description = "Update an existing file with new content"
+ override val requiresApproval = true // Requires approval for file modification
+ override val pathArgs = listOf("file_path")
+
+ override suspend fun execute(args: Map): ToolResult {
+ val filePath = args["file_path"]?.toString()?.trim()
+ val content = args["content"]?.toString() ?: ""
+
+ if (filePath.isNullOrBlank()) {
+ return ToolResult.failure("file_path is required")
+ }
+
+ return try {
+ val file = PathGuard.resolveWithin(filePath)
+ ?: return ToolResult.failure("File path must be within project directory")
+ if (!file.exists()) {
+ ToolResult.failure("File does not exist: $filePath")
+ } else if (!file.isFile) {
+ ToolResult.failure("Path is not a file: $filePath")
+ } else {
+ // Backup existing content
+ val backup = file.readText()
+
+ // Write new content
+ file.writeText(content)
+
+ ToolResult.success(
+ message = "Updated file: $filePath (${content.length} characters)",
+ data = filePath
+ )
+ }
+ } catch (e: Exception) {
+ Log.e("UpdateFileHandler", "Error updating file", e)
+ ToolResult.failure("Error updating file: ${e.message}", e.stackTraceToString())
+ }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/ToolExecutionTracker.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/ToolExecutionTracker.kt
new file mode 100644
index 00000000..335038ed
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/ToolExecutionTracker.kt
@@ -0,0 +1,86 @@
+package com.itsaky.androidide.plugins.aiassistant.utils
+
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+
+/**
+ * Tracks tool execution and generates formatted reports with timing information.
+ */
+class ToolExecutionTracker {
+ private val toolsUsed = mutableListOf()
+ private var operationStartTime = 0L
+
+ data class ToolCallLog(
+ val name: String,
+ val durationMillis: Long,
+ val timestamp: Long
+ )
+
+ fun startTracking() {
+ toolsUsed.clear()
+ operationStartTime = System.currentTimeMillis()
+ }
+
+ fun logToolCall(name: String, durationMillis: Long) {
+ val timestamp = System.currentTimeMillis() - operationStartTime
+ toolsUsed.add(ToolCallLog(name, durationMillis, timestamp))
+ }
+
+ fun generateReport(): String {
+ if (toolsUsed.isEmpty()) {
+ return "✅ **Operation Complete**\n\nNo tools were needed for this request."
+ }
+ val totalDuration = System.currentTimeMillis() - operationStartTime
+ return buildReport("✅ **Operation Complete**", totalDuration)
+ }
+
+ fun generatePartialReport(): String {
+ if (toolsUsed.isEmpty()) {
+ return "🛑 **Operation Cancelled**\n\nNo tools were executed before cancellation."
+ }
+ val totalDuration = System.currentTimeMillis() - operationStartTime
+ return buildReport("🛑 **Operation Cancelled**", totalDuration)
+ }
+
+ fun generatePausedReport(): String {
+ if (toolsUsed.isEmpty()) {
+ return "⏸️ **Awaiting User Input**\n\nNo tools were run before the question was asked."
+ }
+ val totalDuration = System.currentTimeMillis() - operationStartTime
+ return buildReport("⏸️ **Awaiting User Input**", totalDuration)
+ }
+
+ private fun buildReport(title: String, totalDuration: Long): String {
+ val toolCounts = toolsUsed.groupingBy { it.name }.eachCount()
+
+ val reportBuilder = StringBuilder("$title (Total: ${formatTime(totalDuration)})\n\n")
+ reportBuilder.append("**Tool Execution Report:**\n")
+ reportBuilder.append("Sequence:\n")
+ toolsUsed.forEachIndexed { index, log ->
+ reportBuilder.append(
+ "${index + 1}. `${log.name}` (took ${formatTime(log.durationMillis)} at +${formatTime(log.timestamp)})\n"
+ )
+ }
+
+ reportBuilder.append("\nSummary:\n")
+ toolCounts.forEach { (name, count) ->
+ val times = if (count == 1) "1 time" else "$count times"
+ reportBuilder.append("- `$name`: called $times\n")
+ }
+
+ return reportBuilder.toString()
+ }
+
+ private fun formatTime(millis: Long): String {
+ if (millis < 0) return "0.0s"
+ val minutes = TimeUnit.MILLISECONDS.toMinutes(millis)
+ val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) - TimeUnit.MINUTES.toSeconds(minutes)
+ val remainingMillis = millis % 1000
+ val totalSeconds = seconds + (remainingMillis / 1000.0)
+ return if (minutes > 0) {
+ String.format(Locale.US, "%dm %.1fs", minutes, totalSeconds)
+ } else {
+ String.format(Locale.US, "%.1fs", totalSeconds)
+ }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModel.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModel.kt
new file mode 100644
index 00000000..b7ebf272
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModel.kt
@@ -0,0 +1,272 @@
+package com.itsaky.androidide.plugins.aiassistant.viewmodel
+
+import android.content.Context
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.itsaky.androidide.plugins.services.LlmInferenceService
+import com.itsaky.androidide.plugins.services.SharedServices
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+/**
+ * State for the model file loading.
+ */
+sealed class ModelLoadingState {
+ object Idle : ModelLoadingState()
+ object Loading : ModelLoadingState()
+ data class Loaded(val modelName: String) : ModelLoadingState()
+ data class Error(val message: String) : ModelLoadingState()
+}
+
+/**
+ * State for the inference engine initialization.
+ */
+sealed class EngineState {
+ object Uninitialized : EngineState()
+ object Initializing : EngineState()
+ object Initialized : EngineState()
+ data class Error(val message: String) : EngineState()
+}
+
+/**
+ * Available AI backends.
+ */
+enum class AiBackend(val displayName: String) {
+ LOCAL_LLM("Local LLM"),
+ GEMINI("Gemini API")
+}
+
+class AiSettingsViewModel(
+ private val getContext: () -> com.itsaky.androidide.plugins.PluginContext?
+) : ViewModel() {
+
+ private val _savedModelPath = MutableLiveData(null)
+ val savedModelPath: LiveData get() = _savedModelPath
+
+ private val _modelLoadingState = MutableLiveData(ModelLoadingState.Idle)
+ val modelLoadingState: LiveData get() = _modelLoadingState
+
+ private val _engineState = MutableLiveData(EngineState.Initialized)
+ val engineState: LiveData get() = _engineState
+
+ init {
+ checkInitialState()
+ }
+
+ private fun checkInitialState() {
+ val prefs = getPluginPrefs()
+ _savedModelPath.value = prefs?.getString("local_llm_model_path", null)
+
+ // For plugin, engine is always "ready" since it's managed by ai-core plugin
+ _engineState.value = EngineState.Initialized
+ _modelLoadingState.value = ModelLoadingState.Idle
+ }
+
+ private fun getPluginPrefs() = getContext()?.getPluginSharedPreferences("AgentSettings")
+
+ fun getAvailableBackends(): List = AiBackend.entries
+
+ fun saveBackend(backend: AiBackend) {
+ getPluginPrefs()?.edit()?.apply {
+ putString("ai_backend_preference", backend.name)
+ apply()
+ }
+ }
+
+ fun getCurrentBackend(): AiBackend {
+ val backendName = getPluginPrefs()?.getString("ai_backend_preference", "LOCAL_LLM")
+ return try {
+ AiBackend.valueOf(backendName ?: "LOCAL_LLM")
+ } catch (e: Exception) {
+ AiBackend.LOCAL_LLM
+ }
+ }
+
+ fun saveLocalModelPath(path: String) {
+ getPluginPrefs()?.edit()?.apply {
+ putString("local_llm_model_path", path)
+ apply()
+ }
+ // Use postValue instead of value since this can be called from background threads
+ _savedModelPath.postValue(path)
+ }
+
+ fun getLocalModelPath(): String? {
+ return getPluginPrefs()?.getString("local_llm_model_path", null)
+ }
+
+ fun saveLocalModelSha256(hash: String?) {
+ getPluginPrefs()?.edit()?.apply {
+ putString("local_llm_model_sha256", hash?.trim() ?: "")
+ apply()
+ }
+ }
+
+ fun getLocalModelSha256(): String? {
+ return getPluginPrefs()?.getString("local_llm_model_sha256", null)
+ ?.takeIf { it.isNotBlank() }
+ }
+
+ fun setUseSimpleLocalPrompt(enabled: Boolean) {
+ getPluginPrefs()?.edit()?.apply {
+ putBoolean("use_simple_local_prompt", enabled)
+ apply()
+ }
+ }
+
+ fun isUseSimpleLocalPromptEnabled(): Boolean {
+ return getPluginPrefs()?.getBoolean("use_simple_local_prompt", true) ?: true
+ }
+
+ fun saveGeminiApiKey(apiKey: String) {
+ getPluginPrefs()?.edit()?.apply {
+ putString("gemini_api_key", apiKey)
+ putLong("gemini_api_key_timestamp", System.currentTimeMillis())
+ apply()
+ }
+ }
+
+ fun getGeminiApiKey(): String? {
+ return getPluginPrefs()?.getString("gemini_api_key", null)
+ }
+
+ fun getGeminiApiKeySaveTimestamp(): Long {
+ return getPluginPrefs()?.getLong("gemini_api_key_timestamp", 0L) ?: 0L
+ }
+
+ fun clearGeminiApiKey() {
+ getPluginPrefs()?.edit()?.apply {
+ remove("gemini_api_key")
+ remove("gemini_api_key_timestamp")
+ apply()
+ }
+ }
+
+ fun saveGeminiModel(model: String) {
+ getPluginPrefs()?.edit()?.apply {
+ putString("gemini_model", model)
+ apply()
+ }
+ }
+
+ fun getGeminiModel(): String {
+ return getPluginPrefs()?.getString("gemini_model", "gemini-1.5-flash") ?: "gemini-1.5-flash"
+ }
+
+ private val _geminiModels = MutableLiveData>(emptyList())
+ val geminiModels: LiveData> get() = _geminiModels
+
+ private val _geminiModelsLoading = MutableLiveData(false)
+ val geminiModelsLoading: LiveData get() = _geminiModelsLoading
+
+ fun fetchGeminiModels() {
+ viewModelScope.launch(Dispatchers.IO) {
+ _geminiModelsLoading.postValue(true)
+
+ try {
+ // Get the LlmInferenceService from SharedServices
+ val llmService = SharedServices.get(LlmInferenceService::class.java)
+ if (llmService == null) {
+ android.util.Log.e("AiSettingsViewModel", "LlmInferenceService not available")
+ _geminiModels.postValue(listOf(
+ "gemini-1.5-flash",
+ "gemini-1.5-pro",
+ "gemini-2.5-flash",
+ "gemini-2.5-pro",
+ "gemini-3-flash",
+ "gemini-3.5-flash"
+ ))
+ _geminiModelsLoading.postValue(false)
+ return@launch
+ }
+
+ // Get the Gemini backend
+ val geminiBackend = llmService.getBackend("gemini")
+ if (geminiBackend == null) {
+ android.util.Log.e("AiSettingsViewModel", "Gemini backend not available")
+ _geminiModels.postValue(listOf(
+ "gemini-1.5-flash",
+ "gemini-1.5-pro",
+ "gemini-2.5-flash",
+ "gemini-2.5-pro",
+ "gemini-3-flash",
+ "gemini-3.5-flash"
+ ))
+ _geminiModelsLoading.postValue(false)
+ return@launch
+ }
+
+ // Try to get list models method via reflection
+ // (since GeminiBackend is not in the interface)
+ try {
+ val listModelsMethod = geminiBackend.javaClass.getMethod("listModels")
+ val futureResult = listModelsMethod.invoke(geminiBackend)
+
+ if (futureResult is java.util.concurrent.CompletableFuture<*>) {
+ @Suppress("UNCHECKED_CAST")
+ val models = (futureResult as java.util.concurrent.CompletableFuture>).get()
+ android.util.Log.d("AiSettingsViewModel", "Fetched ${models.size} Gemini models")
+ _geminiModels.postValue(models)
+ }
+ } catch (e: Exception) {
+ android.util.Log.e("AiSettingsViewModel", "Error fetching models", e)
+ // Fallback to default list
+ _geminiModels.postValue(listOf(
+ "gemini-1.5-flash",
+ "gemini-1.5-pro",
+ "gemini-2.5-flash",
+ "gemini-2.5-pro",
+ "gemini-3-flash",
+ "gemini-3.5-flash"
+ ))
+ }
+ } catch (e: Exception) {
+ android.util.Log.e("AiSettingsViewModel", "Error in fetchGeminiModels", e)
+ _geminiModels.postValue(listOf(
+ "gemini-1.5-flash",
+ "gemini-1.5-pro",
+ "gemini-2.5-flash",
+ "gemini-2.5-pro",
+ "gemini-3-flash",
+ "gemini-3.5-flash"
+ ))
+ } finally {
+ _geminiModelsLoading.postValue(false)
+ }
+ }
+ }
+
+ /**
+ * Load a model from URI.
+ * In the plugin context, we just save the path - the actual loading
+ * is handled by the ai-core plugin's LocalLlmBackend.
+ */
+ fun loadModelFromUri(uriString: String, context: Context) {
+ viewModelScope.launch(Dispatchers.IO) {
+ _modelLoadingState.postValue(ModelLoadingState.Loading)
+
+ try {
+ // Extract filename from URI for display
+ val fileName = uriString.substringAfterLast("/")
+
+ // Save the path
+ saveLocalModelPath(uriString)
+
+ // In plugin context, we don't directly load the model
+ // The ai-core plugin will load it when needed
+ _modelLoadingState.postValue(
+ ModelLoadingState.Loaded(fileName)
+ )
+
+ android.util.Log.d("AiSettingsViewModel", "Model path saved: $uriString")
+ } catch (e: Exception) {
+ android.util.Log.e("AiSettingsViewModel", "Error saving model path", e)
+ _modelLoadingState.postValue(
+ ModelLoadingState.Error("Failed to save model path: ${e.message}")
+ )
+ }
+ }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt
new file mode 100644
index 00000000..0102ed38
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt
@@ -0,0 +1,717 @@
+package com.itsaky.androidide.plugins.aiassistant.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiassistant.models.AgentState
+import com.itsaky.androidide.plugins.aiassistant.models.ChatMessage
+import com.itsaky.androidide.plugins.aiassistant.models.ChatSession
+import com.itsaky.androidide.plugins.aiassistant.models.MessageStatus
+import com.itsaky.androidide.plugins.aiassistant.models.Sender
+import com.itsaky.androidide.plugins.aiassistant.tool.Executor
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolApprovalManager
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolCall
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolCallExtractor
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolRouter
+import com.itsaky.androidide.plugins.aiassistant.tool.ApprovalRequest
+import com.itsaky.androidide.plugins.aiassistant.tool.ApprovalResult
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.AddDependencyHandler
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.CreateFileHandler
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.GenerateFromTemplateHandler
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.GradleSyncHandler
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.ListFilesHandler
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.OpenFileHandler
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.ReadBuildOutputHandler
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.ReadFileHandler
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.SearchProjectHandler
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.UpdateFileHandler
+import com.itsaky.androidide.plugins.aiassistant.data.ChatStorageManager
+import com.itsaky.androidide.plugins.aiassistant.utils.ToolExecutionTracker
+import com.itsaky.androidide.plugins.services.LlmInferenceService
+import com.itsaky.androidide.plugins.services.SharedServices
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import org.json.JSONObject
+import java.io.File
+import java.util.UUID
+
+/**
+ * ViewModel for managing chat state and LLM interactions.
+ */
+class ChatViewModel(
+ private val getContext: () -> PluginContext?
+) : ViewModel() {
+
+ private fun getLlmService(): LlmInferenceService? {
+ return try {
+ SharedServices.get(LlmInferenceService::class.java)
+ } catch (e: Exception) {
+ android.util.Log.e("ChatViewModel", "Error getting LLM service", e)
+ null
+ }
+ }
+
+ private val _messages = MutableStateFlow>(emptyList())
+ val messages: StateFlow> = _messages.asStateFlow()
+
+ private val _agentState = MutableStateFlow(AgentState.Idle)
+ val agentState: StateFlow = _agentState.asStateFlow()
+
+ private val _isBackendAvailable = MutableStateFlow(false)
+ val isBackendAvailable: StateFlow = _isBackendAvailable.asStateFlow()
+
+ private val _sessions = MutableStateFlow>(emptyList())
+ val sessions: StateFlow> = _sessions.asStateFlow()
+
+ private val _currentSessionId = MutableStateFlow(null)
+ val currentSessionId: StateFlow = _currentSessionId.asStateFlow()
+
+ // Conversation history for LLM context (separate from UI messages)
+ private val _history = MutableStateFlow>(emptyList())
+ val history: StateFlow> = _history.asStateFlow()
+
+ val currentSession: StateFlow = combine(_sessions, _currentSessionId) { sessions, id ->
+ sessions.firstOrNull { it.id == id }
+ }.stateIn(viewModelScope, SharingStarted.Lazily, null)
+
+ private var currentBackendId: String = "local" // Default to local backend
+
+ // Tool execution infrastructure
+ private val approvalManager = ToolApprovalManager()
+ private val toolRouter: ToolRouter
+ private val executor: Executor
+ val toolExecutionTracker = ToolExecutionTracker()
+
+ private val _pendingApprovalRequest = MutableStateFlow(null)
+ val pendingApprovalRequest: StateFlow = _pendingApprovalRequest.asStateFlow()
+
+ private var contextFiles = listOf()
+
+ private var stateUpdateJob: Job? = null
+
+ private lateinit var storageManager: ChatStorageManager
+
+ fun isStorageInitialized(): Boolean = ::storageManager.isInitialized
+
+ init {
+ // Initialize tool handlers
+ val context = getContext()
+ val handlers = if (context != null) {
+ listOf(
+ // Read-only tools
+ ReadFileHandler(context),
+ ListFilesHandler(context),
+ SearchProjectHandler(context),
+ OpenFileHandler(context),
+ ReadBuildOutputHandler(context),
+ // Write tools
+ CreateFileHandler(context),
+ UpdateFileHandler(context),
+ AddDependencyHandler(context),
+ // Build tools
+ com.itsaky.androidide.plugins.aiassistant.tool.handlers.RunAppHandler(context),
+ GradleSyncHandler(context),
+ // Template tool
+ GenerateFromTemplateHandler(context)
+ )
+ } else {
+ emptyList()
+ }
+
+ toolRouter = ToolRouter(handlers)
+ executor = Executor(toolRouter, approvalManager, toolExecutionTracker)
+
+ // Monitor approval requests
+ viewModelScope.launch {
+ while (true) {
+ delay(100) // Poll every 100ms
+ val request = approvalManager.getCurrentApprovalRequest()
+ if (request != _pendingApprovalRequest.value) {
+ _pendingApprovalRequest.value = request
+ }
+ }
+ }
+ }
+
+ fun initializeStorage(context: android.content.Context) {
+ android.util.Log.d("ChatViewModel", "initializeStorage called")
+ storageManager = ChatStorageManager(context)
+ loadSessions()
+ }
+
+ fun loadSessions() {
+ val loaded = storageManager.loadSessions()
+ android.util.Log.d("ChatViewModel", "loadSessions: loaded ${loaded.size} sessions")
+ if (loaded.isEmpty()) {
+ android.util.Log.d("ChatViewModel", "No sessions found, creating new session")
+ createNewSession()
+ } else {
+ _sessions.value = loaded
+ val currentId = storageManager.loadCurrentSessionId()
+ val session = loaded.firstOrNull { it.id == currentId } ?: loaded.first()
+ android.util.Log.d("ChatViewModel", "Switching to session ${session.id} with ${session.messages.size} messages")
+ switchToSession(session.id)
+ }
+ }
+
+ private fun persistSessions() {
+ storageManager.saveSessions(_sessions.value)
+ storageManager.saveCurrentSessionId(_currentSessionId.value)
+ }
+
+ /**
+ * Submit user's approval decision.
+ */
+ fun submitApproval(result: ApprovalResult) {
+ approvalManager.submitApproval(result)
+ _pendingApprovalRequest.value = null
+ }
+
+ /**
+ * Set context files to include in prompts.
+ */
+ fun setContextFiles(files: List) {
+ contextFiles = files
+ }
+
+ /**
+ * Helper method to synchronize a message to the current session.
+ * Updates or adds the message to the session's message list.
+ */
+ private fun syncMessageToSession(message: ChatMessage) {
+ _currentSessionId.value?.let { sessionId ->
+ val session = _sessions.value.firstOrNull { it.id == sessionId }
+ if (session != null) {
+ val existingIndex = session.messages.indexOfFirst { it.id == message.id }
+ if (existingIndex >= 0) {
+ session.messages[existingIndex] = message
+ } else {
+ session.messages.add(message)
+ }
+ // Emit immutable snapshot to trigger StateFlow update
+ _messages.value = session.messages.toList()
+ }
+ }
+ }
+
+ /**
+ * Build context string from selected files.
+ */
+ private fun buildContextString(): String {
+ if (contextFiles.isEmpty()) return ""
+
+ val contextBuilder = StringBuilder()
+ contextBuilder.append("\n\nCONTEXT FILES:\n\n")
+
+ contextFiles.forEach { file ->
+ if (file.exists() && file.isFile) {
+ try {
+ val content = file.readText()
+ contextBuilder.append("=== ${file.name} ===\n")
+ contextBuilder.append(content)
+ contextBuilder.append("\n\n")
+ } catch (e: Exception) {
+ android.util.Log.e("ChatViewModel", "Error reading context file ${file.name}: ${e.message}")
+ }
+ }
+ }
+
+ return contextBuilder.toString()
+ }
+
+ /**
+ * Build appropriate system prompt based on LLM backend.
+ */
+ private fun buildSystemPrompt(): String {
+ return if (currentBackendId == "gemini") {
+ buildSystemPromptGemini()
+ } else {
+ buildSystemPromptLocal()
+ }
+ }
+
+ /**
+ * System prompt for Gemini (high autonomy, structured tool calling via native functions).
+ */
+ private fun buildSystemPromptGemini(): String {
+ val toolDescriptions = toolRouter.getAllHandlers().joinToString("\n") { handler ->
+ "- ${handler.toolName}: ${handler.description}"
+ }
+
+ val prompt = """
+ You are a senior Android developer integrated into AndroidIDE. Your goal is to build complete, working Android apps from user descriptions.
+
+ AVAILABLE TOOLS:
+ $toolDescriptions
+
+ BEHAVIOR:
+ - Create complete, production-ready code
+ - Call tools proactively to build, test, and verify your work
+ - Read files to understand project structure before making changes
+ - After each file modification, verify the build compiles
+ - Generate apps that actually run and work as described
+
+ WORKFLOW:
+ 1. Understand the user's request
+ 2. List files to understand the project structure
+ 3. Create/modify files with complete implementations
+ 4. Add dependencies if needed
+ 5. Sync gradle and verify compilation
+ 6. Run the app to confirm it works
+ 7. Report success and what was built
+
+ You have full access to tools - use them continuously throughout the workflow.
+ """.trimIndent()
+
+ android.util.Log.d("ChatViewModel", "Using Gemini system prompt (high autonomy mode) with ${toolRouter.getAllHandlers().size} tools")
+ return prompt
+ }
+
+ /**
+ * System prompt for local LLMs (guided step-by-step with text-based tool calling).
+ */
+ private fun buildSystemPromptLocal(): String {
+ val toolDescriptions = toolRouter.getAllHandlers().joinToString("\n") { handler ->
+ "- ${handler.toolName}: ${handler.description}"
+ }
+
+ val prompt = """
+ You are a helpful coding assistant integrated into AndroidIDE. Help the user build Android apps step-by-step.
+
+ CRITICAL: You MUST use tools for ANY action-related request. Do NOT just describe what you would do.
+
+ AVAILABLE TOOLS:
+ $toolDescriptions
+
+ TOOL CALLING RULES:
+ 1. When user asks to perform an action (list, read, search, create, update, run), IMMEDIATELY call the tool
+ 2. Do NOT explain or apologize - just execute the tool call
+ 3. Always provide the tool call in this EXACT format:
+ {"tool":"TOOL_NAME","args":{"param1":"value1"}}
+ 4. Execute tools BEFORE saying anything else
+
+ STEP-BY-STEP WORKFLOW:
+ 1. List files to understand the project
+ 2. Read existing files to know what to change
+ 3. Create or update one file at a time
+ 4. After each file, ask the user what to do next
+ 5. Add dependencies when needed
+ 6. Sync gradle to check for errors
+ 7. Run the app to test it
+ 8. Ask for feedback and iterate
+
+ EXAMPLES:
+ User: "list files in src"
+ {"tool":"list_files","args":{"directory":"src"}}
+
+ User: "read MainActivity.kt"
+ {"tool":"read_file","args":{"file_path":"MainActivity.kt"}}
+
+ After each tool call, analyze the result and ask: "What would you like to do next?"
+ """.trimIndent()
+
+ android.util.Log.d("ChatViewModel", "Using Local LLM system prompt (guided mode) with ${toolRouter.getAllHandlers().size} tools")
+ return prompt
+ }
+
+ /**
+ * Parse tool calls from text using multi-strategy extraction.
+ * Tries: XML tags → JSON objects → Implicit actions
+ */
+ private fun parseToolCalls(text: String): List {
+ return ToolCallExtractor.extractToolCalls(text)
+ }
+
+ /**
+ * Execute tool calls and add results to chat.
+ */
+ private suspend fun executeToolCalls(toolCalls: List) {
+ if (toolCalls.isEmpty()) return
+
+ val executingState = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = toolCalls.size,
+ description = toolCalls.first().name
+ )
+ _agentState.value = executingState
+ startStateTimer(executingState)
+
+ val results = executor.execute(toolCalls)
+
+ // Add tool results as messages
+ results.forEachIndexed { index, result ->
+ val toolCall = toolCalls[index]
+ val resultText = if (result.success) {
+ "${toolCall.name}: ${result.message}\n${result.data ?: ""}"
+ } else {
+ "${toolCall.name} failed: ${result.message}\n${result.error_details ?: ""}"
+ }
+ val resultMessage = ChatMessage(
+ id = UUID.randomUUID().toString(),
+ text = resultText,
+ sender = Sender.TOOL,
+ status = if (result.success) MessageStatus.SENT else MessageStatus.ERROR
+ )
+ android.util.Log.d("ChatViewModel", "Adding tool result message: $resultText")
+ _messages.value = _messages.value + resultMessage
+ android.util.Log.d("ChatViewModel", "Total messages after tool result: ${_messages.value.size}")
+ syncMessageToSession(resultMessage)
+ }
+
+ stopStateTimer()
+ _agentState.value = AgentState.Idle
+ }
+
+ /**
+ * Check if any LLM backend is available.
+ * Should be called when the fragment becomes visible.
+ * Retries with delays to handle plugin loading timing.
+ */
+ fun checkBackendAvailability() {
+ android.util.Log.d("ChatViewModel", "checkBackendAvailability: Starting check")
+ viewModelScope.launch(Dispatchers.IO) {
+ // Retry up to 5 times with 500ms delays to handle plugin loading order
+ repeat(5) { attempt ->
+ android.util.Log.d("ChatViewModel", "checkBackendAvailability: Attempt ${attempt + 1}")
+ val llmService = getLlmService()
+ android.util.Log.d("ChatViewModel", "checkBackendAvailability: llmService = $llmService")
+ if (llmService != null) {
+ try {
+ val backends = llmService.availableBackends
+ android.util.Log.d("ChatViewModel", "checkBackendAvailability: Found ${backends.size} backends")
+
+ // Read backend preference from settings
+ val prefs = getContext()?.getPluginSharedPreferences("AgentSettings")
+ val preferredBackendName = prefs?.getString("ai_backend_preference", "LOCAL_LLM")
+ android.util.Log.d("ChatViewModel", "checkBackendAvailability: Preferred backend = $preferredBackendName")
+ val preferredBackendId = when (preferredBackendName) {
+ "GEMINI" -> "gemini"
+ "LOCAL_LLM" -> "local"
+ else -> "local"
+ }
+
+ // First try to use the preferred backend
+ var foundAvailable = false
+ val preferredBackend = backends.find { it.id == preferredBackendId }
+ android.util.Log.d("ChatViewModel", "checkBackendAvailability: Preferred backend (${preferredBackendId}) found=${preferredBackend != null}, available=${preferredBackend?.isAvailable}")
+ if (preferredBackend != null && preferredBackend.isAvailable) {
+ _isBackendAvailable.value = true
+ currentBackendId = preferredBackend.id
+ android.util.Log.d("ChatViewModel", "checkBackendAvailability: Using preferred backend ${preferredBackend.id}")
+ return@launch // Success, exit retry loop
+ }
+
+ // If preferred backend not available, try any available backend as fallback
+ for (backend in backends) {
+ android.util.Log.d("ChatViewModel", "checkBackendAvailability: Checking backend ${backend.id}, available=${backend.isAvailable}")
+ if (backend.isAvailable) {
+ _isBackendAvailable.value = true
+ currentBackendId = backend.id
+ foundAvailable = true
+ android.util.Log.d("ChatViewModel", "checkBackendAvailability: Using fallback backend ${backend.id}")
+ break
+ }
+ }
+
+ if (foundAvailable) {
+ return@launch // Success, exit retry loop
+ }
+ } catch (e: Exception) {
+ android.util.Log.e("ChatViewModel", "Error checking backends on attempt ${attempt + 1}: ${e.message}", e)
+ }
+ }
+
+ // Wait before next retry (except on last attempt)
+ if (attempt < 4) {
+ delay(500)
+ }
+ }
+
+ // All retries failed
+ android.util.Log.d("ChatViewModel", "checkBackendAvailability: All retries failed, no backend available")
+ _isBackendAvailable.value = false
+ }
+ }
+
+ /**
+ * Send a user message and get agent response.
+ */
+ fun sendMessage(userMessage: String) {
+ android.util.Log.d("ChatViewModel", "sendMessage called with: '$userMessage'")
+ val llmService = getLlmService()
+ if (llmService == null) {
+ android.util.Log.d("ChatViewModel", "sendMessage: LLM service not available")
+ _agentState.value = AgentState.Error("LLM service not available. Install AI Core plugin.")
+ return
+ }
+
+ if (!_isBackendAvailable.value) {
+ android.util.Log.d("ChatViewModel", "sendMessage: Backend not available")
+ _agentState.value = AgentState.Error("No LLM backend available. Please configure one in AI Core plugin.")
+ return
+ }
+
+ if (userMessage.isBlank()) {
+ android.util.Log.d("ChatViewModel", "sendMessage: Message is blank")
+ return
+ }
+
+ android.util.Log.d("ChatViewModel", "sendMessage: Starting message processing")
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ // Add user message
+ val userChatMessage = ChatMessage(
+ id = UUID.randomUUID().toString(),
+ text = userMessage,
+ sender = Sender.USER,
+ status = MessageStatus.SENT
+ )
+ _messages.value = _messages.value + userChatMessage
+ android.util.Log.d("ChatViewModel", "sendMessage: Added user message, total messages=${_messages.value.size}")
+ syncMessageToSession(userChatMessage)
+
+ // Add empty agent message that will be updated with streaming tokens
+ val agentMessageId = UUID.randomUUID().toString()
+ val agentMessage = ChatMessage(
+ id = agentMessageId,
+ text = "",
+ sender = Sender.AGENT,
+ status = MessageStatus.SENT // SENT so text is visible immediately
+ )
+ _messages.value = _messages.value + agentMessage
+ syncMessageToSession(agentMessage)
+
+ // Set processing state
+ _agentState.value = AgentState.Processing("Generating...")
+
+ // Record start time
+ val startTime = System.currentTimeMillis()
+
+ // Create LLM config
+ val config = LlmInferenceService.LlmConfig(currentBackendId).apply {
+ temperature = 0.7f
+ maxTokens = 4096 // Increased from 2048 to ensure complete tool calls are generated
+ systemPrompt = buildSystemPrompt()
+ }
+
+ // Build message with context if any files are selected
+ val messageWithContext = buildString {
+ append(userMessage)
+ val context = buildContextString()
+ if (context.isNotEmpty()) {
+ append(context)
+ }
+ }
+
+ // Add user message to conversation history for LLM context
+ val userHistoryMessage = LlmInferenceService.ChatMessage(
+ LlmInferenceService.ChatMessage.Role.USER,
+ messageWithContext
+ )
+ _history.value = _history.value + userHistoryMessage
+ android.util.Log.d("ChatViewModel", "Added user to history. Total history length: ${_history.value.size}")
+
+ // Accumulated response text
+ val responseBuilder = StringBuilder()
+
+ // Use streaming API with callback
+ llmService.generateStreaming(messageWithContext, config, object : LlmInferenceService.StreamCallback {
+ override fun onToken(token: String) {
+ viewModelScope.launch(Dispatchers.Main) {
+ // Accumulate token
+ responseBuilder.append(token)
+
+ // Update the message with new text using map() to trigger DiffUtil
+ val updatedMessage = ChatMessage(
+ id = agentMessageId,
+ text = responseBuilder.toString(),
+ sender = Sender.AGENT,
+ status = MessageStatus.SENT
+ )
+ _messages.value = _messages.value.map { msg ->
+ if (msg.id == agentMessageId) {
+ updatedMessage
+ } else {
+ msg
+ }
+ }
+
+ // Also update current session's message
+ syncMessageToSession(updatedMessage)
+ }
+ }
+
+ override fun onComplete(response: LlmInferenceService.LlmResponse) {
+ viewModelScope.launch(Dispatchers.IO) {
+ val durationMs = System.currentTimeMillis() - startTime
+ val finalText = response.text
+
+ // Mark message as completed with final text
+ launch(Dispatchers.Main) {
+ val updatedMessage = ChatMessage(
+ id = agentMessageId,
+ text = finalText,
+ sender = Sender.AGENT,
+ status = MessageStatus.COMPLETED,
+ durationMs = durationMs
+ )
+ _messages.value = _messages.value.map { msg ->
+ if (msg.id == agentMessageId) {
+ updatedMessage
+ } else {
+ msg
+ }
+ }
+
+ syncMessageToSession(updatedMessage)
+ }
+
+ // Add assistant response to conversation history for LLM context
+ val assistantHistoryMessage = LlmInferenceService.ChatMessage(
+ LlmInferenceService.ChatMessage.Role.ASSISTANT,
+ finalText
+ )
+ _history.value = _history.value + assistantHistoryMessage
+ android.util.Log.d("ChatViewModel", "Added assistant to history. Total history length: ${_history.value.size}")
+
+ // Parse and execute tool calls if any
+ val toolCalls = parseToolCalls(finalText)
+ if (toolCalls.isNotEmpty()) {
+ executeToolCalls(toolCalls)
+ } else {
+ launch(Dispatchers.Main) {
+ _agentState.value = AgentState.Idle
+ }
+ }
+ }
+ }
+
+ override fun onError(error: String) {
+ viewModelScope.launch(Dispatchers.Main) {
+ val durationMs = System.currentTimeMillis() - startTime
+
+ // Remove the agent message
+ _messages.value = _messages.value.filter { it.id != agentMessageId }
+
+ // Add error message
+ _agentState.value = AgentState.Error(error)
+ val errorMessage = ChatMessage(
+ id = UUID.randomUUID().toString(),
+ text = error,
+ sender = Sender.SYSTEM,
+ status = MessageStatus.ERROR,
+ durationMs = durationMs
+ )
+ _messages.value = _messages.value + errorMessage
+ syncMessageToSession(errorMessage)
+ }
+ }
+ })
+
+ } catch (e: Exception) {
+ _agentState.value = AgentState.Error("Error: ${e.message}")
+ val errorMessage = ChatMessage(
+ id = UUID.randomUUID().toString(),
+ text = "Error: ${e.message}",
+ sender = Sender.SYSTEM,
+ status = MessageStatus.ERROR
+ )
+ _messages.value = _messages.value + errorMessage
+ }
+ }
+ }
+
+ /**
+ * Clear all messages from the conversation.
+ */
+ fun clearMessages() {
+ _messages.value = emptyList()
+ _agentState.value = AgentState.Idle
+ }
+
+ /**
+ * Create a new chat session.
+ */
+ fun createNewSession() {
+ val newSession = ChatSession()
+ _sessions.value = _sessions.value + newSession
+ _currentSessionId.value = newSession.id
+ _messages.value = emptyList()
+ }
+
+ /**
+ * Switch to an existing chat session.
+ */
+ fun switchToSession(sessionId: String) {
+ val session = _sessions.value.firstOrNull { it.id == sessionId }
+ android.util.Log.d("ChatViewModel", "switchToSession: sessionId=$sessionId, session found=${session != null}")
+ if (session != null) {
+ _currentSessionId.value = sessionId
+ // Use immutable snapshot to ensure StateFlow emits on mutations
+ _messages.value = session.messages.toList()
+ android.util.Log.d("ChatViewModel", "switchToSession: set _messages to ${session.messages.size} messages")
+ }
+ }
+
+ /**
+ * Delete a chat session.
+ */
+ fun deleteSession(sessionId: String) {
+ _sessions.value = _sessions.value.filter { it.id != sessionId }
+ if (_currentSessionId.value == sessionId) {
+ val remaining = _sessions.value.firstOrNull()
+ _currentSessionId.value = remaining?.id
+ _messages.value = remaining?.messages ?: emptyList()
+ }
+ }
+
+ /**
+ * Stop any ongoing processing.
+ */
+ fun stopProcessing() {
+ if (_agentState.value is AgentState.Processing) {
+ _agentState.value = AgentState.Cancelling
+ getLlmService()?.cancelGeneration()
+ _agentState.value = AgentState.Idle
+ }
+ }
+
+ /**
+ * Start a timer that updates the executing state with elapsed time.
+ * Updates every 100ms for smooth progress display.
+ */
+ fun startStateTimer(state: AgentState.Executing) {
+ stateUpdateJob?.cancel()
+ stateUpdateJob = viewModelScope.launch {
+ while (isActive) {
+ delay(100) // Update every 100ms
+ val elapsed = System.currentTimeMillis() - state.startTime
+ _agentState.value = state.copy(elapsedMillis = elapsed)
+ }
+ }
+ }
+
+ /**
+ * Stop the state timer.
+ */
+ fun stopStateTimer() {
+ stateUpdateJob?.cancel()
+ stateUpdateJob = null
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ persistSessions()
+ stopProcessing()
+ stopStateTimer()
+ }
+}
diff --git a/ai-assistant/src/main/res/drawable/backend_status_background.xml b/ai-assistant/src/main/res/drawable/backend_status_background.xml
new file mode 100644
index 00000000..5d22266f
--- /dev/null
+++ b/ai-assistant/src/main/res/drawable/backend_status_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/ai-assistant/src/main/res/drawable/ic_ai.xml b/ai-assistant/src/main/res/drawable/ic_ai.xml
new file mode 100644
index 00000000..bb43b2a3
--- /dev/null
+++ b/ai-assistant/src/main/res/drawable/ic_ai.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/ai-assistant/src/main/res/layout/fragment_ai_settings.xml b/ai-assistant/src/main/res/layout/fragment_ai_settings.xml
new file mode 100644
index 00000000..40892ed0
--- /dev/null
+++ b/ai-assistant/src/main/res/layout/fragment_ai_settings.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ai-assistant/src/main/res/layout/fragment_chat.xml b/ai-assistant/src/main/res/layout/fragment_chat.xml
new file mode 100644
index 00000000..1234ec75
--- /dev/null
+++ b/ai-assistant/src/main/res/layout/fragment_chat.xml
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ai-assistant/src/main/res/layout/layout_settings_gemini_api.xml b/ai-assistant/src/main/res/layout/layout_settings_gemini_api.xml
new file mode 100644
index 00000000..179cbb56
--- /dev/null
+++ b/ai-assistant/src/main/res/layout/layout_settings_gemini_api.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ai-assistant/src/main/res/layout/layout_settings_local_llm.xml b/ai-assistant/src/main/res/layout/layout_settings_local_llm.xml
new file mode 100644
index 00000000..e84ab1c1
--- /dev/null
+++ b/ai-assistant/src/main/res/layout/layout_settings_local_llm.xml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ai-assistant/src/main/res/layout/list_item_chat_message.xml b/ai-assistant/src/main/res/layout/list_item_chat_message.xml
new file mode 100644
index 00000000..6b3b4e59
--- /dev/null
+++ b/ai-assistant/src/main/res/layout/list_item_chat_message.xml
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ai-assistant/src/main/res/layout/list_item_chat_system_message.xml b/ai-assistant/src/main/res/layout/list_item_chat_system_message.xml
new file mode 100644
index 00000000..add89184
--- /dev/null
+++ b/ai-assistant/src/main/res/layout/list_item_chat_system_message.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ai-assistant/src/main/res/menu/chat_overflow_menu.xml b/ai-assistant/src/main/res/menu/chat_overflow_menu.xml
new file mode 100644
index 00000000..ab3b8e93
--- /dev/null
+++ b/ai-assistant/src/main/res/menu/chat_overflow_menu.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/ai-assistant/src/main/res/values/strings.xml b/ai-assistant/src/main/res/values/strings.xml
new file mode 100644
index 00000000..b9695d93
--- /dev/null
+++ b/ai-assistant/src/main/res/values/strings.xml
@@ -0,0 +1,108 @@
+
+
+
+ AI Assistant
+
+
+ AI Chat
+ Enter your message…
+ Send
+ Stop
+ Clear chat
+
+
+ Clear chat
+ Settings
+ New chat
+ Chat history
+ Copy chat
+
+
+ Ready
+ Initializing…
+ Thinking…
+ Processing…
+ Executing step %1$d of %2$d: %3$s
+ Cancelling…
+ Error: %s
+
+
+ No LLM backend available. Configure AI Core plugin.
+ Local LLM
+ Gemini API
+ Model: %s
+
+
+ Chat cleared
+ Please enter a message
+ Copied
+ Generating…
+
+
+ Copy Text
+ Edit Message
+ Retry
+ Open AI Settings
+
+
+ Reading file…
+ Listing files…
+ Searching…
+ Creating file…
+ Updating file…
+
+
+ LLM service not available. Install AI Core plugin.
+ No LLM backend available. Please configure one in AI Core plugin.
+ Failed to generate response
+ Error reading file: %s
+ Error writing file: %s
+
+
+ Approve %s?
+ The AI wants to execute:
+ Arguments:
+ Approve Once
+ Approve for Session
+ Deny
+
+
+ Tokens: %d%%
+ Tokens: N/A
+
+
+ took %s
+ %.1fs
+ %dm %.1fs
+
+
+ Copy Chat
+ Copy to Clipboard
+ Share as File
+ No messages to copy
+ Chat copied to clipboard
+ Failed to copy chat
+ Failed to share chat
+
+
+ User
+ Agent
+ System
+ Tool
+
+
+ AI Settings
+ Backend
+ Model
+ Temperature
+ Max Tokens
+
+
+ AI Agent
+ New Chat
+ Add context file
+ Type a message or prompt…
+ ⚠️ Experimental AI. Use at your own risk.
+ Current AI backend
+ Send
+
diff --git a/ai-assistant/src/main/res/values/styles.xml b/ai-assistant/src/main/res/values/styles.xml
new file mode 100644
index 00000000..8e7f8b50
--- /dev/null
+++ b/ai-assistant/src/main/res/values/styles.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/data/ChatStorageManagerTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/data/ChatStorageManagerTest.kt
new file mode 100644
index 00000000..c4207a7a
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/data/ChatStorageManagerTest.kt
@@ -0,0 +1,384 @@
+package com.itsaky.androidide.plugins.aiassistant.data
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.itsaky.androidide.plugins.aiassistant.models.ChatMessage
+import com.itsaky.androidide.plugins.aiassistant.models.ChatSession
+import com.itsaky.androidide.plugins.aiassistant.models.Sender
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * Unit tests for ChatStorageManager.
+ * Tests JSON serialization/deserialization with error handling.
+ */
+class ChatStorageManagerTest {
+
+ private lateinit var context: Context
+ private lateinit var sharedPreferences: SharedPreferences
+ private lateinit var editor: SharedPreferences.Editor
+ private lateinit var storageManager: ChatStorageManager
+
+ @Before
+ fun setup() {
+ // Mock Android Context and SharedPreferences
+ context = mockk(relaxed = true)
+ sharedPreferences = mockk(relaxed = true)
+ editor = mockk(relaxed = true)
+
+ every { context.getSharedPreferences("ai_assistant_chats", Context.MODE_PRIVATE) } returns sharedPreferences
+ every { sharedPreferences.edit() } returns editor
+ every { editor.putString(any(), any()) } returns editor
+ every { editor.apply() } returns Unit
+
+ storageManager = ChatStorageManager(context)
+ }
+
+ @Test
+ fun testSaveSessionsWithEmptyList() {
+ val sessions = emptyList()
+ val jsonSlot = slot()
+
+ every { editor.putString("chat_sessions", capture(jsonSlot)) } returns editor
+
+ storageManager.saveSessions(sessions)
+
+ verify { editor.putString("chat_sessions", any()) }
+ verify { editor.apply() }
+
+ // Should serialize to empty JSON array
+ assertEquals("[]", jsonSlot.captured)
+ }
+
+ @Test
+ fun testSaveSessionsWithSingleSession() {
+ val session = ChatSession(
+ id = "test-123",
+ createdAt = 1234567890L,
+ messages = mutableListOf()
+ )
+ val sessions = listOf(session)
+ val jsonSlot = slot()
+
+ every { editor.putString("chat_sessions", capture(jsonSlot)) } returns editor
+
+ storageManager.saveSessions(sessions)
+
+ verify { editor.putString("chat_sessions", any()) }
+ verify { editor.apply() }
+
+ // Verify JSON contains session data
+ val json = jsonSlot.captured
+ assertTrue(json.contains("test-123"))
+ assertTrue(json.contains("1234567890"))
+ }
+
+ @Test
+ fun testSaveSessionsWithMultipleSessions() {
+ val session1 = ChatSession(id = "session-1", createdAt = 1000L)
+ val session2 = ChatSession(id = "session-2", createdAt = 2000L)
+ val sessions = listOf(session1, session2)
+ val jsonSlot = slot()
+
+ every { editor.putString("chat_sessions", capture(jsonSlot)) } returns editor
+
+ storageManager.saveSessions(sessions)
+
+ val json = jsonSlot.captured
+ assertTrue(json.contains("session-1"))
+ assertTrue(json.contains("session-2"))
+ }
+
+ @Test
+ fun testSaveSessionsWithMessages() {
+ val message1 = ChatMessage(
+ id = "msg-1",
+ text = "Hello",
+ sender = Sender.USER
+ )
+ val message2 = ChatMessage(
+ id = "msg-2",
+ text = "Hi there",
+ sender = Sender.AGENT
+ )
+ val session = ChatSession(
+ id = "session-with-messages",
+ messages = mutableListOf(message1, message2)
+ )
+ val jsonSlot = slot()
+
+ every { editor.putString("chat_sessions", capture(jsonSlot)) } returns editor
+
+ storageManager.saveSessions(listOf(session))
+
+ val json = jsonSlot.captured
+ assertTrue(json.contains("Hello"))
+ assertTrue(json.contains("Hi there"))
+ assertTrue(json.contains("msg-1"))
+ assertTrue(json.contains("msg-2"))
+ }
+
+ @Test
+ fun testLoadSessionsWithNoData() {
+ every { sharedPreferences.getString("chat_sessions", null) } returns null
+
+ val sessions = storageManager.loadSessions()
+
+ assertTrue(sessions.isEmpty())
+ }
+
+ @Test
+ fun testLoadSessionsWithEmptyList() {
+ every { sharedPreferences.getString("chat_sessions", null) } returns "[]"
+
+ val sessions = storageManager.loadSessions()
+
+ assertTrue(sessions.isEmpty())
+ }
+
+ @Test
+ fun testLoadSessionsWithValidData() {
+ val validJson = """
+ [
+ {
+ "id": "test-session",
+ "createdAt": 1234567890,
+ "messages": []
+ }
+ ]
+ """.trimIndent()
+
+ every { sharedPreferences.getString("chat_sessions", null) } returns validJson
+
+ val sessions = storageManager.loadSessions()
+
+ assertEquals(1, sessions.size)
+ assertEquals("test-session", sessions[0].id)
+ assertEquals(1234567890L, sessions[0].createdAt)
+ assertTrue(sessions[0].messages.isEmpty())
+ }
+
+ @Test
+ fun testLoadSessionsWithMultipleSessions() {
+ val validJson = """
+ [
+ {
+ "id": "session-1",
+ "createdAt": 1000,
+ "messages": []
+ },
+ {
+ "id": "session-2",
+ "createdAt": 2000,
+ "messages": []
+ }
+ ]
+ """.trimIndent()
+
+ every { sharedPreferences.getString("chat_sessions", null) } returns validJson
+
+ val sessions = storageManager.loadSessions()
+
+ assertEquals(2, sessions.size)
+ assertEquals("session-1", sessions[0].id)
+ assertEquals("session-2", sessions[1].id)
+ }
+
+ @Test
+ fun testLoadSessionsWithMessages() {
+ val validJson = """
+ [
+ {
+ "id": "session-1",
+ "createdAt": 1000,
+ "messages": [
+ {
+ "id": "msg-1",
+ "text": "User message",
+ "sender": "USER",
+ "status": "SENT",
+ "timestamp": 1000
+ },
+ {
+ "id": "msg-2",
+ "text": "Agent response",
+ "sender": "AGENT",
+ "status": "COMPLETED",
+ "timestamp": 2000,
+ "durationMs": 500
+ }
+ ]
+ }
+ ]
+ """.trimIndent()
+
+ every { sharedPreferences.getString("chat_sessions", null) } returns validJson
+
+ val sessions = storageManager.loadSessions()
+
+ assertEquals(1, sessions.size)
+ assertEquals(2, sessions[0].messages.size)
+ assertEquals("User message", sessions[0].messages[0].text)
+ assertEquals(Sender.USER, sessions[0].messages[0].sender)
+ assertEquals("Agent response", sessions[0].messages[1].text)
+ assertEquals(Sender.AGENT, sessions[0].messages[1].sender)
+ }
+
+ @Test
+ fun testLoadSessionsWithCorruptedJson() {
+ val corruptedJson = "{invalid json this is not valid"
+
+ every { sharedPreferences.getString("chat_sessions", null) } returns corruptedJson
+
+ val sessions = storageManager.loadSessions()
+
+ // Should return empty list on parse error
+ assertTrue(sessions.isEmpty())
+ }
+
+ @Test
+ fun testLoadSessionsWithMalformedJson() {
+ val malformedJson = """
+ [
+ {
+ "id": "test",
+ "invalid_field": "this should not break parsing"
+ }
+ ]
+ """.trimIndent()
+
+ every { sharedPreferences.getString("chat_sessions", null) } returns malformedJson
+
+ val sessions = storageManager.loadSessions()
+
+ // Gson should handle extra fields gracefully
+ // Should still load what it can
+ assertTrue(sessions.size >= 0)
+ }
+
+ @Test
+ fun testSaveCurrentSessionIdWithValidId() {
+ val sessionId = "session-123"
+ val idSlot = slot()
+
+ every { editor.putString("current_session_id", capture(idSlot)) } returns editor
+
+ storageManager.saveCurrentSessionId(sessionId)
+
+ verify { editor.putString("current_session_id", any()) }
+ verify { editor.apply() }
+ assertEquals(sessionId, idSlot.captured)
+ }
+
+ @Test
+ fun testSaveCurrentSessionIdWithNull() {
+ val idSlot = slot()
+
+ every { editor.putString("current_session_id", captureNullable(idSlot)) } returns editor
+
+ storageManager.saveCurrentSessionId(null)
+
+ verify { editor.putString("current_session_id", null) }
+ verify { editor.apply() }
+ assertNull(idSlot.captured)
+ }
+
+ @Test
+ fun testLoadCurrentSessionIdWithValidId() {
+ val sessionId = "session-456"
+ every { sharedPreferences.getString("current_session_id", null) } returns sessionId
+
+ val loadedId = storageManager.loadCurrentSessionId()
+
+ assertEquals(sessionId, loadedId)
+ }
+
+ @Test
+ fun testLoadCurrentSessionIdWithNoData() {
+ every { sharedPreferences.getString("current_session_id", null) } returns null
+
+ val loadedId = storageManager.loadCurrentSessionId()
+
+ assertNull(loadedId)
+ }
+
+ @Test
+ fun testRoundTripSaveAndLoad() {
+ // Create test data
+ val message1 = ChatMessage(text = "Test message", sender = Sender.USER)
+ val session1 = ChatSession(
+ id = "round-trip-test",
+ createdAt = 9999999L,
+ messages = mutableListOf(message1)
+ )
+ val sessions = listOf(session1)
+
+ // Capture what gets saved
+ val jsonSlot = slot()
+ every { editor.putString("chat_sessions", capture(jsonSlot)) } returns editor
+
+ // Save
+ storageManager.saveSessions(sessions)
+
+ // Now mock the load to return what was saved
+ every { sharedPreferences.getString("chat_sessions", null) } returns jsonSlot.captured
+
+ // Load
+ val loadedSessions = storageManager.loadSessions()
+
+ // Verify round-trip
+ assertEquals(1, loadedSessions.size)
+ assertEquals("round-trip-test", loadedSessions[0].id)
+ assertEquals(9999999L, loadedSessions[0].createdAt)
+ assertEquals(1, loadedSessions[0].messages.size)
+ assertEquals("Test message", loadedSessions[0].messages[0].text)
+ }
+
+ @Test
+ fun testRoundTripCurrentSessionId() {
+ val sessionId = "current-session"
+ val idSlot = slot()
+
+ every { editor.putString("current_session_id", capture(idSlot)) } returns editor
+
+ // Save
+ storageManager.saveCurrentSessionId(sessionId)
+
+ // Mock load to return what was saved
+ every { sharedPreferences.getString("current_session_id", null) } returns idSlot.captured
+
+ // Load
+ val loadedId = storageManager.loadCurrentSessionId()
+
+ assertEquals(sessionId, loadedId)
+ }
+
+ @Test
+ fun testUsesCorrectSharedPreferencesName() {
+ // Verify that ChatStorageManager uses the correct SharedPreferences file name
+ verify { context.getSharedPreferences("ai_assistant_chats", Context.MODE_PRIVATE) }
+ }
+
+ @Test
+ fun testUsesCorrectKeys() {
+ val sessions = listOf(ChatSession())
+ storageManager.saveSessions(sessions)
+
+ // Verify correct key is used
+ verify { editor.putString("chat_sessions", any()) }
+
+ val sessionId = "test"
+ storageManager.saveCurrentSessionId(sessionId)
+
+ // Verify correct key is used
+ verify { editor.putString("current_session_id", any()) }
+ }
+}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/models/AgentStateExecutingTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/models/AgentStateExecutingTest.kt
new file mode 100644
index 00000000..3691f959
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/models/AgentStateExecutingTest.kt
@@ -0,0 +1,238 @@
+package com.itsaky.androidide.plugins.aiassistant.models
+
+import org.junit.Test
+import org.junit.Assert.*
+import java.util.concurrent.TimeUnit
+
+/**
+ * Unit tests for AgentState.Executing with step progress and timing.
+ */
+class AgentStateExecutingTest {
+
+ @Test
+ fun testExecutingStateCreation() {
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 5,
+ description = "Reading file"
+ )
+
+ assertEquals(0, state.currentStepIndex)
+ assertEquals(5, state.totalSteps)
+ assertEquals("Reading file", state.description)
+ assertNotNull(state.startTime)
+ assertEquals(0, state.elapsedMillis)
+ }
+
+ @Test
+ fun testFormattedProgressFirstStep() {
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 5,
+ description = "Reading file"
+ )
+
+ assertEquals("Step 1 of 5: Reading file", state.formattedProgress)
+ }
+
+ @Test
+ fun testFormattedProgressMiddleStep() {
+ val state = AgentState.Executing(
+ currentStepIndex = 2,
+ totalSteps = 5,
+ description = "Processing data"
+ )
+
+ assertEquals("Step 3 of 5: Processing data", state.formattedProgress)
+ }
+
+ @Test
+ fun testFormattedProgressLastStep() {
+ val state = AgentState.Executing(
+ currentStepIndex = 4,
+ totalSteps = 5,
+ description = "Finalizing"
+ )
+
+ assertEquals("Step 5 of 5: Finalizing", state.formattedProgress)
+ }
+
+ @Test
+ fun testFormattedTimingWithZeroElapsed() {
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 5,
+ description = "Reading file",
+ elapsedMillis = 0
+ )
+
+ val timing = state.formattedTiming
+ assertTrue(timing.contains("0.0s"))
+ assertTrue(timing.contains("of"))
+ }
+
+ @Test
+ fun testFormattedTimingWithMilliseconds() {
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 5,
+ description = "Reading file",
+ elapsedMillis = 500
+ )
+
+ val timing = state.formattedTiming
+ assertTrue(timing.contains("s"))
+ assertTrue(timing.contains("of"))
+ // Should show 0.5s or similar
+ }
+
+ @Test
+ fun testFormattedTimingWithSeconds() {
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 5,
+ description = "Reading file",
+ elapsedMillis = 2500
+ )
+
+ val timing = state.formattedTiming
+ assertTrue(timing.contains("s"))
+ assertTrue(timing.contains("of"))
+ }
+
+ @Test
+ fun testFormattedTimingWithMinutesAndSeconds() {
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 5,
+ description = "Reading file",
+ elapsedMillis = 65000 // 1 minute 5 seconds
+ )
+
+ val timing = state.formattedTiming
+ assertTrue(timing.contains("m"))
+ assertTrue(timing.contains("s"))
+ assertTrue(timing.contains("of"))
+ }
+
+ @Test
+ fun testFormattedTimingEstimateWithMultipleSteps() {
+ // Simulate 2 steps completed out of 5
+ // If 2000ms elapsed for 2 steps, avg is 1000ms per step
+ // Estimated total should be around 5000ms
+ val state = AgentState.Executing(
+ currentStepIndex = 1, // 2nd step (0-indexed)
+ totalSteps = 5,
+ description = "Processing",
+ elapsedMillis = 2000
+ )
+
+ val timing = state.formattedTiming
+ assertTrue(timing.contains("of"))
+ // Should show estimated total of ~5 seconds
+ }
+
+ @Test
+ fun testFormattedTimingNegativeElapsed() {
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 5,
+ description = "Reading file",
+ elapsedMillis = -100
+ )
+
+ val timing = state.formattedTiming
+ assertTrue(timing.contains("0.0s"))
+ }
+
+ @Test
+ fun testExecutingStateCopyWithNewElapsed() {
+ val originalState = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 5,
+ description = "Reading file",
+ elapsedMillis = 0
+ )
+
+ val copiedState = originalState.copy(elapsedMillis = 1000)
+
+ assertEquals(0, originalState.currentStepIndex)
+ assertEquals(5, originalState.totalSteps)
+ assertEquals("Reading file", originalState.description)
+ assertEquals(0, originalState.elapsedMillis)
+
+ assertEquals(0, copiedState.currentStepIndex)
+ assertEquals(5, copiedState.totalSteps)
+ assertEquals("Reading file", copiedState.description)
+ assertEquals(1000, copiedState.elapsedMillis)
+ }
+
+ @Test
+ fun testExecutingStateWithCustomStartTime() {
+ val customStartTime = System.currentTimeMillis() - 10000
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 5,
+ description = "Reading file",
+ startTime = customStartTime,
+ elapsedMillis = 5000
+ )
+
+ assertEquals(customStartTime, state.startTime)
+ assertEquals(5000, state.elapsedMillis)
+ }
+
+ @Test
+ fun testFormattedProgressWithSpecialCharacters() {
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 2,
+ description = "Read: app/src/main.kt"
+ )
+
+ assertEquals("Step 1 of 2: Read: app/src/main.kt", state.formattedProgress)
+ }
+
+ @Test
+ fun testFormattedTimingFormat() {
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 10,
+ description = "Processing",
+ elapsedMillis = 1500
+ )
+
+ val timing = state.formattedTiming
+ // Should match pattern like "(1.5s of 15.0s)"
+ assertTrue(timing.startsWith("("))
+ assertTrue(timing.endsWith(")"))
+ assertTrue(timing.contains("of"))
+ }
+
+ @Test
+ fun testFormattedTimingWith60Seconds() {
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 5,
+ description = "Reading file",
+ elapsedMillis = 60000 // Exactly 1 minute
+ )
+
+ val timing = state.formattedTiming
+ assertTrue(timing.contains("m"))
+ assertTrue(timing.contains("s"))
+ }
+
+ @Test
+ fun testFormattedTimingWith3Minutes() {
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 5,
+ description = "Reading file",
+ elapsedMillis = 180000 // 3 minutes
+ )
+
+ val timing = state.formattedTiming
+ assertTrue(timing.contains("3m"))
+ }
+}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/models/ChatSessionTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/models/ChatSessionTest.kt
new file mode 100644
index 00000000..1dd20a10
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/models/ChatSessionTest.kt
@@ -0,0 +1,99 @@
+package com.itsaky.androidide.plugins.aiassistant.models
+
+import org.junit.Test
+import org.junit.Assert.*
+
+class ChatSessionTest {
+
+ @Test
+ fun testChatSessionCreation() {
+ val session = ChatSession()
+
+ assertNotNull(session.id)
+ assertTrue(session.id.isNotEmpty())
+ assertTrue(session.createdAt > 0)
+ assertTrue(session.messages.isEmpty())
+ }
+
+ @Test
+ fun testChatSessionWithCustomId() {
+ val customId = "test-session-123"
+ val session = ChatSession(id = customId)
+
+ assertEquals(customId, session.id)
+ }
+
+ @Test
+ fun testChatSessionWithCustomCreatedAt() {
+ val customTime = 1234567890L
+ val session = ChatSession(createdAt = customTime)
+
+ assertEquals(customTime, session.createdAt)
+ }
+
+ @Test
+ fun testChatSessionTitle_NewChat() {
+ val session = ChatSession()
+
+ assertEquals("New Chat", session.title)
+ }
+
+ @Test
+ fun testChatSessionTitle_WithUserMessage() {
+ val messages = mutableListOf(
+ ChatMessage(
+ text = "Hello, assistant!",
+ sender = Sender.USER
+ )
+ )
+ val session = ChatSession(messages = messages)
+
+ assertEquals("Hello, assistant!", session.title)
+ }
+
+ @Test
+ fun testChatSessionTitle_WithMultipleMessages() {
+ val messages = mutableListOf(
+ ChatMessage(
+ text = "First user message",
+ sender = Sender.USER
+ ),
+ ChatMessage(
+ text = "Agent response",
+ sender = Sender.AGENT
+ ),
+ ChatMessage(
+ text = "Second user message",
+ sender = Sender.USER
+ )
+ )
+ val session = ChatSession(messages = messages)
+
+ // Should get the first user message
+ assertEquals("First user message", session.title)
+ }
+
+ @Test
+ fun testChatSessionFormattedDate() {
+ val session = ChatSession()
+
+ assertNotNull(session.formattedDate)
+ assertFalse(session.formattedDate.isEmpty())
+ // Check format contains expected pattern like "Jan 01, 2024"
+ assertTrue(session.formattedDate.contains(","))
+ }
+
+ @Test
+ fun testChatSessionMutableMessages() {
+ val session = ChatSession()
+ val message = ChatMessage(
+ text = "Test message",
+ sender = Sender.USER
+ )
+
+ session.messages.add(message)
+
+ assertEquals(1, session.messages.size)
+ assertEquals(message, session.messages[0])
+ }
+}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModelSessionTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModelSessionTest.kt
new file mode 100644
index 00000000..af4b5909
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModelSessionTest.kt
@@ -0,0 +1,144 @@
+package com.itsaky.androidide.plugins.aiassistant.viewmodel
+
+import org.junit.Test
+import org.junit.Assert.*
+import com.itsaky.androidide.plugins.aiassistant.models.ChatMessage
+import com.itsaky.androidide.plugins.aiassistant.models.ChatSession
+import com.itsaky.androidide.plugins.aiassistant.models.Sender
+
+/**
+ * Unit tests for ChatViewModel session management.
+ * Tests the core logic of session creation, switching, and deletion.
+ */
+class ChatViewModelSessionTest {
+
+ @Test
+ fun testSessionCreationLogic() {
+ val sessions = mutableListOf()
+
+ // Create first session
+ val session1 = ChatSession()
+ sessions.add(session1)
+
+ assertEquals(1, sessions.size)
+ assertNotNull(session1.id)
+ assertEquals("New Chat", session1.title)
+ }
+
+ @Test
+ fun testMultipleSessionCreation() {
+ val sessions = mutableListOf()
+
+ val session1 = ChatSession()
+ val session2 = ChatSession()
+ val session3 = ChatSession()
+
+ sessions.add(session1)
+ sessions.add(session2)
+ sessions.add(session3)
+
+ assertEquals(3, sessions.size)
+ assertNotEquals(session1.id, session2.id)
+ assertNotEquals(session2.id, session3.id)
+ }
+
+ @Test
+ fun testSessionSwitching() {
+ val sessions = mutableListOf()
+ val session1 = ChatSession()
+ val session2 = ChatSession()
+
+ sessions.add(session1)
+ sessions.add(session2)
+
+ // Switch to first session
+ val currentSession = sessions.firstOrNull { it.id == session1.id }
+
+ assertNotNull(currentSession)
+ assertEquals(session1.id, currentSession?.id)
+ }
+
+ @Test
+ fun testSessionDeletion() {
+ val sessions = mutableListOf()
+ val session1 = ChatSession()
+ val session2 = ChatSession()
+
+ sessions.add(session1)
+ sessions.add(session2)
+
+ // Delete first session
+ sessions.removeAll { it.id == session1.id }
+
+ assertEquals(1, sessions.size)
+ assertEquals(session2.id, sessions[0].id)
+ }
+
+ @Test
+ fun testDeleteCurrentSessionFallback() {
+ val sessions = mutableListOf()
+ var currentSessionId: String? = null
+
+ val session1 = ChatSession()
+ val session2 = ChatSession()
+
+ sessions.add(session1)
+ sessions.add(session2)
+ currentSessionId = session2.id
+
+ // Delete current session
+ sessions.removeAll { it.id == currentSessionId }
+
+ // Should fallback to remaining session
+ val remaining = sessions.firstOrNull()
+ if (remaining != null) {
+ currentSessionId = remaining.id
+ } else {
+ currentSessionId = null
+ }
+
+ assertEquals(session1.id, currentSessionId)
+ }
+
+ @Test
+ fun testDeleteLastSessionClears() {
+ val sessions = mutableListOf()
+ var currentSessionId: String? = null
+
+ val session = ChatSession()
+ sessions.add(session)
+ currentSessionId = session.id
+
+ // Delete the only session
+ sessions.removeAll { it.id == currentSessionId }
+
+ val remaining = sessions.firstOrNull()
+ if (remaining != null) {
+ currentSessionId = remaining.id
+ } else {
+ currentSessionId = null
+ }
+
+ assertEquals(0, sessions.size)
+ assertNull(currentSessionId)
+ }
+
+ @Test
+ fun testSessionTitleUpdatesWithMessages() {
+ val messages = mutableListOf(
+ ChatMessage(
+ text = "First message",
+ sender = Sender.USER
+ )
+ )
+ val session = ChatSession(messages = messages)
+
+ assertEquals("First message", session.title)
+
+ // Add more messages
+ messages.add(ChatMessage(text = "Second message", sender = Sender.AGENT))
+
+ // Title should still be the first user message
+ assertEquals("First message", session.title)
+ }
+}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModelStorageTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModelStorageTest.kt
new file mode 100644
index 00000000..543221b5
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModelStorageTest.kt
@@ -0,0 +1,265 @@
+package com.itsaky.androidide.plugins.aiassistant.viewmodel
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.itsaky.androidide.plugins.aiassistant.data.ChatStorageManager
+import com.itsaky.androidide.plugins.aiassistant.models.ChatMessage
+import com.itsaky.androidide.plugins.aiassistant.models.ChatSession
+import com.itsaky.androidide.plugins.aiassistant.models.Sender
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * Unit tests for ChatViewModel storage initialization logic.
+ * Tests the interaction between ChatViewModel and ChatStorageManager.
+ *
+ * Note: Full ViewModel lifecycle testing (including onCleared persistence)
+ * would require PluginContext which is not available in unit tests.
+ * These tests focus on the storage initialization and session loading logic.
+ */
+class ChatViewModelStorageTest {
+
+ private lateinit var androidContext: Context
+ private lateinit var sharedPreferences: SharedPreferences
+ private lateinit var editor: SharedPreferences.Editor
+ private lateinit var storageManager: ChatStorageManager
+
+ @Before
+ fun setup() {
+ // Mock Android components
+ androidContext = mockk(relaxed = true)
+ sharedPreferences = mockk(relaxed = true)
+ editor = mockk(relaxed = true)
+
+ every { androidContext.getSharedPreferences("ai_assistant_chats", Context.MODE_PRIVATE) } returns sharedPreferences
+ every { sharedPreferences.edit() } returns editor
+ every { editor.putString(any(), any()) } returns editor
+ every { editor.apply() } returns Unit
+
+ storageManager = ChatStorageManager(androidContext)
+ }
+
+ @Test
+ fun testStorageManagerInitialization() {
+ // Verify StorageManager can be created
+ assertNotNull(storageManager)
+ verify { androidContext.getSharedPreferences("ai_assistant_chats", Context.MODE_PRIVATE) }
+ }
+
+ @Test
+ fun testLoadSessionsWhenEmpty() {
+ every { sharedPreferences.getString("chat_sessions", null) } returns null
+
+ val sessions = storageManager.loadSessions()
+
+ assertTrue(sessions.isEmpty())
+ }
+
+ @Test
+ fun testLoadSessionsWithData() {
+ val sessionJson = """
+ [
+ {
+ "id": "test-session",
+ "createdAt": 1000,
+ "messages": []
+ }
+ ]
+ """.trimIndent()
+
+ every { sharedPreferences.getString("chat_sessions", null) } returns sessionJson
+
+ val sessions = storageManager.loadSessions()
+
+ assertEquals(1, sessions.size)
+ assertEquals("test-session", sessions[0].id)
+ }
+
+ @Test
+ fun testLoadCurrentSessionId() {
+ every { sharedPreferences.getString("current_session_id", null) } returns "session-123"
+
+ val sessionId = storageManager.loadCurrentSessionId()
+
+ assertEquals("session-123", sessionId)
+ }
+
+ @Test
+ fun testSessionRestorationFlow() {
+ // This tests the expected flow that ChatViewModel.loadSessions() should follow:
+ // 1. Load sessions from storage
+ // 2. Load current session ID
+ // 3. If sessions exist, restore the current one or fallback to first
+ // 4. If no sessions, create a new one
+
+ val session1 = ChatSession(id = "session-1", createdAt = 1000)
+ val session2 = ChatSession(id = "session-2", createdAt = 2000)
+ val sessions = listOf(session1, session2)
+
+ val sessionJson = """
+ [
+ {
+ "id": "session-1",
+ "createdAt": 1000,
+ "messages": []
+ },
+ {
+ "id": "session-2",
+ "createdAt": 2000,
+ "messages": []
+ }
+ ]
+ """.trimIndent()
+
+ every { sharedPreferences.getString("chat_sessions", null) } returns sessionJson
+ every { sharedPreferences.getString("current_session_id", null) } returns "session-2"
+
+ // Load sessions
+ val loadedSessions = storageManager.loadSessions()
+ assertEquals(2, loadedSessions.size)
+
+ // Load current ID
+ val currentId = storageManager.loadCurrentSessionId()
+ assertEquals("session-2", currentId)
+
+ // Find current session
+ val currentSession = loadedSessions.firstOrNull { it.id == currentId }
+ assertNotNull(currentSession)
+ assertEquals("session-2", currentSession?.id)
+ }
+
+ @Test
+ fun testSessionRestorationWithMissingCurrentId() {
+ val sessionJson = """
+ [
+ {
+ "id": "session-1",
+ "createdAt": 1000,
+ "messages": []
+ }
+ ]
+ """.trimIndent()
+
+ every { sharedPreferences.getString("chat_sessions", null) } returns sessionJson
+ every { sharedPreferences.getString("current_session_id", null) } returns "non-existent"
+
+ val loadedSessions = storageManager.loadSessions()
+ val currentId = storageManager.loadCurrentSessionId()
+
+ // Should load sessions successfully
+ assertEquals(1, loadedSessions.size)
+
+ // Current ID doesn't match any session
+ val currentSession = loadedSessions.firstOrNull { it.id == currentId }
+ assertFalse(currentSession != null)
+
+ // Fallback should be first session
+ val fallbackSession = loadedSessions.firstOrNull()
+ assertNotNull(fallbackSession)
+ assertEquals("session-1", fallbackSession?.id)
+ }
+
+ @Test
+ fun testSessionPersistenceFlow() {
+ // This tests the expected flow that ChatViewModel.onCleared() should follow:
+ // 1. Save all sessions
+ // 2. Save current session ID
+
+ val message = ChatMessage(text = "Test message", sender = Sender.USER)
+ val session = ChatSession(
+ id = "persist-test",
+ createdAt = 5000,
+ messages = mutableListOf(message)
+ )
+ val sessions = listOf(session)
+
+ // Save sessions
+ storageManager.saveSessions(sessions)
+ verify { editor.putString("chat_sessions", any()) }
+ verify { editor.apply() }
+
+ // Save current session ID
+ storageManager.saveCurrentSessionId("persist-test")
+ verify { editor.putString("current_session_id", "persist-test") }
+ verify(atLeast = 2) { editor.apply() }
+ }
+
+ @Test
+ fun testEmptySessionsCreatesNewSession() {
+ every { sharedPreferences.getString("chat_sessions", null) } returns "[]"
+
+ val sessions = storageManager.loadSessions()
+
+ // Should be empty, indicating ViewModel should create a new session
+ assertTrue(sessions.isEmpty())
+ }
+
+ @Test
+ fun testSessionWithMessagesRestoration() {
+ val sessionJson = """
+ [
+ {
+ "id": "msg-session",
+ "createdAt": 1000,
+ "messages": [
+ {
+ "id": "msg-1",
+ "text": "Hello",
+ "sender": "USER",
+ "status": "SENT",
+ "timestamp": 1000
+ },
+ {
+ "id": "msg-2",
+ "text": "Hi there",
+ "sender": "AGENT",
+ "status": "COMPLETED",
+ "timestamp": 2000,
+ "durationMs": 500
+ }
+ ]
+ }
+ ]
+ """.trimIndent()
+
+ every { sharedPreferences.getString("chat_sessions", null) } returns sessionJson
+
+ val sessions = storageManager.loadSessions()
+
+ assertEquals(1, sessions.size)
+ assertEquals(2, sessions[0].messages.size)
+ assertEquals("Hello", sessions[0].messages[0].text)
+ assertEquals(Sender.USER, sessions[0].messages[0].sender)
+ assertEquals("Hi there", sessions[0].messages[1].text)
+ assertEquals(Sender.AGENT, sessions[0].messages[1].sender)
+ }
+
+ @Test
+ fun testStorageHandlesCorruptedData() {
+ every { sharedPreferences.getString("chat_sessions", null) } returns "{invalid json"
+
+ val sessions = storageManager.loadSessions()
+
+ // Should handle error gracefully and return empty list
+ assertTrue(sessions.isEmpty())
+ }
+
+ @Test
+ fun testMultipleSessionsPersistence() {
+ val session1 = ChatSession(id = "s1", createdAt = 1000)
+ val session2 = ChatSession(id = "s2", createdAt = 2000)
+ val session3 = ChatSession(id = "s3", createdAt = 3000)
+
+ storageManager.saveSessions(listOf(session1, session2, session3))
+
+ verify { editor.putString("chat_sessions", any()) }
+ verify { editor.apply() }
+ }
+}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModelTimerTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModelTimerTest.kt
new file mode 100644
index 00000000..dd8998a8
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModelTimerTest.kt
@@ -0,0 +1,159 @@
+package com.itsaky.androidide.plugins.aiassistant.models
+
+import com.itsaky.androidide.plugins.aiassistant.models.AgentState
+import org.junit.Test
+import org.junit.Assert.*
+
+/**
+ * Unit tests for ChatViewModel timer mechanism for step progress updates.
+ * Tests the core logic of timer state transitions and Executing state properties.
+ */
+class ChatViewModelTimerTest {
+
+ @Test
+ fun testExecutingStateTimerInitialization() {
+ val beforeCreation = System.currentTimeMillis()
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 5,
+ description = "Processing",
+ startTime = System.currentTimeMillis(),
+ elapsedMillis = 0
+ )
+ val afterCreation = System.currentTimeMillis()
+
+ assertEquals(0, state.currentStepIndex)
+ assertEquals(5, state.totalSteps)
+ assertEquals("Processing", state.description)
+ assertEquals(0, state.elapsedMillis)
+ assertTrue(state.startTime >= beforeCreation)
+ assertTrue(state.startTime <= afterCreation + 100)
+ }
+
+ @Test
+ fun testExecutingStateTimerUpdating() {
+ val startTime = System.currentTimeMillis() - 1000
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 5,
+ description = "Processing",
+ startTime = startTime,
+ elapsedMillis = 1000
+ )
+
+ val updatedState = state.copy(elapsedMillis = 2000)
+
+ assertEquals(state.startTime, updatedState.startTime)
+ assertEquals(1000, state.elapsedMillis)
+ assertEquals(2000, updatedState.elapsedMillis)
+ assertEquals(state.description, updatedState.description)
+ }
+
+ @Test
+ fun testExecutingStateWithDefaultStartTime() {
+ val beforeCreation = System.currentTimeMillis()
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 5,
+ description = "Processing"
+ )
+ val afterCreation = System.currentTimeMillis()
+
+ assertTrue(state.startTime >= beforeCreation)
+ assertTrue(state.startTime <= afterCreation + 1000) // Allow 1 second buffer
+ }
+
+ @Test
+ fun testExecutingStateWithCustomStartTime() {
+ val customTime = System.currentTimeMillis() - 60000
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 5,
+ description = "Processing",
+ startTime = customTime
+ )
+
+ assertEquals(customTime, state.startTime)
+ }
+
+ @Test
+ fun testExecutingStateWithDefaultElapsedMillis() {
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 5,
+ description = "Processing"
+ )
+
+ assertEquals(0, state.elapsedMillis)
+ }
+
+ @Test
+ fun testExecutingStateWithCustomElapsedMillis() {
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 5,
+ description = "Processing",
+ elapsedMillis = 5000
+ )
+
+ assertEquals(5000, state.elapsedMillis)
+ }
+
+ @Test
+ fun testFormattedProgressFormat() {
+ val state = AgentState.Executing(
+ currentStepIndex = 0,
+ totalSteps = 3,
+ description = "Loading model"
+ )
+
+ val formattedProgress = state.formattedProgress
+ assertTrue(formattedProgress.contains("Step"))
+ assertTrue(formattedProgress.contains("of"))
+ assertTrue(formattedProgress.contains("Loading model"))
+ }
+
+ @Test
+ fun testFormattedTimingFormat() {
+ val state = AgentState.Executing(
+ currentStepIndex = 1,
+ totalSteps = 3,
+ description = "Processing",
+ elapsedMillis = 2000
+ )
+
+ val formattedTiming = state.formattedTiming
+ assertTrue(formattedTiming.startsWith("("))
+ assertTrue(formattedTiming.endsWith(")"))
+ assertTrue(formattedTiming.contains("of"))
+ }
+
+ @Test
+ fun testFormattedProgressMultipleSteps() {
+ val state1 = AgentState.Executing(0, 5, "First")
+ val state2 = AgentState.Executing(2, 5, "Middle")
+ val state3 = AgentState.Executing(4, 5, "Last")
+
+ assertEquals("Step 1 of 5: First", state1.formattedProgress)
+ assertEquals("Step 3 of 5: Middle", state2.formattedProgress)
+ assertEquals("Step 5 of 5: Last", state3.formattedProgress)
+ }
+
+ @Test
+ fun testTimerStateTransitions() {
+ val startTime = System.currentTimeMillis()
+
+ // Initial state
+ val state1 = AgentState.Executing(0, 5, "Start", startTime, 0)
+ assertEquals(0, state1.elapsedMillis)
+
+ // Simulating timer update
+ val state2 = state1.copy(elapsedMillis = 500)
+ assertEquals(500, state2.elapsedMillis)
+
+ // Another update
+ val state3 = state2.copy(elapsedMillis = 1000)
+ assertEquals(1000, state3.elapsedMillis)
+ assertEquals(state1.startTime, state3.startTime)
+ }
+}
diff --git a/ai-core/.gitignore b/ai-core/.gitignore
new file mode 100644
index 00000000..5380c6d5
--- /dev/null
+++ b/ai-core/.gitignore
@@ -0,0 +1,3 @@
+**/.cxx/
+build-output.log
+**/.kotlin/
diff --git a/ai-core/BUILDING.md b/ai-core/BUILDING.md
new file mode 100644
index 00000000..70a7aef8
--- /dev/null
+++ b/ai-core/BUILDING.md
@@ -0,0 +1,401 @@
+# Building AI Assistant Plugins - Complete Guide
+
+This guide explains how to build the AI assistant plugins.
+
+> **TL;DR** — A normal build needs only the Android SDK + JDK 17. The native
+> llama.cpp library ships **prebuilt** as `ai-core/libs/v8/llama-v8-release.aar`,
+> so you do **not** need the git submodule, the NDK, or CMake to build the
+> plugins. Those are only required when *regenerating* that AAR (see
+> [Updating llama.cpp](#updating-llamacpp-regenerating-the-aar)).
+
+## Prerequisites
+
+### Required for a normal build
+- **Android Studio** or **Android SDK CLI tools** (API 33+)
+- **JDK 17+**
+- **Git**
+
+### Additionally required only to regenerate the native AAR
+- **Android NDK** (r26 or later)
+- **CMake** 3.22.1+ (usually bundled with Android SDK)
+- The `subprojects/llama.cpp` git submodule checked out
+
+### System Requirements
+- **macOS**, **Linux**, or **Windows** (with WSL recommended for Windows)
+- **16GB RAM minimum** (32GB recommended for faster builds)
+- **10GB free disk space** (for SDK, NDK, and build outputs)
+
+---
+
+## Step 1: Configure the Android SDK
+
+Create or update `local.properties` in **each plugin directory** you build
+(`ai-core/` and, for the frontend, `ai-assistant/`):
+
+```properties
+# Path to Android SDK (required)
+sdk.dir=/Users/your-username/Library/Android/sdk
+
+# NDK path — only needed to regenerate the native AAR, not for a normal build
+ndk.dir=/Users/your-username/Library/Android/sdk/ndk/27.0.12077973
+```
+
+That's the only setup a normal build needs. The native llama.cpp library is
+already committed as `ai-core/libs/v8/llama-v8-release.aar` (the JNI
+wrapper plus `.so` files for arm64-v8a, armeabi-v7a, x86 and x86_64), with its
+tiny interface jar at `ai-core/libs/llama-api.jar`.
+
+---
+
+## Step 2: Build the Plugins
+
+### Option A: Build via Gradle (Recommended)
+
+`ai-core` and `ai-assistant` are **independent** Gradle builds — build each in
+its own directory:
+
+```bash
+# release .cgp for each plugin
+cd plugin-examples/ai-core && ./gradlew assemblePlugin
+cd ../ai-assistant && ./gradlew assemblePlugin
+
+# Debug variant
+cd plugin-examples/ai-core && ./gradlew assemblePluginDebug
+```
+
+### Option B: Build via Android Studio
+
+1. Open Android Studio
+2. **File → Open** → Select `plugin-examples/ai-core/` (or `plugin-examples/ai-assistant/`)
+3. Wait for Gradle sync
+4. Run the `assemblePlugin` task from the Gradle panel
+
+---
+
+## Step 3: Outputs & Installation
+
+### Build Outputs
+
+`assemblePlugin` writes ready-to-install `.cgp` files directly:
+
+```
+ai-core/build/plugin/ai-core.cgp
+ai-assistant/build/plugin/ai-assistant.cgp
+```
+
+No renaming needed.
+
+### Install on Device
+
+```bash
+# Push plugins to device
+adb push ai-core/build/plugin/ai-core.cgp /sdcard/Download/
+adb push ai-assistant/build/plugin/ai-assistant.cgp /sdcard/Download/
+
+# Then install via CodeOnTheGo Plugin Manager:
+# 1. Open CodeOnTheGo app
+# 2. Navigate to Settings → Plugins
+# 3. Tap "Install from file"
+# 4. Select ai-core.cgp first
+# 5. Then install ai-assistant.cgp
+# 6. Restart CodeOnTheGo
+```
+
+---
+
+## Updating llama.cpp (regenerating the AAR)
+
+You only need this when your llama.cpp fork changes. It requires the NDK/CMake
+toolchain and the submodule; a normal build does not.
+
+```bash
+# Run from the ai-core plugin dir. One command: inits the submodule, compiles
+# :llama-impl + :llama-api from source, copies fresh artifacts into ai-core/libs/.
+cd plugin-examples/ai-core
+./scripts/rebuild-llama-aar.sh
+
+# To point at a specific fork commit first:
+git -C subprojects/llama.cpp fetch origin
+git -C subprojects/llama.cpp checkout
+./scripts/rebuild-llama-aar.sh
+```
+
+Then commit the refreshed `ai-core/libs/v8/llama-v8-release.aar` and
+`ai-core/libs/llama-api.jar`. The `:llama-impl` / `:llama-api` Gradle
+modules load only while the submodule is checked out, so they never affect a
+normal build.
+
+> Reproducibility: the AAR is a compiled binary, so ideally regenerate it in CI
+> with a pinned NDK/CMake and record its checksum, rather than from an arbitrary
+> laptop.
+
+---
+
+## Troubleshooting
+
+### Native library not found (`UnsatisfiedLinkError`)
+
+The prebuilt AAR bundles arm64-v8a, armeabi-v7a, x86 and x86_64. If you rebuilt
+it with a restricted ABI set, confirm your device's ABI is included:
+
+```bash
+unzip -l ai-core/libs/v8/llama-v8-release.aar | grep 'jni/'
+```
+
+### llama.cpp Not Found (only when regenerating the AAR)
+
+**Error:**
+```
+CMake Error: add_subdirectory given source ".../subprojects/llama.cpp" which is not an existing directory
+```
+
+**Solution:**
+```bash
+# The submodule is required only for regeneration. Initialize it:
+git submodule update --init --recursive
+```
+
+### NDK Not Found
+
+**Error:**
+```
+NDK is not installed
+```
+
+**Solution:**
+1. Install NDK via Android Studio (see Step 2)
+2. Or download directly: https://developer.android.com/ndk/downloads
+3. Update `local.properties` with correct `ndk.dir` path
+
+### CMake Version Mismatch
+
+**Error:**
+```
+CMake 3.22.1 or higher is required
+```
+
+**Solution:**
+```bash
+# Install via Android Studio SDK Manager (SDK Tools → CMake)
+# Or via Homebrew on macOS:
+brew install cmake
+
+# Verify version
+cmake --version
+```
+
+### Out of Memory During Build
+
+**Error:**
+```
+java.lang.OutOfMemoryError: Java heap space
+```
+
+**Solution:**
+Create or update `gradle.properties`:
+```properties
+org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m
+```
+
+### Native Library Loading Errors
+
+**Error:**
+```
+java.lang.UnsatisfiedLinkError: dlopen failed: library "llama-android" not found
+```
+
+**Solution:**
+1. Ensure you built the correct ABI for your device (v8 for 64-bit ARM)
+2. Check that native libraries are included in the APK:
+ ```bash
+ unzip -l ai-assistant-plugin.apk | grep "lib/"
+ # Should show: lib/arm64-v8a/libllama-android.so
+ ```
+
+### Content URI Resolution Errors
+
+**Error:**
+```
+Failed to resolve content URI to file path
+```
+
+**Solution:**
+1. Place GGUF model file in `/sdcard/Download/` directory
+2. Grant storage permissions to CodeOnTheGo app
+3. Use file picker in AI Settings to select model
+
+---
+
+## Development Workflow
+
+### Incremental Builds
+
+For faster iteration during development:
+
+```bash
+# Only rebuild changed modules
+./gradlew :ai-assistant-plugin:assembleV8Debug
+
+# Clean specific module
+./gradlew :ai-core-plugin:clean
+
+# Full clean (when CMake cache is stale)
+./gradlew clean
+rm -rf .gradle .cxx build
+```
+
+### Testing Native Code Changes
+
+When modifying `llama-android.cpp` or `llama.cpp` sources:
+
+```bash
+# Clean CMake cache to force recompilation
+rm -rf llama-impl/.cxx
+
+# Rebuild
+./gradlew :llama-impl:assembleV8Debug
+```
+
+### Debugging with Logcat
+
+```bash
+# Watch plugin logs
+adb logcat | grep -E "Plugin|llama|AiCore|AiAssistant"
+
+# Filter JNI errors
+adb logcat | grep -E "UnsatisfiedLinkError|JNI"
+
+# Full verbose output
+adb logcat -v time '*:V'
+```
+
+---
+
+## Architecture Notes
+
+### llama.cpp Integration
+
+The native integration works as follows:
+
+1. **llama-impl/src/main/cpp/CMakeLists.txt** references `llama.cpp/` via `add_subdirectory()`
+2. **llama-android.cpp** provides JNI bindings to llama.cpp's C++ API
+3. **LLamaAndroid.kt** wraps the JNI calls with Kotlin-friendly interfaces
+4. **ai-core-plugin** uses the llama-api/llama-impl modules to provide LLM inference services
+5. **ai-assistant-plugin** consumes the services via SharedServices plugin API
+
+### Multi-Module Dependencies
+
+Default build (self-contained — consumes the prebuilt artifacts):
+
+```
+ai-assistant-plugin → (SharedServices) → ai-core-plugin → libs/v8/llama-v8-release.aar (native)
+ ↘ libs/llama-api.jar (interfaces)
+```
+
+Regeneration path (only when rebuilding the AAR from source):
+
+```
+llama-impl → llama.cpp (native, git submodule)
+llama-api → interfaces
+ ⇒ scripts/rebuild-llama-aar.sh copies their output into ai-core/libs/
+```
+
+Both plugins depend on `plugin-api` from the parent `plugin-examples/libs/` directory.
+
+---
+
+## Customizations to llama.cpp
+
+This build uses llama.cpp with the following Android-specific modifications:
+
+### Custom Patches Applied
+
+1. **Content URI Resolution** - Converts Android file picker URIs to native paths
+2. **JNI Thread Safety** - Ensures native library loads before static calls
+3. **Tool Calling** - JSON-based function calling for agentic workflows
+4. **Chat History** - Persistent conversation state management
+5. **Memory Optimizations** - Reduced memory footprint for mobile devices
+
+### Upstream Compatibility
+
+The modifications are designed to be compatible with upstream llama.cpp. To update to a newer llama.cpp version:
+
+```bash
+cd ../llama.cpp
+git fetch origin
+git merge origin/master
+
+# Resolve any conflicts in Android-specific code, then regenerate the AAR
+cd ../plugin-examples/ai-core
+./scripts/rebuild-llama-aar.sh
+```
+
+---
+
+## Building for Production
+
+### Release Build
+
+```bash
+# Build optimized release variants
+./gradlew assembleV8Release
+
+# Outputs with ProGuard/R8 optimization
+ai-core/build/outputs/apk/v8/release/ai-core-plugin-v8-release.apk
+ai-assistant/build/outputs/apk/v8/release/ai-assistant-plugin-v8-release.apk
+```
+
+### Code Signing
+
+For distribution, sign the APKs:
+
+```bash
+# Generate keystore (one-time)
+keytool -genkey -v -keystore release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias ai-plugins
+
+# Sign APK
+jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
+ -keystore release-key.jks \
+ ai-core-plugin-v8-release.apk ai-plugins
+```
+
+### Size Optimization
+
+Current release sizes:
+- **ai-core-plugin**: ~5 MB (includes llama.cpp native libraries)
+- **ai-assistant-plugin**: ~2 MB (UI and business logic)
+
+To reduce size:
+1. Build only arm64-v8a ABI (skip v7/x86)
+2. Enable R8 full mode in `gradle.properties`
+3. Strip debug symbols from native libraries
+
+---
+
+## Contributing
+
+When contributing changes to the AI plugins:
+
+1. Test on **physical ARM64 device** (emulators may not support native code)
+2. Verify both **ai-core** and **ai-assistant** plugins work independently
+3. Ensure **llama.cpp submodule** updates are documented
+4. Run ProGuard/R8 release build to catch reflection issues
+5. Update this BUILDING.md with any new dependencies or steps
+
+---
+
+## Additional Resources
+
+- [llama.cpp Official Repo](https://github.com/ggml-org/llama.cpp)
+- [AndroidIDE Plugin Documentation](https://www.appdevforall.org/codeonthego/help/exp-plugins-top.html)
+- [GGUF Model Downloads](https://huggingface.co/models?library=gguf)
+- [Android NDK Documentation](https://developer.android.com/ndk)
+
+---
+
+**Last Updated:** 2026-06-24
+**Tested with:**
+- Android SDK 34
+- NDK 26.1.10909125
+- llama.cpp commit b6521
+- Gradle 8.7
diff --git a/ai-core/README.md b/ai-core/README.md
new file mode 100644
index 00000000..7edbfb46
--- /dev/null
+++ b/ai-core/README.md
@@ -0,0 +1,53 @@
+# AI Core plugin for CodeOnTheGo
+
+The shared **LLM inference backend** for CodeOnTheGo's AI plugins. It exposes an
+inference service (via SharedServices) that other plugins — e.g. the sibling
+[`ai-assistant`](../ai-assistant/) — consume at runtime. It ships two backends:
+
+- **Local (on-device)** — runs GGUF models through a bundled, prebuilt
+ **llama.cpp** AAR.
+- **Gemini** — calls the Gemini API through the Google Generative AI SDK.
+
+## Building
+
+Prerequisites: Android SDK (API 33+), JDK 17. Create `local.properties` with
+`sdk.dir=...`. A normal build needs **no NDK, submodule, or CMake** — the native
+library is committed prebuilt.
+
+```bash
+cd ai-core
+./gradlew assemblePlugin # release -> build/plugin/ai-core.cgp
+./gradlew assemblePluginDebug # debug variant
+```
+
+The build resolves `plugin-api.jar` from the repo-root `../libs/` and the native
+library from `libs/v8/llama-v8-release.aar` + `libs/llama-api.jar`.
+
+## Native llama.cpp: prebuilt by default
+
+The plugin consumes the committed AAR, so the `llama.cpp` git submodule and the
+`llama-api` / `llama-impl` source modules are **not** part of a normal build.
+`settings.gradle.kts` includes those modules **only** when the submodule is
+checked out (`subprojects/llama.cpp/CMakeLists.txt` exists) — which is exactly
+the setup a CI checkout does *not* have, so CI builds against the prebuilt AAR.
+
+To regenerate the AAR after bumping the llama.cpp fork, see **[BUILDING.md](BUILDING.md)**
+and `scripts/rebuild-llama-aar.sh` (requires the submodule + NDK/CMake). Notes on
+the fork live in [SUBMODULE_NOTES.md](SUBMODULE_NOTES.md).
+
+## Installation
+
+Install **`ai-core` before `ai-assistant`** — the assistant resolves this
+plugin's inference service at runtime. Copy `build/plugin/ai-core.cgp` to the
+device and install via CodeOnTheGo's Plugin Manager, then restart the IDE.
+
+## Key classes
+
+- `AiCorePlugin.kt` — plugin entry point; registers the backends
+- `LocalLlmBackend.kt` — on-device GGUF inference over the llama.cpp AAR
+- `GeminiBackend.kt` — Gemini API backend
+- `LlmInferenceServiceImpl.kt` — the SharedServices-exposed inference service
+
+## License
+
+GPL-3.0 — same as AndroidIDE / CodeOnTheGo.
diff --git a/ai-core/SUBMODULE_NOTES.md b/ai-core/SUBMODULE_NOTES.md
new file mode 100644
index 00000000..3241f495
--- /dev/null
+++ b/ai-core/SUBMODULE_NOTES.md
@@ -0,0 +1,102 @@
+# llama.cpp Submodule Notes
+
+## Structure
+
+The llama.cpp source is managed as a git submodule pointing to:
+- **Repository:** `appdevforall/llama.cpp`
+- **Branch:** `androidide-custom`
+- **Path:** `subprojects/llama.cpp/`
+
+## Updating the Submodule
+
+When llama.cpp fork is updated:
+
+```bash
+cd subprojects/llama.cpp
+git fetch origin
+git checkout androidide-custom
+git pull origin androidide-custom
+cd ../..
+git add subprojects/llama.cpp
+git commit -m "chore: Update llama.cpp submodule to latest androidide-custom"
+git push
+```
+
+Or use git's built-in submodule update:
+
+```bash
+git submodule update --remote subprojects/llama.cpp
+git add subprojects/llama.cpp
+git commit -m "chore: Update llama.cpp submodule"
+git push
+```
+
+## Cloning This Repository
+
+When cloning plugin-examples, initialize submodules:
+
+```bash
+git clone git@github.com:appdevforall/plugin-examples.git
+cd plugin-examples/ai-assistant
+git submodule update --init --recursive
+```
+
+Or clone with submodules in one command:
+
+```bash
+git clone --recurse-submodules git@github.com:appdevforall/plugin-examples.git
+```
+
+## Build Requirements
+
+The submodule must be initialized before building:
+
+```bash
+# Check submodule status
+git submodule status
+
+# If submodule is not initialized (shows '-' prefix):
+git submodule update --init --recursive
+```
+
+## CMake Path
+
+The CMakeLists.txt references the submodule:
+```cmake
+add_subdirectory(../../../../subprojects/llama.cpp/ build-llama)
+```
+
+This path is relative from `llama-impl/src/main/cpp/CMakeLists.txt` to `subprojects/llama.cpp/`.
+
+## Troubleshooting
+
+### Submodule shows modified but you didn't change it
+
+```bash
+cd subprojects/llama.cpp
+git status
+git diff
+```
+
+If changes exist, either commit them or reset:
+```bash
+git reset --hard origin/androidide-custom
+```
+
+### Build fails with "llama not found"
+
+Ensure submodule is initialized:
+```bash
+git submodule update --init --recursive
+ls -la subprojects/llama.cpp/
+```
+
+### Want to switch to different commit
+
+```bash
+cd subprojects/llama.cpp
+git checkout
+cd ../..
+git add subprojects/llama.cpp
+git commit -m "chore: Pin llama.cpp to specific commit"
+```
diff --git a/ai-core/build.gradle.kts b/ai-core/build.gradle.kts
new file mode 100644
index 00000000..6d982161
--- /dev/null
+++ b/ai-core/build.gradle.kts
@@ -0,0 +1,82 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.itsaky.androidide.plugins.build")
+}
+
+pluginBuilder {
+ pluginName = "ai-core"
+}
+
+android {
+ namespace = "com.itsaky.androidide.plugins.aicore"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.itsaky.androidide.plugins.aicore"
+ minSdk = 33
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0.0"
+ }
+
+ buildTypes {
+ release {
+ // Disable minification to prevent JNI method stripping (IntVar.getValue)
+ isMinifyEnabled = false
+ isShrinkResources = false
+ signingConfig = signingConfigs.getByName("debug")
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
+ }
+
+ packaging {
+ resources {
+ excludes += setOf(
+ "META-INF/DEPENDENCIES",
+ "META-INF/LICENSE",
+ "META-INF/LICENSE.txt",
+ "META-INF/NOTICE",
+ "META-INF/NOTICE.txt",
+ "META-INF/INDEX.LIST"
+ )
+ }
+ }
+}
+
+dependencies {
+ compileOnly(files("../libs/plugin-api.jar"))
+
+ implementation(files("libs/v8/llama-v8-release.aar"))
+ implementation(files("libs/llama-api.jar"))
+
+ implementation("androidx.appcompat:appcompat:1.6.1")
+ implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+
+ // Google Generative AI SDK for Gemini API
+ implementation("com.google.genai:google-genai:1.16.0")
+
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("io.mockk:mockk:1.13.8")
+}
+
+// AAR metadata checks are disabled by convention for these application-as-library
+// plugins. The prebuilt llama .aar carries a "core library desugaring required"
+// flag, but this module's minSdk (33) makes desugaring unnecessary at runtime,
+// so the check is a false positive here.
+tasks.matching {
+ it.name.contains("checkDebugAarMetadata") ||
+ it.name.contains("checkReleaseAarMetadata")
+}.configureEach { enabled = false }
diff --git a/ai-core/gradle.properties b/ai-core/gradle.properties
new file mode 100644
index 00000000..fcd58cda
--- /dev/null
+++ b/ai-core/gradle.properties
@@ -0,0 +1,10 @@
+android.enableJetifier=false
+android.jetifier.ignorelist=common-30.2.2.jar
+android.nonTransitiveRClass=false
+android.useAndroidX=true
+org.gradle.caching=true
+org.gradle.configureondemand=true
+org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M"
+org.gradle.parallel=true
+
+kotlin.code.style=official
diff --git a/ai-core/gradle/libs.versions.toml b/ai-core/gradle/libs.versions.toml
new file mode 100644
index 00000000..802507a0
--- /dev/null
+++ b/ai-core/gradle/libs.versions.toml
@@ -0,0 +1,334 @@
+[versions]
+activityKtx = "1.8.2"
+agp = "8.13.2"
+agp-tooling = "8.11.0"
+androidx-sqlite = "2.6.2"
+appcompatVersion = "1.7.1"
+bcprovJdk18on = "1.80"
+colorpickerview = "2.3.0"
+commonsCompress = "1.27.1"
+tukaaniXz = "1.9"
+commonsText = "1.11.0"
+commonsTextVersion = "1.14.0"
+constraintlayout = "2.1.4"
+coreKtx = "1.12.0"
+coreKtxVersion = "1.17.0"
+desugar_jdk_libs = "2.1.5"
+googleGenai = "1.16.0"
+fragment = "1.8.8"
+gradle-tooling = "8.6"
+gson = "2.10.1"
+junit-jupiter = "5.10.2"
+anroidx-test-core = "2.2.0"
+koinAndroid = "4.1.1"
+kotlin = "2.3.0"
+kotlin-coroutines = "1.9.0"
+kotlinxCoroutinesCore = "1.10.2"
+kotlinxSerializationJson = "1.9.0"
+lifecycleViewmodelKtx = "2.7.0"
+paletteKtx = "1.0.0"
+playServicesOssLicenses = "17.1.0"
+preferenceKtxVersion = "1.2.1"
+recyclerview = "1.3.2"
+securityCrypto = "1.1.0"
+tree-sitter = "4.3.2"
+editor = "0.23.6"
+glide = "4.16.0"
+androidx-vectordrawable = "1.2.0"
+androidx-navigation = "2.7.7"
+ksp = "2.3.6"
+antlr4 = "4.13.1"
+androidx-work = "2.10.0"
+androidx-espresso = "3.5.1"
+retrofit = "2.11.0"
+markwon = "4.6.2"
+maven-publish-plugin = "0.27.0"
+logback = "1.5.3"
+room = "2.8.4"
+utilcodex = "1.31.1"
+viewpager2 = "1.1.0-beta02"
+zoomage = "1.3.1"
+monitor = "1.6.1"
+monitorVersion = "1.7.2"
+sentry = "8.29.0"
+sentry-gradle-plugin = "5.12.2"
+mockk = "1.14.5"
+firebase-bom = "33.7.0"
+google-services = "4.4.2"
+
+rikka-hidden-api = "4.4.0"
+rikka-refine = "4.4.0"
+rikkax-parcelablelist = "2.0.1"
+libsu-core = "6.0.0"
+
+## IMPORTANT!
+## When updating this value, please manually compile libbrotli.so from Brotli4j for Android and
+## put it in app/src/main/jniLibs
+brotli4j = "1.18.0"
+spotless = "7.2.1"
+fragmentKtx = "1.8.8"
+
+protobuf = "4.32.1"
+protobuf-plugin = "0.9.5"
+protoc-gen-kotlin-ext = "1.1.0"
+
+sonarqube = "7.0.0.6105"
+
+compose-bom = "2024.02.00"
+compose-compiler = "2.1.21"
+
+leakcanary = "2.14"
+
+pebble = "4.1.1"
+
+
+[libraries]
+
+# Dependencies in composite build
+androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtx" }
+androidx-appcompat-v171 = { module = "androidx.appcompat:appcompat", version.ref = "appcompatVersion" }
+androidx-constraintlayout-v214 = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
+androidx-core-ktx-v1120 = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
+androidx-core-ktx-v1170 = { module = "androidx.core:core-ktx", version.ref = "coreKtxVersion" }
+androidx-fragment = { module = "androidx.fragment:fragment", version.ref = "fragment" }
+androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
+androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycleViewmodelKtx" }
+androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleViewmodelKtx" }
+androidx-palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "paletteKtx" }
+androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtxVersion" }
+androidx-recyclerview-v132 = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
+androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" }
+androidx-sqlite-ktx = { module = "androidx.sqlite:sqlite-ktx", version.ref = "androidx-sqlite" }
+androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "androidx-sqlite" }
+androidx-viewpager2-v110beta02 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" }
+bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bcprovJdk18on" }
+bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprovJdk18on" }
+colorpickerview = { module = "com.github.skydoves:colorpickerview", version.ref = "colorpickerview" }
+commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "commonsCompress" }
+tukaani-xz = { module = "org.tukaani:xz", version.ref = "tukaaniXz" }
+commons-text = { module = "org.apache.commons:commons-text", version.ref = "commonsText" }
+commons-text-v1140 = { module = "org.apache.commons:commons-text", version.ref = "commonsTextVersion" }
+composite-appintro = { module = "com.itsaky.androidide.build:appintro" }
+composite-constants = { module = "com.itsaky.androidide.build:constants" }
+composite-desugaringCore = { module = "com.itsaky.androidide.build:desugaring-core" }
+composite-fuzzysearch = { module = "com.itsaky.androidide.build:fuzzysearch" }
+composite-googleJavaFormat = { module = "com.itsaky.androidide.build:google-java-format" }
+composite-javac = { module = "com.itsaky.androidide.build:javac" }
+composite-javapoet = { module = "com.itsaky.androidide.build:javapoet" }
+composite-jaxp = { module = "com.itsaky.androidide.build:jaxp" }
+composite-jdt = { module = "com.itsaky.androidide.build:jdt" }
+composite-layoutlibApi = { module = "com.itsaky.androidide.build:layoutlib-api" }
+composite-treeview = { module = "com.itsaky.androidide.build:treeview" }
+
+# Room
+desugar_jdk_libs-v215 = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
+google-genai = { module = "com.google.genai:google-genai", version.ref = "googleGenai" }
+gson-v2101 = { module = "com.google.code.gson:gson", version.ref = "gson" }
+koin-android = { module = "io.insert-koin:koin-android", version.ref = "koinAndroid" }
+kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
+kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
+room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
+room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
+
+# Compose
+compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
+compose-runtime = { module = "androidx.compose.runtime:runtime" }
+compose-ui = { module = "androidx.compose.ui:ui" }
+compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
+compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
+compose-foundation = { module = "androidx.compose.foundation:foundation" }
+compose-material3 = { module = "androidx.compose.material3:material3" }
+compose-activity = { module = "androidx.activity:activity-compose", version = "1.8.2" }
+
+# Firebase
+firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" }
+firebase-analytics = { module = "com.google.firebase:firebase-analytics-ktx" }
+
+# Sentry
+sentry-core = { module = "io.sentry:sentry", version.ref = "sentry" }
+sentry-android-core = { module = "io.sentry:sentry-android-core", version.ref = "sentry" }
+sentry-android-replay = { module = "io.sentry:sentry-android-replay", version.ref = "sentry" }
+
+# AndroidIDE
+androidide-ts = { module = "com.itsaky.androidide.treesitter:android-tree-sitter", version.ref = "tree-sitter" }
+androidide-ts-java = { module = "com.itsaky.androidide.treesitter:tree-sitter-java", version.ref = "tree-sitter" }
+androidide-ts-json = { module = "com.itsaky.androidide.treesitter:tree-sitter-json", version.ref = "tree-sitter" }
+androidide-ts-kotlin = { module = "com.itsaky.androidide.treesitter:tree-sitter-kotlin", version.ref = "tree-sitter" }
+androidide-ts-log = { module = "com.itsaky.androidide.treesitter:tree-sitter-log", version.ref = "tree-sitter" }
+androidide-ts-xml = { module = "com.itsaky.androidide.treesitter:tree-sitter-xml", version.ref = "tree-sitter" }
+
+#Layout Editor
+utilcodex = { module = "com.blankj:utilcodex", version.ref = "utilcodex" }
+zoomage = { module = "com.jsibbold:zoomage", version.ref = "zoomage" }
+sora-bom = { module = "io.github.Rosemoe.sora-editor:bom", version.ref = "editor" }
+sora-language-textmate = { module = "io.github.Rosemoe.sora-editor:language-textmate" }
+
+# Common
+common-editor = { module = "io.github.Rosemoe.sora-editor:editor" }
+common-utilcode = { module = "com.blankj:utilcodex", version = "1.31.1" }
+common-glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" }
+common-glide_ap = { module = "com.github.bumptech.glide:compiler", version.ref = "glide" }
+common-jsoup = { module = "org.jsoup:jsoup", version = "1.19.1" }
+common-antlr4 = { module = "org.antlr:antlr4", version.ref = "antlr4" }
+common-antlr4-runtime = { module = "org.antlr:antlr4-runtime", version.ref = "antlr4" }
+common-javaparser = { module = "com.github.javaparser:javaparser-symbol-solver-core", version = "3.25.8" }
+common-lang3 = { module = "org.apache.commons:commons-lang3", version = "3.14.0" }
+common-io = { module = "commons-io:commons-io", version = "2.15.1" }
+common-kotlin = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" }
+common-kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlin-coroutines" }
+common-kotlin-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlin-coroutines" }
+common-jkotlin = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" }
+common-javapoet = { module = "com.squareup:javapoet", version = "1.13.0" }
+common-ksp = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
+common-jsonrpc = { module = "org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc", version = "0.22.0" }
+common-lsp4j = { module = "org.eclipse.lsp4j:org.eclipse.lsp4j", version = "0.22.0" }
+common-retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
+common-retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
+common-leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanary" }
+common-markwon-core = { module = "io.noties.markwon:core", version.ref = "markwon" }
+common-markwon-extStrikethrough = { module = "io.noties.markwon:ext-strikethrough", version.ref = "markwon" }
+common-markwon-linkify = { module = "io.noties.markwon:linkify", version.ref = "markwon" }
+common-markwon-recycler = { module = "io.noties.markwon:recycler", version.ref = "markwon" }
+common-hiddenApiBypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version = "6.1" }
+common-termuxAmLib = { module = "com.termux:termux-am-library", version = "v2.0.0" }
+common-charts = { module = "com.github.AppDevNext:AndroidChart", version = "3.1.0.21" }
+brotli4j = { module = "com.aayushatharva.brotli4j:brotli4j", version.ref = "brotli4j" }
+brotli4j-linux-x64 = { module = "com.aayushatharva.brotli4j:native-linux-x86_64", version.ref = "brotli4j" }
+brotli4j-linux-aarch64 = { module = "com.aayushatharva.brotli4j:native-linux-aarch64", version.ref = "brotli4j" }
+brotli4j-windows-x64 = { module = "com.aayushatharva.brotli4j:native-windows-x86_64", version.ref = "brotli4j" }
+brotli4j-windows-aarch64 = { module = "com.aayushatharva.brotli4j:native-windows-aarch64", version.ref = "brotli4j" }
+brotli4j-osx-x64 = { module = "com.aayushatharva.brotli4j:native-osx-x86_64", version.ref = "brotli4j" }
+brotli4j-osx-aarch64 = { module = "com.aayushatharva.brotli4j:native-osx-aarch64", version.ref = "brotli4j" }
+
+rikka-hidden-compat = { module = "dev.rikka.hidden:compat", version.ref = "rikka-hidden-api" }
+rikka-hidden-stub = { module = "dev.rikka.hidden:stub", version.ref = "rikka-hidden-api" }
+rikka-refine-runtime = { module = "dev.rikka.tools.refine:runtime", version.ref = "rikka-refine" }
+rikka-refine-annotation = { module = "dev.rikka.tools.refine:annotation", version.ref = "rikka-refine" }
+rikka-refine-annotation-processor = { module = "dev.rikka.tools.refine:annotation-processor", version.ref = "rikka-refine" }
+rikkax-parcelablelist = { module = "dev.rikka.rikkax.parcelablelist:parcelablelist", version.ref = "rikkax-parcelablelist" }
+rikkax-htmlktx = { module = "dev.rikka.rikkax.html:html-ktx", version = "1.1.2" }
+libsu-core = { module = "com.github.topjohnwu.libsu:core", version.ref = "libsu-core" }
+boringssl = { module = "io.github.vvb2060.ndk:boringssl", version = "20250114" }
+
+# AndroidX
+androidx-annotation = { module = "androidx.annotation:annotation", version = "1.9.1" }
+androidx-appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.0" }
+androidx-cardview = { module = "androidx.cardview:cardview", version = "1.0.0" }
+androidx-collection = { module = "androidx.collection:collection", version = "1.4.0" }
+androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version = "2.2.1" }
+androidx-coordinatorlayout = { module = "androidx.coordinatorlayout:coordinatorlayout", version = "1.3.0" }
+androidx-drawer = { module = "androidx.drawerlayout:drawerlayout", version = "1.2.0" }
+androidx-grid = { module = "androidx.gridlayout:gridlayout", version = "1.0.0" }
+androidx-preference = { module = "androidx.preference:preference", version = "1.2.1" }
+androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.4.0" }
+androidx-vectors = { module = "androidx.vectordrawable:vectordrawable", version.ref = "androidx-vectordrawable" }
+androidx-animated_vectors = { module = "androidx.vectordrawable:vectordrawable-animated", version.ref = "androidx-vectordrawable" }
+androidx-core = { module = "androidx.core:core", version = "1.13.1" }
+androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.13.1" }
+androidx-fragment_ktx = { module = "androidx.fragment:fragment-ktx", version = "1.6.2" }
+androidx-libDesugaring = { module = "com.android.tools:desugar_jdk_libs", version = "2.0.4" }
+androidx-splashscreen = { module = "androidx.core:core-splashscreen", version = "1.0.1" }
+androidx-transition = { module = "androidx.transition:transition-ktx", version = "1.5.1" }
+androidx-nav-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version = "2.8.8" }
+androidx-nav-ui = { module = "androidx.navigation:navigation-ui-ktx", version = "2.8.8" }
+androidx-tracing = { module = "androidx.tracing:tracing", version = "1.2.0" }
+androidx-tracing-ktx = { module = "androidx.tracing:tracing-ktx", version = "1.2.0" }
+androidx-viewpager = { module = "androidx.viewpager:viewpager", version = "1.0.0" }
+androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version = "1.0.0" }
+#noinspection GradleDependency
+androidx-window-v1alpha9 = { module = "androidx.window:window", version = "1.0.0-alpha09" }
+androidx-work = { module = "androidx.work:work-runtime", version.ref = "androidx-work" }
+androidx-work-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }
+
+# Google
+google-material = { module = "com.google.android.material:material", version = "1.12.0" }
+google-gson = { module = "com.google.code.gson:gson", version = "2.12.1" }
+google-guava = { module = "com.google.guava:guava", version = "33.4.0-android" }
+google-auto-value-annotations = { module = "com.google.auto.value:auto-value-annotations", version = "1.11.0" }
+google-auto-value-ap = { module = "com.google.auto.value:auto-value", version = "1.11.0" }
+google-auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version = "1.1.1" }
+google-auto-service = { module = "com.google.auto.service:auto-service", version = "1.1.1" }
+google-protobuf-java = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobuf" }
+google-protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf" }
+google-protobuf-gradle = { module = "com.google.protobuf:protobuf-gradle-plugin", version.ref = "protobuf-plugin" }
+google-java-format = { module = "com.google.googlejavaformat:google-java-format", version = "1.20.0" }
+google-flexbox = { module = "com.google.android.flexbox:flexbox", version = "3.0.0" }
+
+# AAPT2
+aapt2-common = { module = "com.android.tools:common", version = "31.2.2" }
+aapt2-annotations = { module = "com.android.tools:annotations", version = "31.2.2" }
+aapt2-jb-annotations = { module = "org.jetbrains:annotations", version = "24.1.0" }
+
+logging-logback-core = { module = "ch.qos.logback:logback-core", version.ref = "logback" }
+logging-logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
+
+# XML
+xml-xercesImpl = { module = "xerces:xercesImpl", version = "2.12.2" }
+xml-apis = { module = "xml-apis:xml-apis", version = "2.0.2" }
+xml-remark = { module = "com.kotcrab.remark:remark", version = "1.2.0" }
+xml-resolver = { module = "xml-resolver:xml-resolver", version = "1.2" }
+xml-jb-annotations = { module = "org.jetbrains:annotations", version = "24.1.0" }
+
+# GIT
+git-jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version = "6.8.0.202311291450-r" }
+
+# Tests
+tests-junit = { module = "junit:junit", version = "4.13.2" }
+tests-junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" }
+tests-junit-platformLauncher = { module = "org.junit.platform:junit-platform-launcher" }
+core-tests-anroidx-arch = { module = "androidx.arch.core:core-testing", version.ref = "anroidx-test-core" }
+tests-google-truth = { module = "com.google.truth:truth", version = "1.4.1" }
+tests-robolectric = { module = "org.robolectric:robolectric", version = "4.11.1" }
+tests-androidx-test-core = { module = "androidx.test:core", version = "1.5.0" }
+tests-androidx-test-monitor = { module = "androidx.test:monitor", version = "1.8.0" }
+tests-androidx-test-runner = { module = "androidx.test:runner", version = "1.6.2" }
+tests-androidx-test-rules = { module = "androidx.test:rules", version = "1.5.0" }
+tests-androidx-junit = { module = "androidx.test.ext:junit", version = "1.1.5" }
+tests-androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "androidx-work" }
+tests-androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
+tests-androidx-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "androidx-espresso" }
+tests-androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version = "2.3.0" }
+tests-mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "5.2.1" }
+tests-mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
+tests-mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
+tests-barista = { module = "com.adevinta.android:barista", version = "4.3.0" }
+tests-kaspresso = { module = "com.kaspersky.android-components:kaspresso", version = "1.6.0" }
+tests-orchestrator = { module = "androidx.test:orchestrator", version = "1.5.1" }
+tests-junit-kts = { module = "androidx.test.ext:junit-ktx", version = "1.2.1" }
+tests-kotlinx-coroutines = {module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesCore"}
+
+# Tooling
+tooling-builderModel = { module = "com.android.tools.build:builder-model", version.ref = "agp-tooling" }
+tooling-gradleApi = { module = "com.itsaky.androidide.gradle:gradle-tooling-api", version.ref = "gradle-tooling" }
+tooling-slf4j = { module = "org.slf4j:slf4j-api", version = "2.0.12" }
+
+# Classpaths
+android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
+kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
+kotlin-serialization-plugin = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" }
+nav-safe-args-gradle-plugin = { module = "androidx.navigation:navigation-safe-args-gradle-plugin", version.ref = "androidx-navigation" }
+maven-publish = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "maven-publish-plugin" }
+androidx-monitor = { group = "androidx.test", name = "monitor", version.ref = "monitor" }
+monitor = { group = "androidx.test", name = "monitor", version.ref = "monitorVersion" }
+org-json = { module = "org.json:json", version = "20210307"}
+
+pebble = { module = "io.pebbletemplates:pebble", version.ref = "pebble" }
+kotlinx-metadata = { module = "org.jetbrains.kotlin:kotlin-metadata-jvm", version.ref = "kotlin" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+android-library = { id = "com.android.library", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
+maven-publish = { id = "com.vanniktech.maven.publish.base", version.ref = "maven-publish-plugin" }
+gradle-publish = { id = "com.gradle.plugin-publish", version = "1.2.1" }
+spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
+sentry = { id = "io.sentry.android.gradle", version.ref = "sentry-gradle-plugin" }
+google-services = { id = "com.google.gms.google-services", version.ref = "google-services" }
+google-protobuf = { id = "com.google.protobuf", version.ref = "protobuf-plugin" }
+rikka-autoresconfig = { id = "dev.rikka.tools.autoresconfig", version = "1.2.2" }
+rikka-materialthemebuilder = { id = "dev.rikka.tools.materialthemebuilder", version = "1.5.1" }
+rikka-refine = { id = "dev.rikka.tools.refine", version.ref = "rikka-refine" }
+
+sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" }
diff --git a/ai-core/gradle/wrapper/gradle-wrapper.jar b/ai-core/gradle/wrapper/gradle-wrapper.jar
new file mode 100755
index 00000000..d64cd491
Binary files /dev/null and b/ai-core/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/ai-core/gradle/wrapper/gradle-wrapper.properties b/ai-core/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..692c2dc2
--- /dev/null
+++ b/ai-core/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-all.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
\ No newline at end of file
diff --git a/ai-core/gradlew b/ai-core/gradlew
new file mode 100755
index 00000000..1aa94a42
--- /dev/null
+++ b/ai-core/gradlew
@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/ai-core/gradlew.bat b/ai-core/gradlew.bat
new file mode 100755
index 00000000..93e3f59f
--- /dev/null
+++ b/ai-core/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/ai-core/libs/llama-api.jar b/ai-core/libs/llama-api.jar
new file mode 100644
index 00000000..3274247d
Binary files /dev/null and b/ai-core/libs/llama-api.jar differ
diff --git a/ai-core/libs/v8/llama-v8-release.aar b/ai-core/libs/v8/llama-v8-release.aar
new file mode 100644
index 00000000..74b8063e
Binary files /dev/null and b/ai-core/libs/v8/llama-v8-release.aar differ
diff --git a/ai-core/llama-api/.gitignore b/ai-core/llama-api/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/ai-core/llama-api/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/ai-core/llama-api/build.gradle.kts b/ai-core/llama-api/build.gradle.kts
new file mode 100644
index 00000000..50e208a3
--- /dev/null
+++ b/ai-core/llama-api/build.gradle.kts
@@ -0,0 +1,16 @@
+plugins {
+ id("java-library")
+ id("org.jetbrains.kotlin.jvm")
+}
+java {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+}
+kotlin {
+ compilerOptions {
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
+ }
+}
+dependencies {
+ implementation(libs.kotlinx.coroutines.core)
+}
diff --git a/ai-core/llama-api/src/main/java/com/itsaky/androidide/llamacpp/api/ILlamaController.kt b/ai-core/llama-api/src/main/java/com/itsaky/androidide/llamacpp/api/ILlamaController.kt
new file mode 100644
index 00000000..9906520c
--- /dev/null
+++ b/ai-core/llama-api/src/main/java/com/itsaky/androidide/llamacpp/api/ILlamaController.kt
@@ -0,0 +1,23 @@
+package com.itsaky.androidide.llamacpp.api
+
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * The public contract for the Llama C++ implementation.
+ * This interface is shared between the main app and the implementation module.
+ */
+interface ILlamaController {
+ suspend fun load(pathToModel: String)
+ fun send(
+ message: String,
+ formatChat: Boolean = false,
+ stop: List = emptyList(),
+ clearCache: Boolean = false
+ ): Flow
+
+ suspend fun countTokens(text: String): Int
+
+ suspend fun unload()
+ fun stop()
+ suspend fun clearKvCache()
+}
diff --git a/ai-core/llama-impl/.gitignore b/ai-core/llama-impl/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/ai-core/llama-impl/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/ai-core/llama-impl/build.gradle.kts b/ai-core/llama-impl/build.gradle.kts
new file mode 100644
index 00000000..b940f4ff
--- /dev/null
+++ b/ai-core/llama-impl/build.gradle.kts
@@ -0,0 +1,71 @@
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
+ }
+}
+
+android {
+ namespace = "android.llama.cpp"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 33
+ consumerProguardFiles("proguard-rules.pro")
+ ndk {
+ // Add NDK properties if wanted, e.g.
+ // abiFilters += listOf("arm64-v8a")
+ }
+ externalNativeBuild {
+ cmake {
+ arguments += "-DLLAMA_CURL=OFF"
+ arguments += "-DLLAMA_BUILD_COMMON=ON"
+ arguments += "-DGGML_LLAMAFILE=OFF"
+ arguments += "-DCMAKE_BUILD_TYPE=Release"
+ cppFlags += listOf()
+ arguments += listOf()
+
+ cppFlags("")
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ externalNativeBuild {
+ cmake {
+ path("src/main/cpp/CMakeLists.txt")
+ version = "3.22.1"
+ }
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ implementation(project(":llama-api"))
+ implementation(libs.androidx.core.ktx.v1120)
+ implementation(libs.androidx.appcompat.v171)
+ implementation(libs.tooling.slf4j)
+}
diff --git a/ai-core/llama-impl/proguard-rules.pro b/ai-core/llama-impl/proguard-rules.pro
new file mode 100644
index 00000000..0bd1a5b4
--- /dev/null
+++ b/ai-core/llama-impl/proguard-rules.pro
@@ -0,0 +1,8 @@
+# Keep the LLamaAndroid class and its public members from being removed or renamed.
+-keep class android.llama.cpp.LLamaAndroid { *; }
+
+# More specifically, ensure the static 'instance()' method is kept.
+# This rule is more precise and often preferred.
+-keep class android.llama.cpp.LLamaAndroid {
+ public static *** instance();
+}
\ No newline at end of file
diff --git a/ai-core/llama-impl/src/main/cpp/CMakeLists.txt b/ai-core/llama-impl/src/main/cpp/CMakeLists.txt
new file mode 100644
index 00000000..b07ebd4b
--- /dev/null
+++ b/ai-core/llama-impl/src/main/cpp/CMakeLists.txt
@@ -0,0 +1,53 @@
+# For more information about using CMake with Android Studio, read the
+# documentation: https://d.android.com/studio/projects/add-native-code.html.
+# For more examples on how to use CMake, see https://github.com/android/ndk-samples.
+
+# Sets the minimum CMake version required for this project.
+cmake_minimum_required(VERSION 3.22.1)
+
+# Declares the project name. The project name can be accessed via ${ PROJECT_NAME},
+# Since this is the top level CMakeLists.txt, the project name is also accessible
+# with ${CMAKE_PROJECT_NAME} (both CMake variables are in-sync within the top level
+# build script scope).
+project("llama-android")
+
+#include(FetchContent)
+#FetchContent_Declare(
+# llama
+# GIT_REPOSITORY https://github.com/ggml-org/llama.cpp
+# GIT_TAG master
+#)
+
+# Also provides "common"
+#FetchContent_MakeAvailable(llama)
+
+# Creates and names a library, sets it as either STATIC
+# or SHARED, and provides the relative paths to its source code.
+# You can define multiple libraries, and CMake builds them for you.
+# Gradle automatically packages shared libraries with your APK.
+#
+# In this top level CMakeLists.txt, ${CMAKE_PROJECT_NAME} is used to define
+# the target library name; in the sub-module's CMakeLists.txt, ${PROJECT_NAME}
+# is preferred for the same purpose.
+#
+
+#load local llama.cpp
+add_subdirectory(../../../../subprojects/llama.cpp/ build-llama)
+
+# In order to load a library into your app from Java/Kotlin, you must call
+# System.loadLibrary() and pass the name of the library defined here;
+# for GameActivity/NativeActivity derived applications, the same library name must be
+# used in the AndroidManifest.xml file.
+add_library(${CMAKE_PROJECT_NAME} SHARED
+ # List C/C++ source files with relative paths to this CMakeLists.txt.
+ llama-android.cpp)
+
+# Specifies libraries CMake should link to your target library. You
+# can link libraries from various origins, such as libraries defined in this
+# build script, prebuilt third-party libraries, or Android system libraries.
+target_link_libraries(${CMAKE_PROJECT_NAME}
+ # List libraries link to the target library
+ llama
+ common
+ android
+ log)
diff --git a/ai-core/llama-impl/src/main/cpp/llama-android.cpp b/ai-core/llama-impl/src/main/cpp/llama-android.cpp
new file mode 100644
index 00000000..91cff6e9
--- /dev/null
+++ b/ai-core/llama-impl/src/main/cpp/llama-android.cpp
@@ -0,0 +1,882 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "llama.h"
+#include
+#include
+#include "common.h"
+
+#define TAG "llama-android.cpp"
+#define LOGi(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
+#define LOGe(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
+
+jclass la_int_var;
+jmethodID la_int_var_value;
+jmethodID la_int_var_inc;
+
+std::string cached_token_chars;
+static std::unordered_map g_batch_n_tokens;
+static std::vector g_stop_strings;
+static std::string g_generated_text;
+static std::atomic g_stop_requested(false);
+static std::mutex g_globals_mutex;
+
+bool is_valid_utf8(const char *string) {
+ if (!string) {
+ return true;
+ }
+
+ const unsigned char *bytes = (const unsigned char *) string;
+ int num;
+
+ while (*bytes != 0x00) {
+ if ((*bytes & 0x80) == 0x00) {
+ // U+0000 to U+007F
+ num = 1;
+ } else if ((*bytes & 0xE0) == 0xC0) {
+ // U+0080 to U+07FF
+ num = 2;
+ } else if ((*bytes & 0xF0) == 0xE0) {
+ // U+0800 to U+FFFF
+ num = 3;
+ } else if ((*bytes & 0xF8) == 0xF0) {
+ // U+10000 to U+10FFFF
+ num = 4;
+ } else {
+ return false;
+ }
+
+ bytes += 1;
+ for (int i = 1; i < num; ++i) {
+ if ((*bytes & 0xC0) != 0x80) {
+ return false;
+ }
+ bytes += 1;
+ }
+ }
+
+ return true;
+}
+
+static JavaVM *g_jvm = nullptr;
+static jclass g_llama_android_class = nullptr;
+static jmethodID g_log_from_native_method = nullptr;
+static std::atomic g_n_threads(-1);
+static std::atomic g_n_threads_batch(-1);
+static std::atomic g_temperature(0.7f);
+static std::atomic g_top_p(0.9f);
+static std::atomic g_top_k(40);
+static std::atomic g_n_ctx(4096);
+static std::atomic g_kv_cache_reuse(true);
+static std::vector g_cached_tokens;
+
+static jstring new_jstring_utf8(JNIEnv *env, const char *text) {
+ if (!text) {
+ return env->NewStringUTF("");
+ }
+
+ try {
+ std::wstring_convert, char16_t> converter;
+ std::u16string u16 = converter.from_bytes(text);
+ return env->NewString(reinterpret_cast(u16.data()),
+ static_cast(u16.size()));
+ } catch (const std::range_error &) {
+ std::string sanitized;
+ sanitized.reserve(strlen(text));
+ for (const unsigned char ch: std::string(text)) {
+ sanitized.push_back(ch < 0x80 ? static_cast(ch) : '?');
+ }
+ return env->NewStringUTF(sanitized.c_str());
+ }
+}
+
+extern "C"
+JNIEXPORT void JNICALL
+Java_android_llama_cpp_LLamaAndroid_native_1configureThreads(JNIEnv *, jclass, jint n_threads,
+ jint n_threads_batch) {
+ g_n_threads.store(n_threads);
+ g_n_threads_batch.store(n_threads_batch);
+}
+
+extern "C"
+JNIEXPORT void JNICALL
+Java_android_llama_cpp_LLamaAndroid_native_1configureSampling(JNIEnv *, jclass, jfloat temperature,
+ jfloat top_p, jint top_k) {
+ float validated_temperature = temperature;
+ if (validated_temperature < 0.0f) {
+ validated_temperature = 0.0f;
+ } else if (validated_temperature > 5.0f) {
+ validated_temperature = 5.0f;
+ }
+
+ float validated_top_p = top_p;
+ if (validated_top_p < 0.0f) {
+ validated_top_p = 0.0f;
+ } else if (validated_top_p > 1.0f) {
+ validated_top_p = 1.0f;
+ }
+
+ int validated_top_k = top_k < 0 ? 0 : top_k;
+
+ g_temperature.store(validated_temperature);
+ g_top_p.store(validated_top_p);
+ g_top_k.store(validated_top_k);
+}
+
+extern "C"
+JNIEXPORT void JNICALL
+Java_android_llama_cpp_LLamaAndroid_native_1configureContext(JNIEnv *, jclass, jint n_ctx) {
+ if (n_ctx <= 0) {
+ return;
+ }
+ g_n_ctx.store(n_ctx);
+}
+
+extern "C"
+JNIEXPORT void JNICALL
+Java_android_llama_cpp_LLamaAndroid_native_1configureKvCacheReuse(JNIEnv *, jclass, jboolean enabled) {
+ g_kv_cache_reuse.store(enabled == JNI_TRUE);
+}
+
+template
+static auto attach_current_thread_impl(JVM *jvm, JNIEnv **env, int)
+-> decltype(jvm->AttachCurrentThread(env, nullptr), jint{}) {
+ // Header has the JNIEnv** signature
+ return jvm->AttachCurrentThread(env, nullptr);
+}
+
+template
+static jint attach_current_thread_impl(JVM *jvm, JNIEnv **env, long) {
+ // Fallback for headers that want void** (e.g., Flox)
+ void *venv = nullptr;
+ jint r = jvm->AttachCurrentThread(&venv, nullptr);
+ *env = reinterpret_cast(venv);
+ return r;
+}
+
+static inline jint attach_current_thread(JavaVM *jvm, JNIEnv **env) {
+ return attach_current_thread_impl(jvm, env, 0);
+}
+
+void log_to_kotlin_bridge(ggml_log_level level, const char *message) {
+ if (!g_jvm || !g_llama_android_class || !g_log_from_native_method) {
+ __android_log_print(ANDROID_LOG_DEBUG, "llama.cpp", "%s", message);
+ return;
+ }
+
+ JNIEnv *env = nullptr;
+ bool did_attach_thread = false;
+
+ jint get_env_result = g_jvm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6);
+ if (get_env_result == JNI_EDETACHED) {
+ if (attach_current_thread(g_jvm, &env) != JNI_OK || !env) {
+ __android_log_print(ANDROID_LOG_ERROR, TAG, "AttachCurrentThread failed");
+ return;
+ }
+ did_attach_thread = true;
+ } else if (get_env_result != JNI_OK) {
+ __android_log_print(ANDROID_LOG_ERROR, TAG, "GetEnv failed");
+ return;
+ }
+
+ jstring jni_message = new_jstring_utf8(env, message);
+ if (jni_message == nullptr) {
+ // Handle potential out-of-memory error
+ if (did_attach_thread) {
+ g_jvm->DetachCurrentThread();
+ }
+ return;
+ }
+
+ env->CallStaticVoidMethod(g_llama_android_class, g_log_from_native_method, (jint) level,
+ jni_message);
+ env->DeleteLocalRef(jni_message);
+
+ // ✨ THE FIX: Only detach the thread if we were the ones who attached it.
+ // In the case of the Llm-RunLoop, we will NOT detach it.
+ if (did_attach_thread) {
+ g_jvm->DetachCurrentThread();
+ }
+}
+
+void log_info_to_kt(const char *fmt, ...) {
+ char buffer[1024];
+ va_list args;
+ va_start(args, fmt);
+ vsnprintf(buffer, sizeof(buffer), fmt, args);
+ va_end(args);
+
+ log_to_kotlin_bridge((ggml_log_level) 4, buffer);
+}
+
+static void slf4j_log_callback(ggml_log_level level, const char *fmt, void *data) {
+ log_to_kotlin_bridge(level, fmt);
+}
+
+JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
+ g_jvm = vm;
+ JNIEnv *env;
+ if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) {
+ return -1;
+ }
+
+ jclass local_class = env->FindClass("android/llama/cpp/LLamaAndroid");
+ if (!local_class) return -1;
+ g_llama_android_class = (jclass) env->NewGlobalRef(local_class);
+
+ g_log_from_native_method = env->GetStaticMethodID(g_llama_android_class, "logFromNative",
+ "(ILjava/lang/String;)V");
+ if (!g_log_from_native_method) return -1;
+
+ return JNI_VERSION_1_6;
+}
+
+extern "C"
+JNIEXPORT jlong JNICALL
+Java_android_llama_cpp_LLamaAndroid_load_1model(JNIEnv *env, jobject, jstring filename) {
+ llama_model_params model_params = llama_model_default_params();
+
+ auto path_to_model = env->GetStringUTFChars(filename, 0);
+ LOGi("Loading model from %s", path_to_model);
+
+ auto model = llama_model_load_from_file(path_to_model, model_params);
+ env->ReleaseStringUTFChars(filename, path_to_model);
+
+ if (!model) {
+ LOGe("load_model() failed");
+ env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), "load_model() failed");
+ return 0;
+ }
+
+ return reinterpret_cast(model);
+}
+
+extern "C"
+JNIEXPORT void JNICALL
+Java_android_llama_cpp_LLamaAndroid_free_1model(JNIEnv *, jobject, jlong model) {
+ llama_model_free(reinterpret_cast(model));
+}
+
+extern "C"
+JNIEXPORT jlong JNICALL
+Java_android_llama_cpp_LLamaAndroid_new_1context(JNIEnv *env, jobject, jlong jmodel) {
+ auto model = reinterpret_cast(jmodel);
+
+ if (!model) {
+ LOGe("new_context(): model cannot be null");
+ env->ThrowNew(env->FindClass("java/lang/IllegalArgumentException"), "Model cannot be null");
+ return 0;
+ }
+
+ int default_threads = std::max(1, std::min(8, (int) sysconf(_SC_NPROCESSORS_ONLN) - 2));
+ int n_threads = g_n_threads.load();
+ if (n_threads <= 0) {
+ n_threads = default_threads;
+ }
+ int n_threads_batch = g_n_threads_batch.load();
+ if (n_threads_batch <= 0) {
+ n_threads_batch = n_threads;
+ }
+ LOGi("Using %d threads (batch=%d)", n_threads, n_threads_batch);
+
+ llama_context_params ctx_params = llama_context_default_params();
+
+ const int configured_ctx = g_n_ctx.load();
+ ctx_params.n_ctx = configured_ctx > 0 ? configured_ctx : 4096;
+ ctx_params.n_threads = n_threads;
+ ctx_params.n_threads_batch = n_threads_batch;
+
+ llama_context *context = llama_init_from_model(model, ctx_params);
+
+ if (!context) {
+ LOGe("llama_new_context_with_model() returned null)");
+ env->ThrowNew(env->FindClass("java/lang/IllegalStateException"),
+ "llama_new_context_with_model() returned null)");
+ return 0;
+ }
+
+ return reinterpret_cast(context);
+}
+
+extern "C"
+JNIEXPORT void JNICALL
+Java_android_llama_cpp_LLamaAndroid_free_1context(JNIEnv *, jobject, jlong context) {
+ llama_free(reinterpret_cast(context));
+}
+
+extern "C"
+JNIEXPORT void JNICALL
+Java_android_llama_cpp_LLamaAndroid_backend_1free(JNIEnv *, jobject) {
+ llama_backend_free();
+}
+
+extern "C"
+JNIEXPORT void JNICALL
+Java_android_llama_cpp_LLamaAndroid_log_1to_1android(JNIEnv *, jobject) {
+ llama_log_set(slf4j_log_callback, NULL);
+}
+
+extern "C"
+JNIEXPORT jstring JNICALL
+Java_android_llama_cpp_LLamaAndroid_bench_1model(
+ JNIEnv *env,
+ jobject,
+ jlong context_pointer,
+ jlong model_pointer,
+ jlong batch_pointer,
+ jint pp,
+ jint tg,
+ jint pl,
+ jint nr
+) {
+ auto pp_avg = 0.0;
+ auto tg_avg = 0.0;
+ auto pp_std = 0.0;
+ auto tg_std = 0.0;
+
+ const auto context = reinterpret_cast(context_pointer);
+ const auto model = reinterpret_cast(model_pointer);
+ const auto batch = reinterpret_cast(batch_pointer);
+
+ const int n_ctx = llama_n_ctx(context);
+
+ LOGi("n_ctx = %d", n_ctx);
+
+ int i, j;
+ int nri;
+ for (nri = 0; nri < nr; nri++) {
+ LOGi("Benchmark prompt processing (pp)");
+
+ common_batch_clear(*batch);
+
+ const int n_tokens = pp;
+ for (i = 0; i < n_tokens; i++) {
+ common_batch_add(*batch, 0, i, {0}, false);
+ }
+
+ batch->logits[batch->n_tokens - 1] = true;
+ llama_memory_clear(llama_get_memory(context), false);
+
+ const auto t_pp_start = ggml_time_us();
+ if (llama_decode(context, *batch) != 0) {
+ LOGi("llama_decode() failed during prompt processing");
+ }
+ const auto t_pp_end = ggml_time_us();
+
+ // bench text generation
+
+ LOGi("Benchmark text generation (tg)");
+
+ llama_memory_clear(llama_get_memory(context), false);
+ const auto t_tg_start = ggml_time_us();
+ for (i = 0; i < tg; i++) {
+
+ common_batch_clear(*batch);
+ for (j = 0; j < pl; j++) {
+ common_batch_add(*batch, 0, i, {j}, true);
+ }
+
+ LOGi("llama_decode() text generation: %d", i);
+ if (llama_decode(context, *batch) != 0) {
+ LOGi("llama_decode() failed during text generation");
+ }
+ }
+
+ const auto t_tg_end = ggml_time_us();
+
+ llama_memory_clear(llama_get_memory(context), false);
+
+ const auto t_pp = double(t_pp_end - t_pp_start) / 1000000.0;
+ const auto t_tg = double(t_tg_end - t_tg_start) / 1000000.0;
+
+ const auto speed_pp = double(pp) / t_pp;
+ const auto speed_tg = double(pl * tg) / t_tg;
+
+ pp_avg += speed_pp;
+ tg_avg += speed_tg;
+
+ pp_std += speed_pp * speed_pp;
+ tg_std += speed_tg * speed_tg;
+
+ LOGi("pp %f t/s, tg %f t/s", speed_pp, speed_tg);
+ }
+
+ pp_avg /= double(nr);
+ tg_avg /= double(nr);
+
+ if (nr > 1) {
+ pp_std = sqrt(pp_std / double(nr - 1) - pp_avg * pp_avg * double(nr) / double(nr - 1));
+ tg_std = sqrt(tg_std / double(nr - 1) - tg_avg * tg_avg * double(nr) / double(nr - 1));
+ } else {
+ pp_std = 0;
+ tg_std = 0;
+ }
+
+ char model_desc[128];
+ llama_model_desc(model, model_desc, sizeof(model_desc));
+
+ const auto model_size = double(llama_model_size(model)) / 1024.0 / 1024.0 / 1024.0;
+ const auto model_n_params = double(llama_model_n_params(model)) / 1e9;
+
+ const auto backend = "(Android)"; // TODO: What should this be?
+
+ std::stringstream result;
+ result << std::setprecision(2);
+ result << "| model | size | params | backend | test | t/s |\n";
+ result << "| --- | --- | --- | --- | --- | --- |\n";
+ result << "| " << model_desc << " | " << model_size << "GiB | " << model_n_params << "B | "
+ << backend << " | pp " << pp << " | " << pp_avg << " ± " << pp_std << " |\n";
+ result << "| " << model_desc << " | " << model_size << "GiB | " << model_n_params << "B | "
+ << backend << " | tg " << tg << " | " << tg_avg << " ± " << tg_std << " |\n";
+
+ return new_jstring_utf8(env, result.str().c_str());
+}
+
+extern "C"
+JNIEXPORT jlong JNICALL
+Java_android_llama_cpp_LLamaAndroid_new_1batch(JNIEnv *, jobject, jint n_tokens, jint embd,
+ jint n_seq_max) {
+
+ // Source: Copy of llama.cpp:llama_batch_init but heap-allocated.
+
+ llama_batch *batch = new llama_batch{
+ 0,
+ nullptr,
+ nullptr,
+ nullptr,
+ nullptr,
+ nullptr,
+ nullptr,
+ };
+
+ if (embd) {
+ batch->embd = (float *) malloc(sizeof(float) * n_tokens * embd);
+ } else {
+ batch->token = (llama_token *) malloc(sizeof(llama_token) * n_tokens);
+ }
+
+ batch->pos = (llama_pos *) malloc(sizeof(llama_pos) * n_tokens);
+ batch->n_seq_id = (int32_t *) malloc(sizeof(int32_t) * n_tokens);
+ batch->seq_id = (llama_seq_id **) malloc(sizeof(llama_seq_id *) * n_tokens);
+ for (int i = 0; i < n_tokens; ++i) {
+ batch->seq_id[i] = (llama_seq_id *) malloc(sizeof(llama_seq_id) * n_seq_max);
+ }
+ batch->logits = (int8_t *) malloc(sizeof(int8_t) * n_tokens);
+
+ {
+ std::lock_guard lock(g_globals_mutex);
+ g_batch_n_tokens[batch] = n_tokens;
+ }
+ return reinterpret_cast(batch);
+}
+
+extern "C"
+JNIEXPORT void JNICALL
+Java_android_llama_cpp_LLamaAndroid_free_1batch(JNIEnv *, jobject, jlong batch_pointer) {
+ const auto batch = reinterpret_cast(batch_pointer);
+ if (!batch) return;
+
+ free(batch->token);
+ free(batch->embd);
+ free(batch->pos);
+ free(batch->n_seq_id);
+ if (batch->seq_id) {
+ std::lock_guard lock(g_globals_mutex);
+ auto it = g_batch_n_tokens.find(batch);
+ if (it != g_batch_n_tokens.end()) {
+ for (int i = 0; i < it->second; ++i) {
+ free(batch->seq_id[i]);
+ }
+ g_batch_n_tokens.erase(it);
+ }
+ }
+ free(batch->seq_id);
+ free(batch->logits);
+ delete batch;
+}
+
+extern "C"
+JNIEXPORT jlong JNICALL
+Java_android_llama_cpp_LLamaAndroid_new_1sampler(JNIEnv *, jobject) {
+ auto sparams = llama_sampler_chain_default_params();
+ sparams.no_perf = true;
+ llama_sampler *smpl = llama_sampler_chain_init(sparams);
+
+ // The last n tokens to consider for the repetition penalty.
+ // -1 means use the entire context size. 64 is a common default.
+ int32_t penalty_last_n = 64;
+
+ // The penalty value. 1.0 means no penalty. 1.1 is a good start.
+ float penalty_repeat = 1.1f;
+
+ // The following two penalties are disabled (set to 0.0) but are required
+ // by the function signature.
+ float penalty_freq = 0.0f;
+ float penalty_present = 0.0f;
+
+ // **THE FIX:** Add the penalties sampler to the chain.
+ llama_sampler_chain_add(smpl, llama_sampler_init_penalties(
+ penalty_last_n,
+ penalty_repeat,
+ penalty_freq,
+ penalty_present
+ ));
+
+ const float temperature = g_temperature.load();
+ const float top_p = g_top_p.load();
+ const int top_k = g_top_k.load();
+
+ if (temperature > 0.0f) {
+ if (top_k > 0) {
+ llama_sampler_chain_add(smpl, llama_sampler_init_top_k(top_k));
+ }
+ if (top_p > 0.0f && top_p < 1.0f) {
+ llama_sampler_chain_add(smpl, llama_sampler_init_top_p(top_p, 1));
+ }
+ llama_sampler_chain_add(smpl, llama_sampler_init_temp(temperature));
+ const auto seed = static_cast(
+ std::chrono::steady_clock::now().time_since_epoch().count()
+ );
+ llama_sampler_chain_add(smpl, llama_sampler_init_dist(seed));
+ } else {
+ // The chain must end with a sampler that actually selects a token.
+ // Greedy is the simplest (always picks the most likely token).
+ llama_sampler_chain_add(smpl, llama_sampler_init_greedy());
+ }
+
+ return reinterpret_cast(smpl);
+}
+
+extern "C"
+JNIEXPORT void JNICALL
+Java_android_llama_cpp_LLamaAndroid_free_1sampler(JNIEnv *, jobject, jlong sampler_pointer) {
+ llama_sampler_free(reinterpret_cast(sampler_pointer));
+}
+
+extern "C"
+JNIEXPORT void JNICALL
+Java_android_llama_cpp_LLamaAndroid_backend_1init(JNIEnv *, jobject, jboolean numa) {
+ llama_backend_init();
+}
+
+extern "C"
+JNIEXPORT jstring JNICALL
+Java_android_llama_cpp_LLamaAndroid_system_1info(JNIEnv *env, jobject) {
+ return new_jstring_utf8(env, llama_print_system_info());
+}
+
+static int g_prompt_tokens = 0;
+
+extern "C"
+JNIEXPORT jint JNICALL
+Java_android_llama_cpp_LLamaAndroid_completion_1init(
+ JNIEnv *env,
+ jobject,
+ jlong context_pointer,
+ jlong batch_pointer,
+ jstring jtext,
+ jboolean format_chat,
+ jint n_len, jobjectArray stop) {
+
+ {
+ std::lock_guard lock(g_globals_mutex);
+ cached_token_chars.clear();
+ g_generated_text.clear();
+ g_stop_requested.store(false);
+ // Parse stop strings from the Java array
+ g_stop_strings.clear();
+ }
+ if (stop != nullptr) {
+ int stop_count = env->GetArrayLength(stop);
+ for (int i = 0; i < stop_count; i++) {
+ auto jstr = (jstring) env->GetObjectArrayElement(stop, i);
+ if (jstr) {
+ const char *chars = env->GetStringUTFChars(jstr, nullptr);
+ if (chars) {
+ {
+ std::lock_guard lock(g_globals_mutex);
+ g_stop_strings.emplace_back(chars);
+ }
+ env->ReleaseStringUTFChars(jstr, chars);
+ }
+ env->DeleteLocalRef(jstr);
+ }
+ }
+ }
+
+ const auto text = env->GetStringUTFChars(jtext, 0);
+ const auto context = reinterpret_cast(context_pointer);
+ const auto batch = reinterpret_cast(batch_pointer);
+
+ bool parse_special = (format_chat == JNI_TRUE);
+ const auto tokens_list = common_tokenize(context, text, true, parse_special);
+
+ int n_ctx = llama_n_ctx(context);
+ size_t n_kv_req = tokens_list.size() + static_cast(n_len);
+ LOGi("n_len = %d, n_ctx = %d, n_kv_req = %zu", n_len, n_ctx, n_kv_req);
+
+ if (n_kv_req > n_ctx) {
+ LOGe("error: n_kv_req > n_ctx, the required KV cache size is not big enough");
+ env->ThrowNew(env->FindClass("java/lang/IllegalArgumentException"),
+ "Prompt is too long for the model's context size.");
+ return 0;
+ }
+
+ g_prompt_tokens = static_cast(tokens_list.size());
+
+ for (auto id: tokens_list) {
+ LOGi("token: `%s`-> %d ", common_token_to_piece(context, id).c_str(), id);
+ }
+
+ common_batch_clear(*batch);
+
+ bool reuse = false;
+ size_t reuse_prefix = 0;
+ {
+ std::lock_guard lock(g_globals_mutex);
+ if (g_kv_cache_reuse.load() && !g_cached_tokens.empty()) {
+ if (g_cached_tokens.size() <= tokens_list.size()) {
+ reuse = true;
+ for (size_t i = 0; i < g_cached_tokens.size(); i++) {
+ if (g_cached_tokens[i] != tokens_list[i]) {
+ reuse = false;
+ break;
+ }
+ }
+ if (reuse) {
+ reuse_prefix = g_cached_tokens.size();
+ }
+ }
+ }
+ }
+
+ if (!reuse) {
+ // Fully reset KV cache to avoid non-consecutive sequence positions.
+ llama_memory_clear(llama_get_memory(context), true);
+ {
+ std::lock_guard lock(g_globals_mutex);
+ if (!g_kv_cache_reuse.load()) {
+ g_cached_tokens.clear();
+ }
+ g_cached_tokens.assign(tokens_list.begin(), tokens_list.end());
+ }
+ // evaluate the initial prompt
+ for (auto i = 0; i < tokens_list.size(); i++) {
+ common_batch_add(*batch, tokens_list[i], i, {0}, false);
+ }
+ } else {
+ {
+ std::lock_guard lock(g_globals_mutex);
+ g_cached_tokens.assign(tokens_list.begin(), tokens_list.end());
+ }
+ if (reuse_prefix < tokens_list.size()) {
+ for (auto i = reuse_prefix; i < tokens_list.size(); i++) {
+ common_batch_add(*batch, tokens_list[i], i, {0}, false);
+ }
+ }
+ }
+
+ if (batch->n_tokens > 0) {
+ // llama_decode will output logits only for the last token of the prompt
+ batch->logits[batch->n_tokens - 1] = true;
+ if (llama_decode(context, *batch) != 0) {
+ LOGe("llama_decode() failed");
+ }
+ }
+
+ env->ReleaseStringUTFChars(jtext, text);
+
+ return g_prompt_tokens;
+}
+
+extern "C"
+JNIEXPORT jstring JNICALL
+Java_android_llama_cpp_LLamaAndroid_completion_1loop(
+ JNIEnv *env,
+ jobject,
+ jlong context_pointer,
+ jlong batch_pointer,
+ jlong sampler_pointer,
+ jint n_len,
+ jobject intvar_ncur
+) {
+ const auto context = reinterpret_cast(context_pointer);
+ const auto batch = reinterpret_cast(batch_pointer);
+ const auto sampler = reinterpret_cast(sampler_pointer);
+ const auto model = llama_get_model(context);
+ const auto vocab = llama_model_get_vocab(model);
+
+ if (!la_int_var) la_int_var = env->GetObjectClass(intvar_ncur);
+ if (!la_int_var_value) la_int_var_value = env->GetMethodID(la_int_var, "getValue", "()I");
+ if (!la_int_var_inc) la_int_var_inc = env->GetMethodID(la_int_var, "inc", "()V");
+
+ if (g_stop_requested.load()) {
+ return nullptr;
+ }
+
+ const auto new_token_id = llama_sampler_sample(sampler, context, -1);
+
+ const auto n_cur = env->CallIntMethod(intvar_ncur, la_int_var_value);
+ const auto generated = n_cur - g_prompt_tokens;
+ if (llama_vocab_is_eog(vocab, new_token_id) || generated >= n_len) {
+ return nullptr;
+ }
+
+ auto new_token_chars = common_token_to_piece(context, new_token_id);
+ {
+ std::lock_guard lock(g_globals_mutex);
+ cached_token_chars += new_token_chars;
+ }
+
+ jstring new_token = nullptr;
+ std::string cached_snapshot;
+ {
+ std::lock_guard lock(g_globals_mutex);
+ cached_snapshot = cached_token_chars;
+ }
+ if (is_valid_utf8(cached_snapshot.c_str())) {
+ size_t prior_len = 0;
+ {
+ std::lock_guard lock(g_globals_mutex);
+ prior_len = g_generated_text.size();
+ g_generated_text += cached_token_chars;
+ }
+
+ // Check if any stop string has been generated
+ std::vector stop_strings_snapshot;
+ {
+ std::lock_guard lock(g_globals_mutex);
+ stop_strings_snapshot = g_stop_strings;
+ }
+ std::string generated_snapshot;
+ {
+ std::lock_guard lock(g_globals_mutex);
+ generated_snapshot = g_generated_text;
+ }
+ for (const auto &stop_str : stop_strings_snapshot) {
+ if (!stop_str.empty() && generated_snapshot.length() >= stop_str.length()) {
+ auto pos = generated_snapshot.find(stop_str);
+ if (pos != std::string::npos) {
+ LOGi("Stop string matched: %s", stop_str.c_str());
+ size_t prefix_len = pos > prior_len ? pos - prior_len : 0;
+ if (prefix_len > 0) {
+ std::string prefix;
+ {
+ std::lock_guard lock(g_globals_mutex);
+ cached_token_chars = cached_token_chars.substr(0, prefix_len);
+ prefix = cached_token_chars;
+ }
+ new_token = new_jstring_utf8(env, prefix.c_str());
+ } else {
+ {
+ std::lock_guard lock(g_globals_mutex);
+ cached_token_chars.clear();
+ }
+ new_token = new_jstring_utf8(env, "");
+ }
+ {
+ std::lock_guard lock(g_globals_mutex);
+ g_generated_text = g_generated_text.substr(0, pos);
+ cached_token_chars.clear();
+ }
+ g_stop_requested.store(true);
+ return new_token;
+ }
+ }
+ }
+
+ {
+ std::lock_guard lock(g_globals_mutex);
+ new_token = new_jstring_utf8(env, cached_token_chars.c_str());
+ }
+
+ {
+ std::lock_guard lock(g_globals_mutex);
+ log_info_to_kt("cached: %s, new_token_chars: `%s`, id: %d", cached_token_chars.c_str(),
+ new_token_chars.c_str(), new_token_id);
+ }
+
+ {
+ std::lock_guard lock(g_globals_mutex);
+ cached_token_chars.clear();
+ }
+ } else {
+ new_token = new_jstring_utf8(env, "");
+ }
+
+ common_batch_clear(*batch);
+ common_batch_add(*batch, new_token_id, n_cur, {0}, true);
+
+ env->CallVoidMethod(intvar_ncur, la_int_var_inc);
+
+ if (llama_decode(context, *batch) != 0) {
+ LOGe("llama_decode() returned null");
+ return nullptr;
+ }
+
+ if (g_kv_cache_reuse.load()) {
+ std::lock_guard lock(g_globals_mutex);
+ g_cached_tokens.push_back(new_token_id);
+ }
+
+ return new_token;
+}
+
+extern "C"
+JNIEXPORT void JNICALL
+Java_android_llama_cpp_LLamaAndroid_kv_1cache_1clear(JNIEnv *, jobject, jlong context) {
+ llama_memory_clear(llama_get_memory(reinterpret_cast(context)), true);
+ std::lock_guard lock(g_globals_mutex);
+ g_cached_tokens.clear();
+}
+
+
+extern "C"
+JNIEXPORT jint JNICALL
+Java_android_llama_cpp_LLamaAndroid_model_1n_1ctx(
+ JNIEnv *env,
+ jobject /* this */,
+ jlong context_ptr) {
+ auto *context = reinterpret_cast(context_ptr);
+ if (!context) {
+ return 0;
+ }
+ return llama_n_ctx(context);
+}
+
+extern "C" JNIEXPORT jintArray JNICALL
+Java_android_llama_cpp_LLamaAndroid_tokenize(
+ JNIEnv *env,
+ jobject /* this */,
+ jlong context_ptr,
+ jstring text_to_tokenize,
+ jboolean add_bos) {
+ auto *context = reinterpret_cast(context_ptr);
+ if (!context) {
+ return env->NewIntArray(0); // Return empty array if context is invalid
+ }
+
+ const char *text_chars = env->GetStringUTFChars(text_to_tokenize, nullptr);
+ std::string text(text_chars);
+ env->ReleaseStringUTFChars(text_to_tokenize, text_chars);
+
+ bool parse_special = false;
+ const std::vector tokens_list = common_tokenize(context, text, add_bos,
+ parse_special);
+
+ jintArray result = env->NewIntArray(tokens_list.size());
+
+ if (!tokens_list.empty()) {
+ env->SetIntArrayRegion(result, 0, tokens_list.size(),
+ reinterpret_cast(tokens_list.data()));
+ }
+
+ return result;
+}
diff --git a/ai-core/llama-impl/src/main/java/android/llama/cpp/LLamaAndroid.kt b/ai-core/llama-impl/src/main/java/android/llama/cpp/LLamaAndroid.kt
new file mode 100644
index 00000000..0ab34f60
--- /dev/null
+++ b/ai-core/llama-impl/src/main/java/android/llama/cpp/LLamaAndroid.kt
@@ -0,0 +1,358 @@
+package android.llama.cpp
+
+import com.itsaky.androidide.llamacpp.api.ILlamaController
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.withContext
+import org.slf4j.LoggerFactory
+import java.util.concurrent.Executors
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.concurrent.thread
+
+/**
+ * Static library loader - ensures native library is loaded before any static methods are called.
+ * This object's init block runs when the object is first accessed.
+ */
+private object NativeLibraryLoader {
+ private val log = LoggerFactory.getLogger("llama.cpp.loader")
+
+ @Volatile
+ private var loaded = false
+
+ init {
+ ensureLoaded()
+ }
+
+ @Synchronized
+ fun ensureLoaded() {
+ if (!loaded) {
+ try {
+ System.loadLibrary("llama-android")
+ loaded = true
+ log.info("Successfully loaded llama-android native library")
+ } catch (e: UnsatisfiedLinkError) {
+ // Check if the error is because library is already loaded by another ClassLoader
+ if (e.message?.contains("already opened") == true) {
+ log.warn("Native library already loaded by another ClassLoader - continuing anyway")
+ loaded = true // Mark as loaded to prevent retries
+ } else {
+ log.error("Failed to load llama-android native library", e)
+ throw e
+ }
+ }
+ }
+ }
+}
+
+class LLamaAndroid : ILlamaController {
+
+ private val log = LoggerFactory.getLogger(LLamaAndroid::class.java)
+
+ init {
+ // Ensure native library is loaded when any instance is created
+ NativeLibraryLoader.ensureLoaded()
+ }
+
+ private external fun model_n_ctx(context: Long): Int
+
+ private external fun tokenize(context: Long, text: String, add_bos: Boolean): IntArray
+ suspend fun getContextSize(): Int {
+ return withContext(runLoop) {
+ when (val state = threadLocalState.get()) {
+ is State.Loaded -> model_n_ctx(state.context)
+ else -> throw IllegalStateException("Model not loaded")
+ }
+ }
+ }
+
+ override suspend fun clearKvCache() {
+ withContext(runLoop) {
+ when (val state = threadLocalState.get()) {
+ is State.Loaded -> kv_cache_clear(state.context)
+ else -> {}
+ }
+ }
+ }
+
+ override suspend fun countTokens(text: String): Int {
+ return tokenize(text).size
+ }
+
+ suspend fun tokenize(text: String): IntArray {
+ return withContext(runLoop) {
+ when (val state = threadLocalState.get()) {
+ is State.Loaded -> tokenize(state.context, text, true)
+ else -> throw IllegalStateException("Model not loaded")
+ }
+ }
+ }
+
+ private val threadLocalState: ThreadLocal = ThreadLocal.withInitial { State.Idle }
+
+ private val isStopped = AtomicBoolean(false)
+
+ private val runLoop: CoroutineDispatcher = Executors.newSingleThreadExecutor {
+ thread(start = false, name = "Llm-RunLoop") {
+ log.debug("Dedicated thread for native code: {}", Thread.currentThread().name)
+
+ // Library is now loaded in static initializer, so we can call native methods directly
+ log_to_android()
+ backend_init(false)
+
+ log.debug("System Info: {}", system_info())
+
+ it.run()
+ }.apply {
+ uncaughtExceptionHandler = Thread.UncaughtExceptionHandler { _, exception: Throwable ->
+ log.error("Unhandled exception on Llm-RunLoop thread", exception)
+ }
+ }
+ }.asCoroutineDispatcher()
+
+ private var nlen: Int = 256
+
+ private fun updateMaxTokens(maxTokens: Int) {
+ val clamped = maxTokens.coerceIn(64, 1024)
+ nlen = clamped
+ }
+
+ private external fun log_to_android()
+ private external fun load_model(filename: String): Long
+ private external fun free_model(model: Long)
+ private external fun new_context(model: Long): Long
+ private external fun free_context(context: Long)
+ private external fun backend_init(numa: Boolean)
+ private external fun backend_free()
+ private external fun new_batch(nTokens: Int, embd: Int, nSeqMax: Int): Long
+ private external fun free_batch(batch: Long)
+ private external fun new_sampler(): Long
+ private external fun free_sampler(sampler: Long)
+ private external fun bench_model(
+ context: Long,
+ model: Long,
+ batch: Long,
+ pp: Int,
+ tg: Int,
+ pl: Int,
+ nr: Int
+ ): String
+
+ private external fun system_info(): String
+
+ private external fun completion_init(
+ context: Long,
+ batch: Long,
+ text: String,
+ formatChat: Boolean,
+ nLen: Int,
+ stop: Array
+ ): Int
+
+ private external fun completion_loop(
+ context: Long,
+ batch: Long,
+ sampler: Long,
+ nLen: Int,
+ ncur: IntVar
+ ): String?
+
+ private external fun kv_cache_clear(context: Long)
+
+ override fun stop() {
+ log.info("Stop requested for current generation.")
+ isStopped.set(true)
+ }
+
+ suspend fun bench(pp: Int, tg: Int, pl: Int, nr: Int = 1): String {
+ return withContext(runLoop) {
+ when (val state = threadLocalState.get()) {
+ is State.Loaded -> {
+ log.debug("bench(): {}", state)
+ bench_model(state.context, state.model, state.batch, pp, tg, pl, nr)
+ }
+
+ else -> throw IllegalStateException("No model loaded")
+ }
+ }
+ }
+
+ override suspend fun load(pathToModel: String) {
+ withContext(runLoop) {
+ when (threadLocalState.get()) {
+ is State.Idle -> {
+ val model = load_model(pathToModel)
+ if (model == 0L) throw IllegalStateException("load_model() failed")
+
+ val context = new_context(model)
+ if (context == 0L) throw IllegalStateException("new_context() failed")
+
+ val batch = new_batch(2048, 0, 1)
+ if (batch == 0L) throw IllegalStateException("new_batch() failed")
+
+ val sampler = new_sampler()
+ if (sampler == 0L) throw IllegalStateException("new_sampler() failed")
+
+ log.info("Loaded model {}", pathToModel)
+ threadLocalState.set(State.Loaded(model, context, batch, sampler))
+ }
+
+ else -> throw IllegalStateException("Model already loaded")
+ }
+ }
+ }
+
+
+ /*
+
+ formatChat: Boolean = false,
+ stop: List = emptyList(),
+ clearCache: Boolean = false
+ */
+ override fun send(
+ message: String,
+ formatChat: Boolean,
+ stop: List,
+ clearCache: Boolean
+ ): Flow = flow {
+ when (val state = threadLocalState.get()) {
+ is State.Loaded -> {
+ isStopped.set(false)
+
+ if (clearCache) {
+ kv_cache_clear(state.context)
+ }
+
+ val ncur = IntVar(
+ completion_init(
+ state.context,
+ state.batch,
+ message,
+ formatChat,
+ nlen,
+ stop.toTypedArray()
+ )
+ )
+
+ while (true) {
+ if (isStopped.get()) {
+ log.info("Stopping generation loop because stop flag was set.")
+ break
+ }
+
+ val str = completion_loop(state.context, state.batch, state.sampler, nlen, ncur)
+ if (str == null) {
+ break
+ }
+ emit(str)
+ }
+ }
+
+ else -> {}
+ }
+ }.flowOn(runLoop)
+
+ override suspend fun unload() {
+ withContext(runLoop) {
+ when (val state = threadLocalState.get()) {
+ is State.Loaded -> {
+ free_context(state.context)
+ free_model(state.model)
+ free_batch(state.batch)
+ free_sampler(state.sampler)
+
+ threadLocalState.set(State.Idle)
+ }
+
+ else -> {}
+ }
+ }
+ }
+
+ companion object {
+ private val nativeLog = LoggerFactory.getLogger("llama.cpp")
+
+ // External native methods
+ @JvmStatic
+ private external fun native_configureThreads(nThreads: Int, nThreadsBatch: Int)
+
+ @JvmStatic
+ private external fun native_configureSampling(temperature: Float, topP: Float, topK: Int)
+
+ @JvmStatic
+ private external fun native_configureContext(nCtx: Int)
+
+ @JvmStatic
+ private external fun native_configureKvCacheReuse(enabled: Boolean)
+
+ // Public wrapper methods that ensure library is loaded first
+ @JvmStatic
+ fun configureThreads(nThreads: Int, nThreadsBatch: Int) {
+ NativeLibraryLoader.ensureLoaded()
+ native_configureThreads(nThreads, nThreadsBatch)
+ }
+
+ @JvmStatic
+ fun configureSampling(temperature: Float, topP: Float, topK: Int) {
+ NativeLibraryLoader.ensureLoaded()
+ native_configureSampling(temperature, topP, topK)
+ }
+
+ @JvmStatic
+ fun configureContext(nCtx: Int) {
+ NativeLibraryLoader.ensureLoaded()
+ native_configureContext(nCtx)
+ }
+
+ @JvmStatic
+ fun configureKvCacheReuse(enabled: Boolean) {
+ NativeLibraryLoader.ensureLoaded()
+ native_configureKvCacheReuse(enabled)
+ }
+
+ @JvmStatic
+ fun configureMaxTokens(maxTokens: Int) {
+ _instance.updateMaxTokens(maxTokens)
+ }
+
+ @JvmStatic
+ fun logFromNative(level: Int, message: String) {
+ val cleanMessage = message.trim()
+ when (level) {
+ 2 -> nativeLog.error(cleanMessage) // GGML_LOG_LEVEL_ERROR = 2
+ 3 -> nativeLog.warn(cleanMessage) // GGML_LOG_LEVEL_WARN = 3
+ 4 -> nativeLog.info(cleanMessage) // GGML_LOG_LEVEL_INFO = 4
+ else -> nativeLog.debug(cleanMessage)
+ }
+ }
+
+ private class IntVar(value: Int) {
+ @Volatile
+ var value: Int = value
+ // Removed private set to allow JNI to call getValue()
+
+ fun inc() {
+ synchronized(this) {
+ value += 1
+ }
+ }
+ }
+
+ private sealed interface State {
+ data object Idle : State
+ data class Loaded(
+ val model: Long,
+ val context: Long,
+ val batch: Long,
+ val sampler: Long
+ ) : State
+ }
+
+ private val _instance: LLamaAndroid = LLamaAndroid()
+
+ @JvmStatic
+ fun instance(): LLamaAndroid = _instance
+ }
+}
diff --git a/ai-core/proguard-rules.pro b/ai-core/proguard-rules.pro
new file mode 100644
index 00000000..dfa051a3
--- /dev/null
+++ b/ai-core/proguard-rules.pro
@@ -0,0 +1,22 @@
+# AI Core Plugin ProGuard Rules
+
+# Keep plugin entry point
+-keep public class com.itsaky.androidide.plugins.aicore.AiCorePlugin {
+ public ;
+}
+
+# Keep LlmInferenceService implementation
+-keep public class com.itsaky.androidide.plugins.aicore.LlmInferenceServiceImpl {
+ public ;
+}
+
+# Keep LocalLlmBackend
+-keep public class com.itsaky.androidide.plugins.aicore.LocalLlmBackend {
+ public ;
+}
+
+# Keep plugin-api interfaces
+-keep interface com.itsaky.androidide.plugins.** { *; }
+
+# Keep llama-impl classes (if needed)
+-keep class com.itsaky.llama.** { *; }
diff --git a/ai-core/scripts/rebuild-llama-aar.sh b/ai-core/scripts/rebuild-llama-aar.sh
new file mode 100755
index 00000000..848d72e9
--- /dev/null
+++ b/ai-core/scripts/rebuild-llama-aar.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+#
+# Regenerate the prebuilt llama.cpp AAR consumed by ai-core-plugin.
+#
+# You only need this after bumping the llama.cpp submodule (i.e. when your fork
+# is updated). A normal plugin build does NOT use this script — it consumes the
+# committed AAR directly.
+#
+# Requirements: the llama.cpp submodule and an Android NDK/CMake toolchain
+# (ANDROID_HOME / sdk.dir configured, NDK installed).
+
+set -euo pipefail
+
+# Run from the ai-assistant/ project root regardless of where it's invoked.
+cd "$(dirname "$0")/.."
+
+AAR_DST="ai-core-plugin/libs/v8/llama-v8-release.aar"
+AAR_SRC="llama-impl/build/outputs/aar/llama-impl-release.aar"
+API_DST="ai-core-plugin/libs/llama-api.jar"
+API_SRC="llama-api/build/libs/llama-api.jar"
+
+echo "==> Initializing the llama.cpp submodule (source for the native build)"
+git submodule update --init --recursive
+
+echo "==> Building :llama-impl (native lib) and :llama-api (interface jar)"
+./gradlew :llama-impl:assembleRelease :llama-api:jar
+
+echo "==> Copying artifacts into ai-core-plugin/libs"
+cp "$AAR_SRC" "$AAR_DST"
+cp "$API_SRC" "$API_DST"
+
+REV="$(git -C subprojects/llama.cpp rev-parse --short HEAD 2>/dev/null || echo unknown)"
+echo "==> Done. Regenerated from llama.cpp @ $REV"
+echo " Review and commit: $AAR_DST, $API_DST"
diff --git a/ai-core/settings.gradle.kts b/ai-core/settings.gradle.kts
new file mode 100644
index 00000000..814b83f3
--- /dev/null
+++ b/ai-core/settings.gradle.kts
@@ -0,0 +1,44 @@
+@file:Suppress("UnstableApiUsage")
+
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
+
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ google()
+ mavenCentral()
+ }
+}
+
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath(files("../libs/plugin-api.jar"))
+ classpath(files("../libs/gradle-plugin.jar"))
+ classpath("com.android.tools.build:gradle:8.13.2")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.0")
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven { url = uri("https://jitpack.io") }
+ }
+}
+
+rootProject.name = "ai-core"
+
+// The llama.cpp native modules are only needed to regenerate the prebuilt
+// AAR under libs/ (see scripts/rebuild-llama-aar.sh). A normal plugin build
+// consumes the committed AAR and needs neither the submodule nor the NDK, so
+// they are included only when the submodule is checked out.
+if (file("subprojects/llama.cpp/CMakeLists.txt").exists()) {
+ include(":llama-api")
+ include(":llama-impl")
+}
diff --git a/ai-core/src/main/AndroidManifest.xml b/ai-core/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..4e277475
--- /dev/null
+++ b/ai-core/src/main/AndroidManifest.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ai-core/src/main/assets/icon_day.png b/ai-core/src/main/assets/icon_day.png
new file mode 100644
index 00000000..4cb1dc35
Binary files /dev/null and b/ai-core/src/main/assets/icon_day.png differ
diff --git a/ai-core/src/main/assets/icon_night.png b/ai-core/src/main/assets/icon_night.png
new file mode 100644
index 00000000..355a5c0e
Binary files /dev/null and b/ai-core/src/main/assets/icon_night.png differ
diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/AiCorePlugin.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/AiCorePlugin.kt
new file mode 100644
index 00000000..7f23d5b0
--- /dev/null
+++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/AiCorePlugin.kt
@@ -0,0 +1,104 @@
+package com.itsaky.androidide.plugins.aicore
+
+import com.itsaky.androidide.plugins.IPlugin
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.services.LlmInferenceService
+import com.itsaky.androidide.plugins.services.SharedServices
+
+/**
+ * AI Core Plugin providing LLM inference capabilities.
+ * Implements LlmInferenceService and registers local LLM backend.
+ */
+class AiCorePlugin : IPlugin {
+
+ private lateinit var context: PluginContext
+ private lateinit var llmService: LlmInferenceServiceImpl
+ private lateinit var localBackend: LocalLlmBackend
+ private lateinit var geminiBackend: GeminiBackend
+
+ companion object {
+ const val PLUGIN_ID = "com.itsaky.androidide.plugins.aicore"
+ }
+
+ override fun initialize(context: PluginContext): Boolean {
+ return try {
+ this.context = context
+ context.logger.info("AiCorePlugin: Plugin initialized successfully")
+ true
+ } catch (e: Exception) {
+ context.logger.error("AiCorePlugin: Plugin initialization failed", e)
+ false
+ }
+ }
+
+ override fun activate(): Boolean {
+ context.logger.info("AiCorePlugin: Activating plugin")
+
+ try {
+ // Create LlmInferenceService
+ llmService = LlmInferenceServiceImpl()
+
+ // Register in SharedServices (accessible by all plugins)
+ SharedServices.register(LlmInferenceService::class.java, llmService)
+ context.logger.info("AiCorePlugin: Registered LlmInferenceService in SharedServices")
+
+ // Create and register local LLM backend
+ localBackend = LocalLlmBackend(context)
+ llmService.registerBackend(localBackend)
+ context.logger.info("AiCorePlugin: Registered local LLM backend")
+
+ // Create and register Gemini API backend
+ geminiBackend = GeminiBackend(context)
+ llmService.registerBackend(geminiBackend)
+ context.logger.info("AiCorePlugin: Registered Gemini API backend")
+
+ return true
+ } catch (e: Exception) {
+ context.logger.error("AiCorePlugin: Activation failed", e)
+ return false
+ }
+ }
+
+ override fun deactivate(): Boolean {
+ context.logger.info("AiCorePlugin: Deactivating plugin")
+
+ try {
+ // Unregister backends
+ if (::llmService.isInitialized) {
+ llmService.unregisterBackend("local")
+ context.logger.info("AiCorePlugin: Unregistered local LLM backend")
+ llmService.unregisterBackend("gemini")
+ context.logger.info("AiCorePlugin: Unregistered Gemini API backend")
+ }
+
+ // Unregister from SharedServices
+ SharedServices.unregister(LlmInferenceService::class.java)
+ context.logger.info("AiCorePlugin: Unregistered LlmInferenceService from SharedServices")
+
+ return true
+ } catch (e: Exception) {
+ context.logger.error("AiCorePlugin: Deactivation failed", e)
+ return false
+ }
+ }
+
+ override fun dispose() {
+ context.logger.info("AiCorePlugin: Disposing plugin")
+
+ // Cancel any ongoing generation
+ if (::llmService.isInitialized) {
+ llmService.cancelGeneration()
+ }
+
+ // Free the native model and cancel backend scopes so nothing leaks
+ // when the plugin is unloaded.
+ if (::localBackend.isInitialized) {
+ localBackend.close()
+ context.logger.info("AiCorePlugin: Released local LLM backend")
+ }
+ if (::geminiBackend.isInitialized) {
+ geminiBackend.close()
+ context.logger.info("AiCorePlugin: Released Gemini backend")
+ }
+ }
+}
diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt
new file mode 100644
index 00000000..714aa8b7
--- /dev/null
+++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt
@@ -0,0 +1,435 @@
+package com.itsaky.androidide.plugins.aicore
+
+import com.google.genai.Client
+import com.google.genai.ResponseStream
+import com.google.genai.types.Content
+import com.google.genai.types.GenerateContentConfig
+import com.google.genai.types.GenerateContentResponse
+import com.google.genai.types.Part
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.services.LlmInferenceService
+import com.itsaky.androidide.plugins.services.LlmInferenceService.*
+import com.itsaky.androidide.plugins.services.SharedServices
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Gemini API backend for cloud-based LLM inference.
+ * Uses Google's Generative AI SDK to make API calls.
+ */
+class GeminiBackend(private val context: PluginContext) : LlmBackend {
+
+ private val scope = CoroutineScope(Dispatchers.IO)
+
+ @Volatile
+ private var currentJob: Job? = null
+
+ /**
+ * Get the model name from preferences, or use default.
+ * Default to gemini-1.5-flash for compatibility, fallback to gemini-2.5-flash.
+ */
+ private fun getModelName(): String {
+ val prefs = try {
+ val aiAssistantContext = SharedServices.get(PluginContext::class.java)
+ aiAssistantContext?.getPluginSharedPreferences("AgentSettings")
+ } catch (e: Exception) {
+ context.logger.error("GeminiBackend: Error getting preferences", e)
+ null
+ }
+ return prefs?.getString("gemini_model", "gemini-1.5-flash") ?: "gemini-1.5-flash"
+ }
+
+ override fun getId(): String = "gemini"
+
+ override fun getName(): String = "Gemini API"
+
+ override fun isAvailable(): Boolean {
+ // Check if API key is configured
+ val prefs = try {
+ // Get ai-assistant plugin's preferences
+ val aiAssistantContext = SharedServices.get(PluginContext::class.java)
+ aiAssistantContext?.getPluginSharedPreferences("AgentSettings")
+ } catch (e: Exception) {
+ context.logger.error("GeminiBackend: Error getting preferences", e)
+ null
+ }
+
+ val apiKey = prefs?.getString("gemini_api_key", null)
+ context.logger.debug("GeminiBackend.isAvailable() - API key configured: ${!apiKey.isNullOrBlank()}")
+
+ return !apiKey.isNullOrBlank()
+ }
+
+ override fun generate(prompt: String, config: LlmConfig): CompletableFuture {
+ val future = CompletableFuture()
+
+ currentJob = scope.launch {
+ try {
+ val client = createClient()
+ if (client == null) {
+ future.complete(LlmResponse.failure("Gemini API key not configured"))
+ return@launch
+ }
+
+ val startTime = System.currentTimeMillis()
+ context.logger.info("GeminiBackend: Generating response for prompt (${prompt.length} chars)")
+
+ // Create history with user message
+ val history = listOf(
+ Content.builder()
+ .parts(listOf(Part.builder().text(buildPrompt(prompt, config)).build()))
+ .role("user")
+ .build()
+ )
+
+ // Generate content
+ val generateConfig = GenerateContentConfig.builder()
+ .temperature(config.temperature)
+ .maxOutputTokens(config.maxTokens)
+ .build()
+
+ val response = client.models.generateContent(getModelName(), history, generateConfig)
+
+ // Extract text from response (handle Java Optional)
+ val candidates = response.candidates().orElse(emptyList())
+ if (candidates.isEmpty()) {
+ future.complete(LlmResponse.failure("No response from Gemini API"))
+ return@launch
+ }
+
+ val content = candidates[0].content().orElse(null)
+ val parts = content?.parts()?.orElse(emptyList())
+ val text = parts?.firstOrNull()?.text()?.orElse(null)
+
+ if (text.isNullOrBlank()) {
+ future.complete(LlmResponse.failure("Empty response from Gemini API"))
+ } else {
+ val tokenCount = text.split("\\s+".toRegex()).size // Approximate token count
+ context.logger.info("GeminiBackend: Generated ${text.length} chars, ~$tokenCount tokens")
+ future.complete(LlmResponse.success(text, tokenCount, System.currentTimeMillis() - startTime))
+ }
+
+ } catch (e: Exception) {
+ context.logger.error("GeminiBackend: Error generating response", e)
+ val errorMsg = formatErrorMessage(e)
+ future.complete(LlmResponse.failure(errorMsg))
+ }
+ }
+
+ return future
+ }
+
+ override fun generateStreaming(
+ prompt: String,
+ config: LlmConfig,
+ callback: StreamCallback
+ ) {
+ currentJob = scope.launch {
+ try {
+ val client = createClient()
+ if (client == null) {
+ callback.onError("Gemini API key not configured")
+ return@launch
+ }
+
+ val startTime = System.currentTimeMillis()
+ context.logger.info("GeminiBackend: Streaming response for prompt (${prompt.length} chars)")
+
+ // Create history with user message
+ val history = listOf(
+ Content.builder()
+ .parts(listOf(Part.builder().text(buildPrompt(prompt, config)).build()))
+ .role("user")
+ .build()
+ )
+
+ // Generate content
+ val generateConfig = GenerateContentConfig.builder()
+ .temperature(config.temperature)
+ .maxOutputTokens(config.maxTokens)
+ .build()
+
+ // Use true streaming API
+ val responseStream: ResponseStream =
+ client.models.generateContentStream(getModelName(), history, generateConfig)
+
+ val fullText = StringBuilder()
+ var chunkCount = 0
+
+ try {
+ // Iterate through stream chunks as they arrive
+ for (response in responseStream) {
+ val chunk = response.text()
+ if (chunk != null && chunk.isNotEmpty()) {
+ chunkCount++
+ fullText.append(chunk)
+ context.logger.debug("GeminiBackend: Stream chunk #$chunkCount: ${chunk.length} chars")
+
+ // Send each chunk to UI immediately
+ callback.onToken(chunk)
+ }
+ }
+
+ val finalText = fullText.toString()
+ if (finalText.isBlank()) {
+ callback.onError("Empty response from Gemini API")
+ } else {
+ val tokenCount = finalText.split("\\s+".toRegex()).size
+ context.logger.info("GeminiBackend: Streamed ${finalText.length} chars in $chunkCount chunks, ~$tokenCount tokens")
+ callback.onComplete(LlmResponse.success(finalText, tokenCount, System.currentTimeMillis() - startTime))
+ }
+ } finally {
+ responseStream.close()
+ }
+
+ } catch (e: Exception) {
+ context.logger.error("GeminiBackend: Error in streaming", e)
+ callback.onError(formatErrorMessage(e))
+ }
+ }
+ }
+
+ override fun generateWithHistory(
+ history: List,
+ prompt: String,
+ config: LlmConfig
+ ): CompletableFuture {
+ context.logger.info("GeminiBackend.generateWithHistory() called with ${history.size} messages")
+
+ val future = CompletableFuture()
+
+ currentJob = scope.launch {
+ try {
+ val client = createClient()
+ if (client == null) {
+ future.complete(LlmResponse.failure("Gemini API key not configured"))
+ return@launch
+ }
+
+ val startTime = System.currentTimeMillis()
+
+ // Convert chat history to Gemini format
+ val geminiHistory = mutableListOf()
+
+ // Add system message if provided
+ if (config.systemPrompt != null) {
+ geminiHistory.add(
+ Content.builder()
+ .parts(listOf(Part.builder().text(config.systemPrompt).build()))
+ .role("user")
+ .build()
+ )
+ geminiHistory.add(
+ Content.builder()
+ .parts(listOf(Part.builder().text("Understood.").build()))
+ .role("model")
+ .build()
+ )
+ }
+
+ // Add chat history
+ for (msg in history) {
+ val role = when (msg.role) {
+ ChatMessage.Role.USER -> "user"
+ ChatMessage.Role.ASSISTANT -> "model"
+ ChatMessage.Role.SYSTEM -> "user" // System messages go as user
+ }
+ geminiHistory.add(
+ Content.builder()
+ .parts(listOf(Part.builder().text(msg.content).build()))
+ .role(role)
+ .build()
+ )
+ }
+
+ // Add current prompt
+ geminiHistory.add(
+ Content.builder()
+ .parts(listOf(Part.builder().text(prompt).build()))
+ .role("user")
+ .build()
+ )
+
+ // Generate content
+ val generateConfig = GenerateContentConfig.builder()
+ .temperature(config.temperature)
+ .maxOutputTokens(config.maxTokens)
+ .build()
+
+ val response = client.models.generateContent(getModelName(), geminiHistory, generateConfig)
+
+ // Extract text from response
+ val candidates = response.candidates().orElse(emptyList())
+ if (candidates.isEmpty()) {
+ future.complete(LlmResponse.failure("No response from Gemini API"))
+ return@launch
+ }
+
+ val content = candidates[0].content().orElse(null)
+ val parts = content?.parts()?.orElse(emptyList())
+ val text = parts?.firstOrNull()?.text()?.orElse(null)
+
+ if (text.isNullOrBlank()) {
+ future.complete(LlmResponse.failure("Empty response from Gemini API"))
+ } else {
+ val tokenCount = text.split("\\s+".toRegex()).size
+ context.logger.info("GeminiBackend: Generated ${text.length} chars with history, ~$tokenCount tokens")
+ future.complete(LlmResponse.success(text, tokenCount, System.currentTimeMillis() - startTime))
+ }
+
+ } catch (e: Exception) {
+ context.logger.error("GeminiBackend: Error generating with history", e)
+ future.complete(LlmResponse.failure(formatErrorMessage(e)))
+ }
+ }
+
+ return future
+ }
+
+ /**
+ * List available Gemini models.
+ * Returns a CompletableFuture with list of model names.
+ *
+ * Note: The Gemini Java SDK 1.16.0 doesn't have a direct list models API,
+ * so we return a curated list of commonly available models.
+ */
+ fun listModels(): CompletableFuture> {
+ val future = CompletableFuture>()
+
+ scope.launch {
+ try {
+ context.logger.info("GeminiBackend: Returning available models list")
+
+ // Return list of known Gemini models
+ // gemini-1.5-* may be deprecated, but included for fallback
+ future.complete(listOf(
+ "gemini-1.5-flash",
+ "gemini-1.5-pro",
+ "gemini-2.5-flash",
+ "gemini-2.5-flash-lite",
+ "gemini-2.5-pro",
+ "gemini-3-flash",
+ "gemini-3.5-flash"
+ ))
+ } catch (e: Exception) {
+ context.logger.error("GeminiBackend: Error in listModels", e)
+ // Return minimal fallback list on error
+ future.complete(listOf(
+ "gemini-1.5-flash",
+ "gemini-2.5-flash",
+ "gemini-3.5-flash"
+ ))
+ }
+ }
+
+ return future
+ }
+
+ /**
+ * Create Gemini client with API key from settings.
+ */
+ private fun createClient(): Client? {
+ val prefs = try {
+ val aiAssistantContext = SharedServices.get(PluginContext::class.java)
+ aiAssistantContext?.getPluginSharedPreferences("AgentSettings")
+ } catch (e: Exception) {
+ context.logger.error("GeminiBackend: Error getting preferences", e)
+ return null
+ }
+
+ val apiKey = prefs?.getString("gemini_api_key", null)
+ if (apiKey.isNullOrBlank()) {
+ context.logger.error("GeminiBackend: API key not found")
+ return null
+ }
+
+ return try {
+ Client.builder()
+ .apiKey(apiKey.trim())
+ .build()
+ } catch (e: Exception) {
+ context.logger.error("GeminiBackend: Error creating client", e)
+ null
+ }
+ }
+
+ /**
+ * Build the full prompt including system instructions.
+ */
+ private fun buildPrompt(userPrompt: String, config: LlmConfig): String {
+ val systemPrompt = config.systemPrompt ?: "You are a helpful coding assistant."
+ return """$systemPrompt
+
+User: $userPrompt"""
+ }
+
+ /**
+ * Generate streaming response with native Gemini function calling.
+ * This method replaces text-based tool call parsing with structured function calling.
+ */
+ fun generateStreamingWithTools(
+ prompt: String,
+ history: List,
+ config: LlmConfig,
+ tools: List,
+ callback: LlmInferenceService.ToolStreamCallback
+ ) {
+ currentJob = scope.launch {
+ try {
+ val client = createClient()
+ if (client == null) {
+ callback.onError("Gemini API key not configured")
+ return@launch
+ }
+
+ context.logger.info("GeminiBackend: Streaming with tools - ${tools.size} tools available")
+
+ // For Phase 1, we delegate to streaming without tools
+ // Full function calling integration requires Gemini SDK extensions
+ // This is a placeholder that uses the text-based approach
+ // TODO: Full implementation in next iteration with proper FunctionDeclaration support
+
+ val streamCallback = object : StreamCallback {
+ override fun onToken(token: String) = callback.onToken(token)
+ override fun onComplete(response: LlmResponse) = callback.onComplete(response)
+ override fun onError(error: String) = callback.onError(error)
+ }
+
+ generateStreaming(prompt, config, streamCallback)
+
+ } catch (e: Exception) {
+ context.logger.error("GeminiBackend: Error in streaming with tools", e)
+ callback.onError(formatErrorMessage(e))
+ }
+ }
+ }
+
+ /**
+ * Release all resources: cancel the backend scope and any in-flight
+ * request. Called from AiCorePlugin.dispose().
+ */
+ fun close() {
+ currentJob?.cancel()
+ scope.cancel()
+ }
+
+ /**
+ * Format error message with user-friendly descriptions.
+ */
+ private fun formatErrorMessage(e: Exception): String {
+ return when {
+ e.message?.contains("API key", ignoreCase = true) == true ->
+ "Invalid API key. Please check your Gemini API key in settings."
+ e.message?.contains("quota", ignoreCase = true) == true ||
+ e.message?.contains("limit", ignoreCase = true) == true ->
+ "API quota exceeded. Please check your Gemini API usage."
+ e.message?.contains("network", ignoreCase = true) == true ->
+ "Network error. Please check your internet connection."
+ else -> "Gemini API error: ${e.message}"
+ }
+ }
+}
diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LlmInferenceServiceImpl.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LlmInferenceServiceImpl.kt
new file mode 100644
index 00000000..47e921ed
--- /dev/null
+++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LlmInferenceServiceImpl.kt
@@ -0,0 +1,134 @@
+package com.itsaky.androidide.plugins.aicore
+
+import com.itsaky.androidide.plugins.services.LlmInferenceService
+import com.itsaky.androidide.plugins.services.LlmInferenceService.*
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Implementation of LlmInferenceService.
+ * Manages LLM backends and delegates generation requests to registered backends.
+ */
+class LlmInferenceServiceImpl : LlmInferenceService {
+
+ private val backends = ConcurrentHashMap()
+ @Volatile private var currentGeneration: CompletableFuture? = null
+
+ override fun registerBackend(backend: LlmBackend) {
+ backends[backend.getId()] = backend
+ }
+
+ override fun unregisterBackend(backendId: String) {
+ backends.remove(backendId)
+ }
+
+ override fun getAvailableBackends(): List {
+ return backends.values.toList()
+ }
+
+ override fun getBackend(backendId: String): LlmBackend? {
+ return backends[backendId]
+ }
+
+ override fun isBackendAvailable(backendId: String): Boolean {
+ val backend = backends[backendId]
+ return backend != null && backend.isAvailable()
+ }
+
+ override fun generateCompletion(prompt: String, config: LlmConfig): CompletableFuture {
+ val backend = backends[config.backendId]
+ ?: return CompletableFuture.completedFuture(
+ LlmResponse.failure("Backend '${config.backendId}' not found")
+ )
+
+ if (!backend.isAvailable()) {
+ return CompletableFuture.completedFuture(
+ LlmResponse.failure("Backend '${config.backendId}' is not available")
+ )
+ }
+
+ val future = backend.generate(prompt, config)
+ currentGeneration = future
+ return future
+ }
+
+ override fun generateStreaming(prompt: String, config: LlmConfig, callback: StreamCallback) {
+ val backend = backends[config.backendId]
+ if (backend == null) {
+ callback.onError("Backend '${config.backendId}' not found")
+ return
+ }
+
+ if (!backend.isAvailable()) {
+ callback.onError("Backend '${config.backendId}' is not available")
+ return
+ }
+
+ backend.generateStreaming(prompt, config, callback)
+ }
+
+ override fun generateWithHistory(
+ history: List,
+ prompt: String,
+ config: LlmConfig
+ ): CompletableFuture {
+ val backend = backends[config.backendId]
+ ?: return CompletableFuture.completedFuture(
+ LlmResponse.failure("Backend '${config.backendId}' not found")
+ )
+
+ if (!backend.isAvailable()) {
+ return CompletableFuture.completedFuture(
+ LlmResponse.failure("Backend '${config.backendId}' is not available")
+ )
+ }
+
+ val future = backend.generateWithHistory(history, prompt, config)
+ currentGeneration = future
+ return future
+ }
+
+ override fun generateStreamingWithTools(
+ prompt: String,
+ history: List,
+ config: LlmConfig,
+ tools: List,
+ callback: ToolStreamCallback
+ ) {
+ val backend = backends[config.backendId]
+ if (backend == null) {
+ callback.onError("Backend '${config.backendId}' not found")
+ return
+ }
+
+ if (!backend.isAvailable()) {
+ callback.onError("Backend '${config.backendId}' is not available")
+ return
+ }
+
+ // Check if backend supports tool calling (only Gemini for now)
+ if (backend !is GeminiBackend) {
+ // Fallback to streaming without tools for non-Gemini backends
+ val streamCallback = object : StreamCallback {
+ override fun onToken(token: String) = callback.onToken(token)
+ override fun onComplete(response: LlmResponse) = callback.onComplete(response)
+ override fun onError(error: String) = callback.onError(error)
+ }
+ backend.generateStreaming(prompt, config, streamCallback)
+ return
+ }
+
+ // Delegate to Gemini backend with tool support
+ (backend as GeminiBackend).generateStreamingWithTools(prompt, history, config, tools, callback)
+ }
+
+ override fun getEmbeddings(text: String, backendId: String): CompletableFuture {
+ // Stub implementation - embeddings not needed for Phase 3
+ return CompletableFuture.completedFuture(FloatArray(0))
+ }
+
+ override fun cancelGeneration() {
+ currentGeneration?.cancel(true)
+ currentGeneration = null
+ }
+}
diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LocalLlmBackend.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LocalLlmBackend.kt
new file mode 100644
index 00000000..f42eb393
--- /dev/null
+++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LocalLlmBackend.kt
@@ -0,0 +1,350 @@
+package com.itsaky.androidide.plugins.aicore
+
+import android.llama.cpp.LLamaAndroid
+import android.net.Uri
+import android.provider.DocumentsContract
+import com.itsaky.androidide.plugins.services.LlmInferenceService.*
+import com.itsaky.androidide.plugins.services.SharedServices
+import com.itsaky.androidide.plugins.PluginContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import java.io.File
+import java.io.FileOutputStream
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Local LLM backend using llama-impl for on-device inference.
+ * Wraps llama-impl APIs and implements LlmBackend interface.
+ */
+class LocalLlmBackend(private val context: PluginContext) : LlmBackend {
+
+ private val llama = LLamaAndroid.instance()
+ private val scope = CoroutineScope(Dispatchers.IO)
+
+ @Volatile private var modelLoaded = false
+ @Volatile private var currentModelPath: String? = null
+
+ override fun getId(): String = "local"
+
+ override fun getName(): String = "Local LLM"
+
+ override fun isAvailable(): Boolean {
+ // Check if model is actually configured
+ val prefs = try {
+ // Try to get ai-assistant plugin's preferences
+ val aiAssistantContext = SharedServices.get(PluginContext::class.java)
+ aiAssistantContext?.getPluginSharedPreferences("AgentSettings")
+ } catch (e: Exception) {
+ null
+ }
+
+ val configuredPath = prefs?.getString("local_llm_model_path", null)
+
+ context.logger.debug("LocalLlmBackend.isAvailable() - configured path: $configuredPath, modelLoaded: $modelLoaded")
+
+ // Available if model is loaded OR if a path is configured
+ return modelLoaded || !configuredPath.isNullOrBlank()
+ }
+
+ /**
+ * Resolves a content URI to an actual file path that native code can access.
+ * If the URI points to a real file, returns the file path. Otherwise, returns null.
+ */
+ private fun resolveContentUriToPath(uriString: String): String? {
+ if (!uriString.startsWith("content://")) {
+ return uriString // Already a file path
+ }
+
+ val uri = Uri.parse(uriString)
+ context.logger.info("Resolving content URI: $uri")
+
+ try {
+ // Try to get the actual file path using DocumentsContract
+ if (DocumentsContract.isDocumentUri(context.androidContext, uri)) {
+ val docId = DocumentsContract.getDocumentId(uri)
+ context.logger.debug("Document ID: $docId")
+
+ // For downloads provider, the path is typically in the downloads folder
+ if (uri.authority == "com.android.providers.downloads.documents") {
+ // The docId for downloads provider can be in different formats
+ // Try to construct the file path
+ val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(
+ android.os.Environment.DIRECTORY_DOWNLOADS
+ )
+
+ // Try common patterns for file names in downloads
+ // The docId might be a number (media store ID) or "msf:number" or "raw:/path"
+ val filePath = when {
+ docId.startsWith("raw:") -> {
+ docId.substring(4) // Remove "raw:" prefix
+ }
+ docId.startsWith("msf:") -> {
+ // This is a media store file ID, we need to query it
+ // For now, try to find .gguf files in downloads
+ findGgufFileInDownloads(downloadsDir)
+ }
+ else -> {
+ // Might be a direct file ID, try downloads folder
+ findGgufFileInDownloads(downloadsDir)
+ }
+ }
+
+ if (filePath != null && File(filePath).exists()) {
+ context.logger.info("Resolved to file path: $filePath")
+ return filePath
+ }
+ }
+ }
+ } catch (e: Exception) {
+ context.logger.error("Error resolving content URI", e)
+ }
+
+ return null
+ }
+
+ /**
+ * Scans the downloads directory for .gguf files and returns the first one found.
+ * This is a fallback when we can't directly resolve the content URI.
+ */
+ private fun findGgufFileInDownloads(downloadsDir: File): String? {
+ if (!downloadsDir.exists() || !downloadsDir.isDirectory) {
+ context.logger.warn("Downloads directory not found: ${downloadsDir.absolutePath}")
+ return null
+ }
+
+ val ggufFiles = downloadsDir.listFiles { file ->
+ file.isFile && file.name.endsWith(".gguf", ignoreCase = true)
+ }
+
+ if (ggufFiles != null && ggufFiles.isNotEmpty()) {
+ // Return the most recently modified .gguf file
+ val latestFile = ggufFiles.maxByOrNull { it.lastModified() }
+ context.logger.info("Found .gguf file in downloads: ${latestFile?.absolutePath}")
+ return latestFile?.absolutePath
+ }
+
+ context.logger.warn("No .gguf files found in downloads directory")
+ return null
+ }
+
+ private suspend fun ensureModelLoaded(modelPath: String) {
+ // Resolve content URI to actual file path
+ val resolvedPath = resolveContentUriToPath(modelPath)
+ if (resolvedPath == null) {
+ throw IllegalStateException("Could not resolve model path: $modelPath. Make sure the .gguf file is in the Downloads folder.")
+ }
+
+ if (modelLoaded && currentModelPath == resolvedPath) {
+ return // Already loaded
+ }
+
+ // Unload old model if loaded
+ if (modelLoaded) {
+ context.logger.info("Unloading previous model: $currentModelPath")
+ llama.unload()
+ modelLoaded = false
+ currentModelPath = null
+ }
+
+ // Load new model with resolved path
+ context.logger.info("Loading model: $resolvedPath")
+ llama.load(resolvedPath)
+ modelLoaded = true
+ currentModelPath = resolvedPath
+ context.logger.info("Model loaded successfully")
+ }
+
+ override fun generate(prompt: String, config: LlmConfig): CompletableFuture {
+ context.logger.info("LocalLlmBackend.generate() called with prompt: ${prompt.take(50)}...")
+
+ // Check if model is configured
+ val prefs = try {
+ val aiAssistantContext = SharedServices.get(PluginContext::class.java)
+ aiAssistantContext?.getPluginSharedPreferences("AgentSettings")
+ } catch (e: Exception) {
+ null
+ }
+
+ val configuredPath = prefs?.getString("local_llm_model_path", null)
+ context.logger.info("LocalLlmBackend: configured model path = $configuredPath")
+
+ if (configuredPath.isNullOrBlank()) {
+ return CompletableFuture.completedFuture(
+ LlmResponse.failure("No model configured. Please go to Settings and select a .gguf model file.")
+ )
+ }
+
+ val future = CompletableFuture()
+
+ scope.launch {
+ try {
+ // Configure sampling (use defaults for topP and topK)
+ LLamaAndroid.configureSampling(
+ config.temperature,
+ 0.9f, // topP default
+ 40 // topK default
+ )
+ LLamaAndroid.configureMaxTokens(config.maxTokens)
+
+ // Ensure model is loaded
+ ensureModelLoaded(configuredPath)
+
+ // Build full prompt with system message
+ val fullPrompt = if (config.systemPrompt != null) {
+ "${config.systemPrompt}\n\nUser: $prompt\nAssistant:"
+ } else {
+ "User: $prompt\nAssistant:"
+ }
+
+ val startTime = System.currentTimeMillis()
+
+ // Collect all tokens
+ val responseBuilder = StringBuilder()
+ var tokenCount = 0
+
+ llama.send(
+ message = fullPrompt,
+ formatChat = false,
+ stop = emptyList(),
+ clearCache = false
+ ).collect { token ->
+ responseBuilder.append(token)
+ tokenCount++
+ }
+
+ val responseText = responseBuilder.toString()
+ context.logger.info("Generated response: ${responseText.take(50)}... ($tokenCount tokens)")
+
+ future.complete(LlmResponse.success(responseText, tokenCount, System.currentTimeMillis() - startTime))
+ } catch (e: Exception) {
+ context.logger.error("Error during generation", e)
+ future.complete(LlmResponse.failure("Error: ${e.message}"))
+ }
+ }
+
+ return future
+ }
+
+ override fun generateStreaming(prompt: String, config: LlmConfig, callback: StreamCallback) {
+ context.logger.info("LocalLlmBackend.generateStreaming() called")
+
+ // Check if model is configured
+ val prefs = try {
+ val aiAssistantContext = SharedServices.get(PluginContext::class.java)
+ aiAssistantContext?.getPluginSharedPreferences("AgentSettings")
+ } catch (e: Exception) {
+ null
+ }
+
+ val configuredPath = prefs?.getString("local_llm_model_path", null)
+
+ if (configuredPath.isNullOrBlank()) {
+ callback.onError("No model configured. Please go to Settings and select a .gguf model file.")
+ return
+ }
+
+ scope.launch {
+ try {
+ // Configure sampling (use defaults for topP and topK)
+ LLamaAndroid.configureSampling(
+ config.temperature,
+ 0.9f, // topP default
+ 40 // topK default
+ )
+ LLamaAndroid.configureMaxTokens(config.maxTokens)
+
+ // Ensure model is loaded
+ ensureModelLoaded(configuredPath)
+
+ // Build full prompt with system message
+ val fullPrompt = if (config.systemPrompt != null) {
+ "${config.systemPrompt}\n\nUser: $prompt\nAssistant:"
+ } else {
+ "User: $prompt\nAssistant:"
+ }
+
+ val startTime = System.currentTimeMillis()
+ var tokenCount = 0
+ val responseBuilder = StringBuilder()
+
+ llama.send(
+ message = fullPrompt,
+ formatChat = false,
+ stop = emptyList(),
+ clearCache = false
+ ).collect { token ->
+ callback.onToken(token)
+ responseBuilder.append(token)
+ tokenCount++
+ }
+
+ callback.onComplete(LlmResponse.success(responseBuilder.toString(), tokenCount, System.currentTimeMillis() - startTime))
+ } catch (e: Exception) {
+ context.logger.error("Error during streaming generation", e)
+ callback.onError("Error: ${e.message}")
+ }
+ }
+ }
+
+ override fun generateWithHistory(
+ history: List,
+ prompt: String,
+ config: LlmConfig
+ ): CompletableFuture {
+ context.logger.info("LocalLlmBackend.generateWithHistory() called with ${history.size} messages")
+
+ // Build conversation prompt
+ val conversationBuilder = StringBuilder()
+
+ if (config.systemPrompt != null) {
+ conversationBuilder.append(config.systemPrompt).append("\n\n")
+ }
+
+ // Add history
+ for (msg in history) {
+ val role = when (msg.role) {
+ ChatMessage.Role.USER -> "User"
+ ChatMessage.Role.ASSISTANT -> "Assistant"
+ ChatMessage.Role.SYSTEM -> "System"
+ }
+ conversationBuilder.append("$role: ${msg.content}\n")
+ }
+
+ // Add current prompt
+ conversationBuilder.append("User: $prompt\nAssistant:")
+
+ // Use regular generate with the full conversation
+ return generate(conversationBuilder.toString(), config)
+ }
+
+ /** Suspending model unload — safe to call from any coroutine. */
+ private suspend fun unloadModelInternal() {
+ if (modelLoaded) {
+ llama.unload()
+ modelLoaded = false
+ currentModelPath = null
+ context.logger.info("Model unloaded")
+ }
+ }
+
+ /**
+ * Release all resources. Called from AiCorePlugin.dispose(), which may run on
+ * the main thread; llama.unload() drains a single-threaded native run loop and
+ * can block while inference is in flight, so it must never run via runBlocking
+ * on Main. Cancel generation, then unload on a background thread.
+ */
+ fun close() {
+ scope.cancel()
+ if (modelLoaded) {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ unloadModelInternal()
+ } catch (e: Exception) {
+ context.logger.error("Error unloading model during close()", e)
+ }
+ }
+ }
+ }
+}
diff --git a/ai-core/src/main/res/values/styles.xml b/ai-core/src/main/res/values/styles.xml
new file mode 100644
index 00000000..8e7f8b50
--- /dev/null
+++ b/ai-core/src/main/res/values/styles.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/AiCoreIntegrationTest.kt b/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/AiCoreIntegrationTest.kt
new file mode 100644
index 00000000..01de4ee3
--- /dev/null
+++ b/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/AiCoreIntegrationTest.kt
@@ -0,0 +1,85 @@
+package com.itsaky.androidide.plugins.aicore
+
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.PluginLogger
+import com.itsaky.androidide.plugins.services.LlmInferenceService
+import com.itsaky.androidide.plugins.services.LlmInferenceService.*
+import com.itsaky.androidide.plugins.manager.context.ServiceRegistryImpl
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Test
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.After
+
+/**
+ * Integration test demonstrating complete AI Core Plugin workflow.
+ */
+class AiCoreIntegrationTest {
+
+ private lateinit var plugin: AiCorePlugin
+ private lateinit var context: PluginContext
+
+ @Before
+ fun setup() {
+ val mockLogger = mockk(relaxed = true)
+ val serviceRegistry = ServiceRegistryImpl()
+
+ context = mockk {
+ every { logger } returns mockLogger
+ every { services } returns serviceRegistry
+ }
+
+ plugin = AiCorePlugin()
+ }
+
+ @After
+ fun teardown() {
+ plugin.dispose()
+ }
+
+ @Test
+ fun testCompletePluginWorkflow() {
+ // Step 1: Initialize plugin
+ val initSuccess = plugin.initialize(context)
+ assertTrue("Plugin initialization should succeed", initSuccess)
+
+ // Step 2: Activate plugin (registers service and backend)
+ val activateSuccess = plugin.activate()
+ assertTrue("Plugin activation should succeed", activateSuccess)
+
+ // Step 3: Retrieve LlmInferenceService from context
+ val service = context.services.get(LlmInferenceService::class.java)
+ assertNotNull("LlmInferenceService should be registered", service)
+
+ // Step 4: Verify local backend is registered
+ val backends = service!!.getAvailableBackends()
+ assertEquals("Should have 1 backend", 1, backends.size)
+ assertEquals("Backend ID should be 'local'", "local", backends[0].getId())
+
+ // Step 5: Check backend availability
+ val isAvailable = service.isBackendAvailable("local")
+ assertFalse("Backend should not be available (model not loaded)", isAvailable)
+
+ // Step 6: Attempt generation with unavailable backend
+ val config = LlmConfig("local")
+ config.temperature = 0.7f
+ config.maxTokens = 100
+
+ val future = service.generateCompletion("Write a hello world function", config)
+ val response = future.get()
+
+ assertFalse("Response should fail (backend unavailable)", response.success)
+ assertNotNull("Error message should be present", response.error)
+ assertTrue("Error should mention availability",
+ response.error!!.contains("not available"))
+
+ // Step 7: Deactivate plugin
+ val deactivateSuccess = plugin.deactivate()
+ assertTrue("Plugin deactivation should succeed", deactivateSuccess)
+
+ // Step 8: Verify backend unregistered
+ val backendAfterDeactivate = service.getBackend("local")
+ assertNull("Backend should be unregistered after deactivation", backendAfterDeactivate)
+ }
+}
diff --git a/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/AiCorePluginTest.kt b/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/AiCorePluginTest.kt
new file mode 100644
index 00000000..91df2b98
--- /dev/null
+++ b/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/AiCorePluginTest.kt
@@ -0,0 +1,90 @@
+package com.itsaky.androidide.plugins.aicore
+
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.PluginLogger
+import com.itsaky.androidide.plugins.ServiceRegistry
+import com.itsaky.androidide.plugins.services.LlmInferenceService
+import com.itsaky.androidide.plugins.manager.context.ServiceRegistryImpl
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Test
+import org.junit.Assert.*
+
+class AiCorePluginTest {
+
+ @Test
+ fun testPluginInitialization() {
+ val mockLogger = mockk