diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackend.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackend.kt
index cb7d3fd6..e7396227 100644
--- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackend.kt
+++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackend.kt
@@ -351,7 +351,7 @@ class GeminiBackend(
override fun generate(prompt: String, config: LlmConfig): CompletableFuture {
val future = CompletableFuture()
- currentJob = scope.launch {
+ val job = scope.launch {
val keyStamp = storedKeyStamp()
try {
val apiKey = readGeminiApiKey()
@@ -381,6 +381,8 @@ class GeminiBackend(
future.complete(LlmResponse.failure(formatErrorMessage(e, keyStamp)))
}
}
+ currentJob = job
+ future.cancelJobOnCancel(job)
return future
}
@@ -577,7 +579,7 @@ class GeminiBackend(
val future = CompletableFuture()
- currentJob = scope.launch {
+ val job = scope.launch {
val keyStamp = storedKeyStamp()
try {
val apiKey = readGeminiApiKey()
@@ -607,6 +609,8 @@ class GeminiBackend(
future.complete(LlmResponse.failure(formatErrorMessage(e, keyStamp)))
}
}
+ currentJob = job
+ future.cancelJobOnCancel(job)
return future
}
diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt
index a7c7baca..bfd89fef 100644
--- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt
+++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt
@@ -32,6 +32,7 @@ import com.itsaky.androidide.plugins.PluginContext
import com.itsaky.androidide.plugins.aiagentgemini.plugin.GeminiPlugin
import com.itsaky.androidide.plugins.aiagentgemini.R
import com.itsaky.androidide.plugins.aiagentgemini.ui.SecretRevealController
+import com.itsaky.androidide.plugins.aiagentgemini.ui.applyPaneStyling
import com.itsaky.androidide.plugins.base.PluginFragmentHelper
import com.itsaky.androidide.plugins.security.KeystoreSecretStore
import com.itsaky.androidide.plugins.services.IdeTooltipService
@@ -40,6 +41,14 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
+/** The pane's secondary actions, drawn outlined; each section's Save stays filled. */
+private val OUTLINED_BUTTON_IDS = setOf(
+ R.id.btn_clear_api_key,
+ R.id.btn_edit_api_key,
+ R.id.btn_get_free_key,
+ R.id.btn_refresh_models,
+)
+
/**
* This backend's settings pane, mounted by whichever screen offers a backend selector.
*
@@ -108,6 +117,7 @@ class GeminiSettingsFragment : Fragment() {
GeminiSettingsViewModelFactory { GeminiPlugin.getContext() }
)[GeminiSettingsViewModel::class.java]
+ view.applyPaneStyling(OUTLINED_BUTTON_IDS)
setupApiKeyUi(view)
setupModelPicker(view, chatModelPicker())
setupModelPicker(view, embeddingModelPicker())
@@ -580,6 +590,7 @@ class GeminiSettingsFragment : Fragment() {
}.isSuccess
}
+
/**
* Put the dropdown chevron on [box]'s end icon.
*
diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/ui/PaneStyling.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/ui/PaneStyling.kt
new file mode 100644
index 00000000..75ce661e
--- /dev/null
+++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/ui/PaneStyling.kt
@@ -0,0 +1,77 @@
+package com.itsaky.androidide.plugins.aiagentgemini.ui
+
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.ColorRes
+import androidx.core.content.ContextCompat
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.divider.MaterialDivider
+import com.google.android.material.textfield.TextInputLayout
+import com.itsaky.androidide.plugins.aiagentgemini.R
+
+/** How much a pane button stands out: one filled action per section, the rest outlined. */
+private enum class ButtonEmphasis { FILLED, OUTLINED }
+
+/**
+ * Gives every Material button, text field and divider under [this] its Material 3 colours, outline
+ * and ripple in code. The styles' `app:` items are dropped inside the host, so XML alone leaves
+ * these controls on the host theme's values.
+ *
+ * @param outlinedButtonIds the buttons that are secondary actions; every other button is filled.
+ */
+internal fun View.applyPaneStyling(outlinedButtonIds: Set) {
+ when (this) {
+ is MaterialButton -> applyEmphasis(
+ if (id in outlinedButtonIds) ButtonEmphasis.OUTLINED else ButtonEmphasis.FILLED
+ )
+ is TextInputLayout -> applyOutline()
+ is MaterialDivider -> applyHairline()
+ }
+ if (this is ViewGroup) {
+ for (i in 0 until childCount) getChildAt(i).applyPaneStyling(outlinedButtonIds)
+ }
+}
+
+/** Container, label, icon, border and ripple for [emphasis], each with its disabled state. */
+private fun MaterialButton.applyEmphasis(emphasis: ButtonEmphasis) {
+ val filled = emphasis == ButtonEmphasis.FILLED
+ val content = colors(
+ if (filled) R.color.plugin_button_filled_content else R.color.plugin_button_outlined_content
+ )
+ backgroundTintList = if (filled) {
+ colors(R.color.plugin_button_filled_container)
+ } else {
+ ColorStateList.valueOf(Color.TRANSPARENT)
+ }
+ setTextColor(content)
+ iconTint = content
+ rippleColor = colors(
+ if (filled) R.color.plugin_button_filled_ripple else R.color.plugin_button_outlined_ripple
+ )
+ strokeColor = colors(R.color.plugin_button_outlined_stroke)
+ strokeWidth = if (filled) 0 else resources.getDimensionPixelSize(R.dimen.button_stroke_width)
+ cornerRadius = resources.getDimensionPixelSize(R.dimen.radius_md)
+}
+
+/** Outline, corners, hint and end icon of an outlined-box field. */
+private fun TextInputLayout.applyOutline() {
+ setBoxStrokeColorStateList(colors(R.color.plugin_box_stroke))
+ setBoxStrokeErrorColor(colors(R.color.plugin_error))
+ val radius = resources.getDimension(R.dimen.radius_md)
+ setBoxCornerRadii(radius, radius, radius, radius)
+ val hint = colors(R.color.plugin_text_muted)
+ defaultHintTextColor = hint
+ hintTextColor = hint
+ setEndIconTintList(colors(R.color.plugin_on_surface_variant))
+}
+
+private fun MaterialDivider.applyHairline() {
+ setDividerColorResource(R.color.plugin_outline_variant)
+ setDividerThicknessResource(R.dimen.divider_thickness)
+}
+
+/** Resolved against this view's context, which carries the plugin's resources. */
+private fun View.colors(@ColorRes id: Int): ColorStateList =
+ requireNotNull(ContextCompat.getColorStateList(context, id))
diff --git a/plugins/AI-Agent-Gemini/src/main/res/color/plugin_box_stroke.xml b/plugins/AI-Agent-Gemini/src/main/res/color/plugin_box_stroke.xml
new file mode 100644
index 00000000..497e1d2d
--- /dev/null
+++ b/plugins/AI-Agent-Gemini/src/main/res/color/plugin_box_stroke.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_filled_container.xml b/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_filled_container.xml
new file mode 100644
index 00000000..12f92e88
--- /dev/null
+++ b/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_filled_container.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_filled_content.xml b/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_filled_content.xml
new file mode 100644
index 00000000..ae2cd196
--- /dev/null
+++ b/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_filled_content.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_filled_ripple.xml b/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_filled_ripple.xml
new file mode 100644
index 00000000..fb923b0e
--- /dev/null
+++ b/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_filled_ripple.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_outlined_content.xml b/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_outlined_content.xml
new file mode 100644
index 00000000..2c028905
--- /dev/null
+++ b/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_outlined_content.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_outlined_ripple.xml b/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_outlined_ripple.xml
new file mode 100644
index 00000000..95596266
--- /dev/null
+++ b/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_outlined_ripple.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_outlined_stroke.xml b/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_outlined_stroke.xml
new file mode 100644
index 00000000..e009bd35
--- /dev/null
+++ b/plugins/AI-Agent-Gemini/src/main/res/color/plugin_button_outlined_stroke.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/plugins/AI-Agent-Gemini/src/main/res/layout/fragment_gemini_settings.xml b/plugins/AI-Agent-Gemini/src/main/res/layout/fragment_gemini_settings.xml
index 688a4a51..93c8c402 100644
--- a/plugins/AI-Agent-Gemini/src/main/res/layout/fragment_gemini_settings.xml
+++ b/plugins/AI-Agent-Gemini/src/main/res/layout/fragment_gemini_settings.xml
@@ -75,7 +75,7 @@
+
+
+
#A3A3A3
#525252
- #1FFAFAFA
diff --git a/plugins/AI-Agent-Gemini/src/main/res/values/colors.xml b/plugins/AI-Agent-Gemini/src/main/res/values/colors.xml
index 3bd84f25..d0616906 100644
--- a/plugins/AI-Agent-Gemini/src/main/res/values/colors.xml
+++ b/plugins/AI-Agent-Gemini/src/main/res/values/colors.xml
@@ -36,5 +36,4 @@
#737373#A3A3A3
- #1F0A0A0A
diff --git a/plugins/AI-Agent-Gemini/src/main/res/values/dimens.xml b/plugins/AI-Agent-Gemini/src/main/res/values/dimens.xml
index 8279555f..b57850fe 100644
--- a/plugins/AI-Agent-Gemini/src/main/res/values/dimens.xml
+++ b/plugins/AI-Agent-Gemini/src/main/res/values/dimens.xml
@@ -11,6 +11,9 @@
16dp24dp
+ 1dp
+ 1dp
+
8dp11sp
diff --git a/plugins/AI-Agent-Gemini/src/main/res/values/styles.xml b/plugins/AI-Agent-Gemini/src/main/res/values/styles.xml
index c86952f6..8dc1c450 100644
--- a/plugins/AI-Agent-Gemini/src/main/res/values/styles.xml
+++ b/plugins/AI-Agent-Gemini/src/main/res/values/styles.xml
@@ -95,19 +95,33 @@
by choice, matching the IDE's own grey accent rather than this plugin's orange, on a screen
whose chrome belongs to AI Core. Light/dark comes from values-night/colors.xml.
- One style for every button on the pane: a secondary action that styled itself differently
- is exactly what broke on the OpenAI pane, and the hierarchy is not worth a second way to
- get contrast wrong. -->
+ Filled for the one primary action in a section, outlined for the rest, per Material 3's
+ button hierarchy. Both pin direct colours, so neither resolves a role against the host.
+ The app: items here are dropped inside the host; ui/PaneStyling.kt applies them in code. -->
+
+
diff --git a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt
index ef762321..644b3ea2 100644
--- a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt
+++ b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt
@@ -713,7 +713,7 @@ class LocalLlmBackend(
val future = CompletableFuture()
- currentGenerateJob = scope.launch {
+ val job = scope.launch {
try {
// Serialize against other generations on the shared native context.
generationMutex.withLock {
@@ -767,6 +767,8 @@ class LocalLlmBackend(
future.complete(LlmResponse.failure("Error: ${e.message}"))
}
}
+ currentGenerateJob = job
+ future.cancelJobOnCancel(job)
return future
}
@@ -945,3 +947,15 @@ class LocalLlmBackend(
cleanup.invokeOnCompletion { cleanupScope.cancel() }
}
}
+
+/**
+ * Cancel [job] when this future is cancelled by its caller.
+ *
+ * [CompletableFuture.cancel] only flips the future's own state, so without this a caller that gives
+ * up leaves the generation holding the native context until it runs out of tokens.
+ *
+ * @param job the coroutine producing this future's value
+ */
+private fun CompletableFuture.cancelJobOnCancel(job: Job) {
+ whenComplete { _, _ -> if (isCancelled) job.cancel() }
+}
diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt
index 37d2f227..28566943 100644
--- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt
+++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt
@@ -307,7 +307,7 @@ class OpenAiBackend(
override fun generate(prompt: String, config: LlmConfig): CompletableFuture {
val future = CompletableFuture()
- currentJob = scope.launch {
+ val job = scope.launch {
val keyStamp = storedKeyStamp()
try {
val startTime = System.currentTimeMillis()
@@ -331,6 +331,8 @@ class OpenAiBackend(
future.complete(LlmResponse.failure(formatErrorMessage(e, keyStamp)))
}
}
+ currentJob = job
+ future.cancelJobOnCancel(job)
return future
}
@@ -357,7 +359,7 @@ class OpenAiBackend(
val future = CompletableFuture()
- currentJob = scope.launch {
+ val job = scope.launch {
val keyStamp = storedKeyStamp()
try {
val startTime = System.currentTimeMillis()
@@ -380,6 +382,8 @@ class OpenAiBackend(
future.complete(LlmResponse.failure(formatErrorMessage(e, keyStamp)))
}
}
+ currentJob = job
+ future.cancelJobOnCancel(job)
return future
}
diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt
index 4c82f89a..1eba75be 100644
--- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt
+++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt
@@ -34,6 +34,7 @@ import com.itsaky.androidide.plugins.PluginContext
import com.itsaky.androidide.plugins.aiagentopenai.R
import com.itsaky.androidide.plugins.aiagentopenai.plugin.OpenAiPlugin
import com.itsaky.androidide.plugins.aiagentopenai.ui.SecretRevealController
+import com.itsaky.androidide.plugins.aiagentopenai.ui.applyPaneStyling
import com.itsaky.androidide.plugins.base.PluginFragmentHelper
import com.itsaky.androidide.plugins.security.KeystoreSecretStore
import com.itsaky.androidide.plugins.services.IdeTooltipService
@@ -42,6 +43,14 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
+/** The pane's secondary actions, drawn outlined; each section's Save stays filled. */
+private val OUTLINED_BUTTON_IDS = setOf(
+ R.id.btn_clear_api_key,
+ R.id.btn_edit_api_key,
+ R.id.btn_get_key,
+ R.id.btn_test_connection,
+)
+
/**
* This backend's settings pane, mounted by whichever screen offers a backend selector.
*
@@ -112,6 +121,7 @@ class OpenAiSettingsFragment : Fragment() {
// The key section publishes onServerChanged, so it is built before the server section that
// fires it, and before the first call below that dresses the pane for the saved server.
+ view.applyPaneStyling(OUTLINED_BUTTON_IDS)
setupApiKeyUi(view)
setupServerUi(view)
setupModelPicker(view, chatModelPicker())
@@ -199,6 +209,7 @@ class OpenAiSettingsFragment : Fragment() {
target.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
}
+
/**
* Put the dropdown chevron on [box]'s end icon.
*
diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/ui/PaneStyling.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/ui/PaneStyling.kt
new file mode 100644
index 00000000..d65bac24
--- /dev/null
+++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/ui/PaneStyling.kt
@@ -0,0 +1,77 @@
+package com.itsaky.androidide.plugins.aiagentopenai.ui
+
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.ColorRes
+import androidx.core.content.ContextCompat
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.divider.MaterialDivider
+import com.google.android.material.textfield.TextInputLayout
+import com.itsaky.androidide.plugins.aiagentopenai.R
+
+/** How much a pane button stands out: one filled action per section, the rest outlined. */
+private enum class ButtonEmphasis { FILLED, OUTLINED }
+
+/**
+ * Gives every Material button, text field and divider under [this] its Material 3 colours, outline
+ * and ripple in code. The styles' `app:` items are dropped inside the host, so XML alone leaves
+ * these controls on the host theme's values.
+ *
+ * @param outlinedButtonIds the buttons that are secondary actions; every other button is filled.
+ */
+internal fun View.applyPaneStyling(outlinedButtonIds: Set) {
+ when (this) {
+ is MaterialButton -> applyEmphasis(
+ if (id in outlinedButtonIds) ButtonEmphasis.OUTLINED else ButtonEmphasis.FILLED
+ )
+ is TextInputLayout -> applyOutline()
+ is MaterialDivider -> applyHairline()
+ }
+ if (this is ViewGroup) {
+ for (i in 0 until childCount) getChildAt(i).applyPaneStyling(outlinedButtonIds)
+ }
+}
+
+/** Container, label, icon, border and ripple for [emphasis], each with its disabled state. */
+private fun MaterialButton.applyEmphasis(emphasis: ButtonEmphasis) {
+ val filled = emphasis == ButtonEmphasis.FILLED
+ val content = colors(
+ if (filled) R.color.plugin_button_filled_content else R.color.plugin_button_outlined_content
+ )
+ backgroundTintList = if (filled) {
+ colors(R.color.plugin_button_filled_container)
+ } else {
+ ColorStateList.valueOf(Color.TRANSPARENT)
+ }
+ setTextColor(content)
+ iconTint = content
+ rippleColor = colors(
+ if (filled) R.color.plugin_button_filled_ripple else R.color.plugin_button_outlined_ripple
+ )
+ strokeColor = colors(R.color.plugin_button_outlined_stroke)
+ strokeWidth = if (filled) 0 else resources.getDimensionPixelSize(R.dimen.button_stroke_width)
+ cornerRadius = resources.getDimensionPixelSize(R.dimen.radius_md)
+}
+
+/** Outline, corners, hint and end icon of an outlined-box field. */
+private fun TextInputLayout.applyOutline() {
+ setBoxStrokeColorStateList(colors(R.color.plugin_box_stroke))
+ setBoxStrokeErrorColor(colors(R.color.plugin_error))
+ val radius = resources.getDimension(R.dimen.radius_md)
+ setBoxCornerRadii(radius, radius, radius, radius)
+ val hint = colors(R.color.plugin_text_muted)
+ defaultHintTextColor = hint
+ hintTextColor = hint
+ setEndIconTintList(colors(R.color.plugin_on_surface_variant))
+}
+
+private fun MaterialDivider.applyHairline() {
+ setDividerColorResource(R.color.plugin_outline_variant)
+ setDividerThicknessResource(R.dimen.divider_thickness)
+}
+
+/** Resolved against this view's context, which carries the plugin's resources. */
+private fun View.colors(@ColorRes id: Int): ColorStateList =
+ requireNotNull(ContextCompat.getColorStateList(context, id))
diff --git a/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_box_stroke.xml b/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_box_stroke.xml
new file mode 100644
index 00000000..497e1d2d
--- /dev/null
+++ b/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_box_stroke.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_filled_container.xml b/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_filled_container.xml
new file mode 100644
index 00000000..12f92e88
--- /dev/null
+++ b/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_filled_container.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_filled_content.xml b/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_filled_content.xml
new file mode 100644
index 00000000..ae2cd196
--- /dev/null
+++ b/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_filled_content.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_filled_ripple.xml b/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_filled_ripple.xml
new file mode 100644
index 00000000..fb923b0e
--- /dev/null
+++ b/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_filled_ripple.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_outlined_content.xml b/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_outlined_content.xml
new file mode 100644
index 00000000..2c028905
--- /dev/null
+++ b/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_outlined_content.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_outlined_ripple.xml b/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_outlined_ripple.xml
new file mode 100644
index 00000000..95596266
--- /dev/null
+++ b/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_outlined_ripple.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_outlined_stroke.xml b/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_outlined_stroke.xml
new file mode 100644
index 00000000..e009bd35
--- /dev/null
+++ b/plugins/AI-Agent-OpenAI/src/main/res/color/plugin_button_outlined_stroke.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/plugins/AI-Agent-OpenAI/src/main/res/layout/fragment_openai_settings.xml b/plugins/AI-Agent-OpenAI/src/main/res/layout/fragment_openai_settings.xml
index 17dd2819..a6b7c7d8 100644
--- a/plugins/AI-Agent-OpenAI/src/main/res/layout/fragment_openai_settings.xml
+++ b/plugins/AI-Agent-OpenAI/src/main/res/layout/fragment_openai_settings.xml
@@ -156,7 +156,7 @@
+
+
+
#A3A3A3
#525252
- #1FFAFAFA
diff --git a/plugins/AI-Agent-OpenAI/src/main/res/values/colors.xml b/plugins/AI-Agent-OpenAI/src/main/res/values/colors.xml
index f81752fc..39b52582 100644
--- a/plugins/AI-Agent-OpenAI/src/main/res/values/colors.xml
+++ b/plugins/AI-Agent-OpenAI/src/main/res/values/colors.xml
@@ -38,5 +38,4 @@
#737373#A3A3A3
- #1F0A0A0A
diff --git a/plugins/AI-Agent-OpenAI/src/main/res/values/dimens.xml b/plugins/AI-Agent-OpenAI/src/main/res/values/dimens.xml
index 8279555f..b57850fe 100644
--- a/plugins/AI-Agent-OpenAI/src/main/res/values/dimens.xml
+++ b/plugins/AI-Agent-OpenAI/src/main/res/values/dimens.xml
@@ -11,6 +11,9 @@
16dp24dp
+ 1dp
+ 1dp
+
8dp11sp
diff --git a/plugins/AI-Agent-OpenAI/src/main/res/values/styles.xml b/plugins/AI-Agent-OpenAI/src/main/res/values/styles.xml
index 2284db4a..9b73d902 100644
--- a/plugins/AI-Agent-OpenAI/src/main/res/values/styles.xml
+++ b/plugins/AI-Agent-OpenAI/src/main/res/values/styles.xml
@@ -95,18 +95,33 @@
by choice, matching the IDE's own grey accent rather than this plugin's teal, on a screen
whose chrome belongs to AI Core. Light/dark comes from values-night/colors.xml.
- One style for every button on the pane: a secondary action that styled itself differently
- is exactly what broke, and the hierarchy is not worth a second way to get contrast wrong. -->
+ Filled for the one primary action in a section, outlined for the rest, per Material 3's
+ button hierarchy. Both pin direct colours, so neither resolves a role against the host.
+ The app: items here are dropped inside the host; ui/PaneStyling.kt applies them in code. -->
+
+
diff --git a/plugins/AI-Core/build.gradle.kts b/plugins/AI-Core/build.gradle.kts
index 44d9e757..1c27c327 100644
--- a/plugins/AI-Core/build.gradle.kts
+++ b/plugins/AI-Core/build.gradle.kts
@@ -72,7 +72,6 @@ dependencies {
implementation("androidx.fragment:fragment-ktx:1.8.8")
implementation("com.google.android.material:material:1.10.0")
implementation("androidx.recyclerview:recyclerview:1.3.2")
- implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.21")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
diff --git a/plugins/AI-Core/src/main/assets/docs/index.html b/plugins/AI-Core/src/main/assets/docs/index.html
index ca82ec5b..227be9d0 100644
--- a/plugins/AI-Core/src/main/assets/docs/index.html
+++ b/plugins/AI-Core/src/main/assets/docs/index.html
@@ -203,6 +203,17 @@
Your Gemini API key
app's data, for example — the stored key can no longer be read and will need to
be entered again; tapping Edit tells you when that has happened.
+
Writing a message
+
+
The prompt field grows as you type, up to about eight lines, and then scrolls: drag inside it to
+ read back over a long prompt without moving the panel it sits in.
+
With a hardware keyboard attached, Shift+Enter sends the message
+ and Enter starts a new line.
+
A message the agent cannot send — no backend configured, or no key saved
+ — is left in the field, so the warning never costs you what you typed. The
+ warning itself goes away on its own once the backend is configured.
+
+
Reading the conversation
Retry appears on a step that failed — including a tool call you
@@ -216,8 +227,11 @@
Reading the conversation
System log rows are collapsed records of the agent's internal
steps: tools run, files touched, backend errors. Tap the header to expand
one. They are saved with the session but are not sent to the model.
-
Long-press a message to copy its text, or to edit and re-send one of
- your own.
+
Your own messages longer than four lines are shown folded. Tap the
+ down arrow in the bubble's corner to read the whole message, and the up
+ arrow to fold it again. The model always receives all of it.
+
Tap the copy icon under a message, yours or the agent's, to copy its
+ text. It appears under a reply once the agent has finished writing it.
Keeping several chats
@@ -225,6 +239,12 @@
Keeping several chats
that project's chats and no one else's, and you can keep as many as you want —
one for a build failure, one for a refactor, one for the feature you are adding
— instead of letting a single conversation collect all three.
+
The header of the Agent tab names the chat you are in, shortened to one line;
+ long-press it for the same note as every other control on the screen.
+
After the agent's first reply in a chat, the backend you selected writes a short
+ title for it from your first message and that reply. Meanwhile the header reads
+ Naming chat…; if the backend cannot write one, the chat is named after the
+ first ten words of your first message.
The button at the top right of the Agent tab slides a sidebar in over
the conversation. Its three parts are fixed in place except the middle one, which
is the only part that scrolls:
@@ -232,7 +252,7 @@
Keeping several chats
New chat, at the top, starts a fresh conversation. The one you were
in is kept, and you can go back to it whenever you like.
Recent, in the middle, lists this project's conversations, newest
- first, each named after its first message and dated from when it began, with
+ first, each named with a short title and dated from when it began, with
a tick on the one you are in. It holds the newest to begin with and loads
more as you scroll, so a project with a long history still opens at once.
Tap a row to carry on with it: the agent reloads that conversation, so the
@@ -250,8 +270,8 @@
Keeping several chats
left of that row closes the sidebar, as does tapping the dimmed conversation
beside it or pressing Back.
The ⋮ button on a row in Recent renames or deletes that chat. A
- name you give it replaces the first-message title; clearing the name gives the
- first-message title back. Deleting cannot be undone — and deleting the last chat
+ name you give it replaces the automatic title; clearing the name gives the
+ automatic title back. Deleting cannot be undone — and deleting the last chat
leaves you an empty one to carry on in, rather than nothing.
To clear out several at once, long-press a row. Every row gets a
checkbox, tapping a row ticks it instead of opening it, and the bin at the top of
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/adapters/ChatAdapter.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/adapters/ChatAdapter.kt
index a26209c1..2a311873 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/adapters/ChatAdapter.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/adapters/ChatAdapter.kt
@@ -1,21 +1,18 @@
package com.itsaky.androidide.plugins.aicore.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.view.ViewTreeObserver
import android.widget.Button
+import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
-import android.widget.PopupMenu
import android.widget.ProgressBar
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
-import com.google.android.material.snackbar.Snackbar
import com.itsaky.androidide.plugins.aicore.R
import com.itsaky.androidide.plugins.aicore.logging.LOG_PREFIX
import com.itsaky.androidide.plugins.aicore.models.ChatMessage
@@ -34,10 +31,16 @@ private const val TAG = "$LOG_PREFIX.ChatAdapter"
* @param wireTooltip attaches this plugin's long-press help for a tag to a view. Supplied by
* ChatFragment, which owns the [com.itsaky.androidide.plugins.services.IdeTooltipService]
* lookup, so the adapter stays free of service plumbing. Defaults to a no-op for tests.
+ * @param isUserMessageExpanded whether a user bubble is unfolded; the state lives in ChatViewModel,
+ * so a fold outlives this adapter. Defaults to always folded for tests.
+ * @param toggleUserMessageExpanded unfolds or folds a user bubble, returning its new state.
+ * @param onMessageAction runs one of the `ACTION_*` constants for a message, in ChatFragment.
*/
class ChatAdapter(
private val markwon: Markwon,
private val wireTooltip: (View, String) -> Unit = { _, _ -> },
+ private val isUserMessageExpanded: (messageId: String) -> Boolean = { false },
+ private val toggleUserMessageExpanded: (messageId: String) -> Boolean = { false },
private val onMessageAction: (action: String, message: ChatMessage) -> Unit
) : ListAdapter(DiffCallback) {
@@ -49,9 +52,10 @@ class ChatAdapter(
companion object {
private const val VIEW_TYPE_DEFAULT = 0
private const val VIEW_TYPE_SYSTEM = 1
+ private const val VIEW_TYPE_USER = 2
- const val ACTION_EDIT = "edit"
const val ACTION_RETRY = "retry"
+ const val ACTION_COPY = "copy"
const val ACTION_OPEN_SETTINGS = "open_settings"
}
@@ -66,6 +70,10 @@ class ChatAdapter(
val generatingDots: TextView = view.findViewById(R.id.generating_dots)
val messageDuration: TextView = view.findViewById(R.id.message_duration)
val btnRetry: Button = view.findViewById(R.id.btn_retry)
+ /** Fold toggle; only the user bubble layout has one. */
+ val btnToggleExpand: ImageButton? = view.findViewById(R.id.btn_toggle_expand)
+ val messageActions: LinearLayout = view.findViewById(R.id.message_actions)
+ val btnCopyMessage: ImageButton = view.findViewById(R.id.btn_copy_message)
/**
* Queued next step of the "..." animation, or null when it isn't running. Retained so
@@ -94,6 +102,8 @@ class ChatAdapter(
VIEW_TYPE_DEFAULT
} else if (message.sender == Sender.SYSTEM) {
VIEW_TYPE_SYSTEM
+ } else if (message.sender == Sender.USER) {
+ VIEW_TYPE_USER
} else {
VIEW_TYPE_DEFAULT
}
@@ -108,9 +118,23 @@ class ChatAdapter(
val view = inflater.inflate(R.layout.list_item_chat_system_message, parent, false)
SystemMessageViewHolder(view)
}
+ // Own view type, so a recycled row never carries the bubble over to an agent message.
+ VIEW_TYPE_USER -> {
+ val view = inflater.inflate(R.layout.list_item_chat_user_message, parent, false)
+ DefaultMessageViewHolder(view).also(::wireMessageActions).apply {
+ // The bubble is the sender cue, so the label would only repeat it.
+ messageSender.visibility = View.GONE
+ // A match_parent child can't widen a wrap_content bubble; it stays one word wide.
+ messageContent.layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT
+ // Line count is only known once the text is laid out at its final width.
+ messageContent.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
+ updateExpandToggleVisibility(this)
+ }
+ }
+ }
else -> {
val view = inflater.inflate(R.layout.list_item_chat_message, parent, false)
- DefaultMessageViewHolder(view)
+ DefaultMessageViewHolder(view).also(::wireMessageActions)
}
}
}
@@ -164,6 +188,7 @@ class ChatAdapter(
holder.messageContent.text = payload.text
}
}
+ updateMessageActions(holder, message)
} else if (payload is TextUpdatePayload && holder is SystemMessageViewHolder) {
markwon.setMarkdown(holder.messageContent, payload.text)
updateSystemMessageExpansion(holder, getItem(position))
@@ -178,12 +203,8 @@ class ChatAdapter(
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
- }
+ updateMessageActions(holder, message)
+ holder.btnToggleExpand?.let { bindExpandToggle(holder, it, message) }
when (message.status) {
MessageStatus.LOADING -> {
@@ -242,6 +263,88 @@ class ChatAdapter(
}
}
+ /** Wired once per holder: a streamed reply grows via payloads, so the tap reads the current item. */
+ private fun wireMessageActions(holder: DefaultMessageViewHolder) {
+ holder.btnCopyMessage.setOnClickListener {
+ val pos = holder.bindingAdapterPosition
+ if (pos != RecyclerView.NO_POSITION) onMessageAction(ACTION_COPY, getItem(pos))
+ }
+ wireTooltip(holder.btnCopyMessage, AiCorePlugin.TOOLTIP_TAG_MESSAGE_COPY)
+ }
+
+ /**
+ * Shows the actions under user and agent messages once their text is final: not while loading,
+ * nor while a reply is still streaming. System error and tool-activity rows share this layout
+ * but get none.
+ */
+ private fun updateMessageActions(holder: DefaultMessageViewHolder, message: ChatMessage) {
+ val streaming = message.sender == Sender.AGENT &&
+ message.status == MessageStatus.SENT && message.durationMs == null
+ val copyable = message.sender == Sender.USER || message.sender == Sender.AGENT
+ val show = copyable && message.status != MessageStatus.LOADING && !streaming
+ holder.messageActions.visibility = if (show) View.VISIBLE else View.GONE
+ }
+
+ private fun bindExpandToggle(holder: DefaultMessageViewHolder, toggle: ImageButton, message: ChatMessage) {
+ applyUserMessageExpansion(holder, toggle, isUserMessageExpanded(message.id))
+ toggle.setOnClickListener {
+ val expanded = toggleUserMessageExpanded(message.id)
+ val rowTop = holder.itemView.top
+ applyUserMessageExpansion(holder, toggle, expanded)
+ keepRowTopInPlace(holder.itemView, rowTop)
+ }
+ wireTooltip(toggle, AiCorePlugin.TOOLTIP_TAG_USER_MESSAGE_EXPAND)
+ }
+
+ /**
+ * Folds the bubble to `R.integer.user_message_collapsed_lines` or unfolds it, and turns the arrow and its
+ * spoken label to match: down/"show the whole message" while folded, up/"show less" once open.
+ */
+ private fun applyUserMessageExpansion(holder: DefaultMessageViewHolder, toggle: ImageButton, expanded: Boolean) {
+ holder.messageContent.maxLines = if (expanded) Int.MAX_VALUE else collapsedLines(holder)
+ toggle.setImageResource(if (expanded) R.drawable.ic_expand_less else R.drawable.ic_expand_more)
+ toggle.contentDescription = toggle.context.getString(
+ if (expanded) R.string.desc_collapse_user_message else R.string.desc_expand_user_message
+ )
+ }
+
+ private fun collapsedLines(holder: DefaultMessageViewHolder): Int =
+ holder.messageContent.resources.getInteger(R.integer.user_message_collapsed_lines)
+
+ /**
+ * Scrolls [row] back to [rowTop] after its next layout, before that frame draws. The list stacks
+ * from the end, so a row that changes height moves its top: unfolding would push the start of
+ * the message off screen.
+ */
+ private fun keepRowTopInPlace(row: View, rowTop: Int) {
+ val list = row.parent as? RecyclerView ?: return
+ list.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
+ override fun onPreDraw(): Boolean {
+ list.viewTreeObserver.removeOnPreDrawListener(this)
+ if (row.parent !== list) return true
+ val drift = row.top - rowTop
+ if (drift == 0) return true
+ list.scrollBy(0, drift)
+ // Skip this frame: it was laid out before the correction.
+ return false
+ }
+ })
+ }
+
+ /**
+ * Shows the toggle only when the text runs past the fold. lineCount counts every line even while
+ * maxLines hides some (no ellipsize is set), so this holds folded or not. Posted, since it runs
+ * mid-layout and a visibility change there would be deferred with a warning anyway.
+ */
+ private fun updateExpandToggleVisibility(holder: DefaultMessageViewHolder) {
+ val toggle = holder.btnToggleExpand ?: return
+ toggle.post {
+ val overflows = holder.messageContent.lineCount > collapsedLines(holder)
+ val visibility = if (overflows) View.VISIBLE else View.GONE
+ if (toggle.visibility != visibility) toggle.visibility = visibility
+ }
+ }
+
private fun bindSystemMessage(holder: SystemMessageViewHolder, message: ChatMessage) {
markwon.setMarkdown(holder.messageContent, message.text)
updateSystemMessageExpansion(holder, message)
@@ -324,7 +427,11 @@ class ChatAdapter(
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
super.onViewRecycled(holder)
- if (holder is DefaultMessageViewHolder) hideGeneratingDots(holder)
+ if (holder is DefaultMessageViewHolder) {
+ hideGeneratingDots(holder)
+ // Here, not in bind: a rebind of the same row would flash its toggle off for a frame.
+ holder.btnToggleExpand?.visibility = View.GONE
+ }
}
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
@@ -387,34 +494,6 @@ class ChatAdapter(
}
}
- 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)
- Snackbar.make(view, view.context.getString(R.string.msg_copied), Snackbar.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()
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt
index 7cc10751..eb8875c0 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt
@@ -1,5 +1,7 @@
package com.itsaky.androidide.plugins.aicore.fragments
+import android.content.ClipData
+import android.content.ClipboardManager
import android.content.Context
import android.content.res.Configuration
import android.graphics.Rect
@@ -33,6 +35,9 @@ import com.itsaky.androidide.plugins.aicore.models.AgentState
import com.itsaky.androidide.plugins.aicore.models.isRunning
import com.itsaky.androidide.plugins.aicore.models.traceLabel
import com.itsaky.androidide.plugins.aicore.plugin.AiCorePlugin
+import com.itsaky.androidide.plugins.aicore.shortcuts.ChatShortcuts
+import com.itsaky.androidide.plugins.aicore.shortcuts.bindShortcuts
+import com.itsaky.androidide.plugins.aicore.shortcuts.runs
import com.itsaky.androidide.plugins.aicore.viewmodel.ChatViewModel
import com.itsaky.androidide.plugins.aicore.viewmodel.ChatViewModelStore
import com.itsaky.androidide.plugins.base.PluginFragmentHelper
@@ -42,10 +47,19 @@ import com.itsaky.androidide.plugins.services.IdeUIService
import io.noties.markwon.Markwon
import java.io.File
import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
private const val TAG = "$LOG_PREFIX.ChatFragment"
+/** Runs of whitespace, including the line breaks a pasted prompt carries. */
+private val WHITESPACE = Regex("\\s+")
+
+/** One line, so a title taken from a multi-line prompt ellipsizes instead of stopping at a break. */
+private fun String.oneLine(): String = trim().replace(WHITESPACE, " ")
+
/**
* ChatFragment for Agent chat UI.
* Provides a full chat interface with LLM integration.
@@ -179,6 +193,7 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
initializeMarkwon()
initializeViewModel()
syncStorageToCurrentProject()
+ setupChatTitle()
setupSidebar()
setupRecyclerView()
setupInputArea()
@@ -304,9 +319,13 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
private fun setupRecyclerView() {
// Item views inflate from parent.context, so no Context needs passing in.
- chatAdapter = ChatAdapter(markwon, ::wireTooltip) { action, message ->
- onMessageAction(action, message)
- }
+ chatAdapter = ChatAdapter(
+ markwon = markwon,
+ wireTooltip = ::wireTooltip,
+ isUserMessageExpanded = viewModel::isUserMessageExpanded,
+ toggleUserMessageExpanded = viewModel::toggleUserMessageExpanded,
+ onMessageAction = ::onMessageAction,
+ )
binding.chatRecyclerView.apply {
adapter = chatAdapter
layoutManager = LinearLayoutManager(requireContext()).apply {
@@ -315,6 +334,46 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
}
}
+ /**
+ * Keeps the toolbar showing the conversation that is on screen, retitling it as the user's
+ * first message arrives or the chat is renamed. A chat that is neither named nor started reads
+ * as the sidebar calls it, rather than leaving the header blank.
+ */
+ private fun setupChatTitle() {
+ wireTooltip(binding.chatTitle, AiCorePlugin.TOOLTIP_TAG_CHAT_TITLE)
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ // The current session is republished on every streamed token, and its title is the
+ // same string in nearly all of them. Mapped and de-duplicated first, so a run costs
+ // one String comparison a token instead of a collapse and a toolbar layout pass.
+ combine(
+ viewModel.currentSession.map { it?.id to it?.displayTitle }.distinctUntilChanged(),
+ viewModel.titlePending,
+ ) { (id, title), pending -> (id != null && id in pending) to title }
+ .distinctUntilChanged()
+ .collect { (naming, title) -> showChatTitle(naming, title) }
+ }
+ }
+ }
+
+ /**
+ * @param naming whether the backend is still writing this chat's title: a spinner and a muted
+ * placeholder stand in, so the raw prompt never flashes up before the real title.
+ * @param title the chat's title, or null for one neither named nor started.
+ */
+ private fun showChatTitle(naming: Boolean, title: String?) {
+ val binding = _binding ?: return
+ binding.chatTitleProgress.isVisible = naming
+ val shown = title?.oneLine()?.takeIf { it.isNotEmpty() }
+ binding.chatTitle.text = when {
+ naming -> getString(R.string.session_title_generating)
+ else -> shown ?: getString(R.string.session_untitled)
+ }
+ // The view's own Context: it carries the plugin's resources and the IDE's day/night mode.
+ val color = if (naming) R.color.plugin_on_surface_variant else R.color.plugin_on_surface
+ binding.chatTitle.setTextColor(binding.chatTitle.context.getColor(color))
+ }
+
/**
* Stands the sidebar up: the panel that replaced the toolbar overflow menu and the chat-history
* dialog behind it. Everything it does lives in [ChatSidebarController]; this only hands it
@@ -358,15 +417,18 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
if (viewModel.agentState.value.isRunning) {
viewModel.stopProcessing(reason = "stop button")
} else {
- val message = binding.promptInputEdittext.text?.toString() ?: return@setOnClickListener
- if (message.isNotBlank()) {
- composer?.hideKeyboard()
- viewModel.sendMessage(message)
- binding.promptInputEdittext.text?.clear()
- }
+ sendPrompt()
}
}
+ // The field scrolls its own long prompts, and answers the chat's keyboard chords.
+ binding.promptInputEdittext.keepVerticalDragsToItself()
+ binding.promptInputEdittext.growUpTo(
+ resources.getInteger(R.integer.chat_input_max_lines),
+ binding.root,
+ )
+ binding.promptInputEdittext.bindShortcuts(ChatShortcuts.SEND_MESSAGE runs ::sendPrompt)
+
binding.btnAddContext.setOnClickListener {
showFilePicker()
}
@@ -378,6 +440,21 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
wireTooltip(binding.backendStatusText, AiCorePlugin.TOOLTIP_TAG_SETTINGS_BACKEND)
}
+ /**
+ * Sends what is in the composer, from the button or from [ChatShortcuts.SEND_MESSAGE].
+ *
+ * A refused send — no backend configured, no key saved — leaves the composer exactly as it
+ * was, text and keyboard both: it used to wipe the prompt the user had just typed, and putting
+ * the keyboard away as well would take the caret off a prompt they still have to re-send.
+ */
+ private fun sendPrompt() {
+ val message = binding.promptInputEdittext.text?.toString() ?: return
+ if (message.isBlank()) return
+ if (!viewModel.sendMessage(message)) return
+ composer?.hideKeyboard()
+ binding.promptInputEdittext.text?.clear()
+ }
+
/**
* Records the message list's own padding so the cutout inset is added to it rather than
* replacing it, and stays correct however many inset passes the window makes.
@@ -653,11 +730,6 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
private fun onMessageAction(action: String, message: com.itsaky.androidide.plugins.aicore.models.ChatMessage) {
when (action) {
- ChatAdapter.ACTION_EDIT -> {
- // Show dialog to edit message
- binding.promptInputEdittext.setText(message.text)
- binding.promptInputEdittext.requestFocus()
- }
ChatAdapter.ACTION_RETRY -> {
// The prompt behind this row, not its text: the row may be a tool failure.
viewModel.retryLastRun()
@@ -666,9 +738,17 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
// Open settings fragment
openSettingsFragment()
}
+ ChatAdapter.ACTION_COPY -> copyToClipboard(message.text)
}
}
+ private fun copyToClipboard(text: String) {
+ val binding = _binding ?: return
+ val clipboard = requireContext().getSystemService(ClipboardManager::class.java)
+ clipboard.setPrimaryClip(ClipData.newPlainText("chat_message", text))
+ Snackbar.make(binding.root, getString(R.string.msg_copied), Snackbar.LENGTH_SHORT).show()
+ }
+
/**
* Open the Agent settings screen — the same one Preferences → Configuration → Agent opens, so
* there is one implementation of it. The host mounts it full-screen in PluginScreenActivity;
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatSidebarController.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatSidebarController.kt
index 7734d968..e47ab82c 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatSidebarController.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatSidebarController.kt
@@ -325,6 +325,7 @@ internal class ChatSidebarController(
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeSessions() {
val untitled = _binding?.root?.context?.getString(R.string.session_untitled) ?: return
+ val naming = _binding?.root?.context?.getString(R.string.session_title_generating) ?: return
scope.launch {
// Nothing is collected while the panel is hidden: the sessions are rewritten once per
// streamed token, and re-sorting, re-paging and diffing a list nobody can see is the
@@ -335,8 +336,9 @@ internal class ChatSidebarController(
viewModel.currentSessionId,
selection,
visibleCount,
- ) { sessions, currentId, picked, limit ->
- ChatSessionRows.from(sessions, currentId, untitled, picked, limit)
+ viewModel.titlePending,
+ ) { sessions, currentId, picked, limit, pending ->
+ ChatSessionRows.from(sessions, currentId, untitled, picked, limit, pending, naming)
}
}.collect { rows ->
sessionAdapter.submitList(rows)
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/PromptHeightCap.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/PromptHeightCap.kt
new file mode 100644
index 00000000..63c9c3a9
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/PromptHeightCap.kt
@@ -0,0 +1,28 @@
+package com.itsaky.androidide.plugins.aicore.fragments
+
+import android.view.View
+import android.widget.EditText
+
+/** Most of [container]'s height a grown prompt may take; the rest stays with the transcript. */
+private const val MAX_CONTAINER_FRACTION = 0.4f
+
+/** Never shrink below this, however cramped the sheet: a one-line field cannot show a newline. */
+private const val MIN_LINES = 2
+
+/**
+ * Lets the prompt grow with what is typed up to [maxLines], but never past a share of [container].
+ *
+ * The field wraps its content, so on its own it grows to [maxLines]. In the host's bottom sheet,
+ * in landscape or behind the keyboard, eight lines alone can outgrow the space the chat column
+ * has, and the weighted transcript is then measured at zero (ADFA-3070). This trims the line cap
+ * whenever [container] is resized, so the prompt scrolls instead.
+ */
+internal fun EditText.growUpTo(maxLines: Int, container: View) {
+ container.addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
+ val budget = (bottom - top) * MAX_CONTAINER_FRACTION - paddingTop - paddingBottom
+ if (budget <= 0 || lineHeight <= 0) return@addOnLayoutChangeListener
+ val lines = (budget / lineHeight).toInt().coerceIn(MIN_LINES, maxLines)
+ // Posted: setMaxLines requests a layout, which must not happen inside this one.
+ if (lines != this.maxLines) post { this.maxLines = lines }
+ }
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/PromptScrollGuard.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/PromptScrollGuard.kt
new file mode 100644
index 00000000..51c97c02
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/PromptScrollGuard.kt
@@ -0,0 +1,34 @@
+package com.itsaky.androidide.plugins.aicore.fragments
+
+import android.annotation.SuppressLint
+import android.view.MotionEvent
+import android.widget.EditText
+
+/**
+ * Keeps a vertical drag inside the prompt field instead of handing it to whatever the chat is
+ * mounted in.
+ *
+ * The Agent chat sits in the host's bottom sheet, which claims vertical swipes to drag the sheet,
+ * so a prompt taller than the field could not be scrolled at all. Asking the ancestors not to
+ * intercept for the length of the gesture leaves the field its own scrolling and the sheet every
+ * drag that starts anywhere else.
+ */
+@SuppressLint("ClickableViewAccessibility")
+internal fun EditText.keepVerticalDragsToItself() {
+ setOnTouchListener { view, event ->
+ when (event.actionMasked) {
+ // Only while there is something to scroll to, or the sheet loses drags for nothing.
+ MotionEvent.ACTION_DOWN -> {
+ if (view.canScrollVertically(1) || view.canScrollVertically(-1)) {
+ view.parent?.requestDisallowInterceptTouchEvent(true)
+ }
+ }
+
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
+ view.parent?.requestDisallowInterceptTouchEvent(false)
+ }
+ }
+ // Never consumed: the caret, the selection and the paste menu stay the field's own business.
+ false
+ }
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatMessage.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatMessage.kt
index b65fb7ca..aa7a30ff 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatMessage.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatMessage.kt
@@ -45,5 +45,11 @@ data class ChatMessage(
* bubble, e.g. a localized "the action failed"). Null on a message stored before this
* existed, and on every turn the two agree on.
*/
- val historyText: String? = null
+ val historyText: String? = null,
+ /**
+ * Whether this row only reports that the backend is not configured yet. The chat drops those
+ * once the backend answers as ready, so a key saved afterwards leaves no stranded warning
+ * behind. False for every message stored before this field existed.
+ */
+ val isSetupError: Boolean = false
)
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatSession.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatSession.kt
index 55cd084a..c9f59ff3 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatSession.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatSession.kt
@@ -19,27 +19,50 @@ import java.util.UUID
* @property name the title the user gave this chat, or null while they have not renamed it.
* Nullable for the same reason as [projectKey]: a session stored before this field existed
* deserializes as null whatever default is declared here, and null is exactly "never renamed".
+ * @property generatedTitle the title the selected backend wrote after the first reply, or null
+ * until then (and for sessions stored before this field, for the same Gson reason). Never
+ * overrides [name]; see ChatViewModel's title generation.
*/
data class ChatSession(
val id: String = UUID.randomUUID().toString(),
val createdAt: Long = System.currentTimeMillis(),
val messages: List = emptyList(),
val projectKey: String? = null,
- val name: String? = null
+ val name: String? = null,
+ val generatedTitle: String? = null
) {
/**
- * What names this chat in the session list: the [name] the user gave it, otherwise its first
- * user turn. Null for a chat that is neither renamed nor started — the UI labels that one from
- * strings.xml, which this model has no Context to reach.
+ * What names this chat in the session list: the [name] the user gave it, otherwise the
+ * [generatedTitle], otherwise its first user turn cut to [FALLBACK_TITLE_WORDS] words. Null for
+ * a chat neither renamed nor started — the UI labels that one from strings.xml, out of reach here.
*/
val displayTitle: String?
get() = name?.takeIf { it.isNotBlank() }
- ?: messages.firstOrNull { it.sender == Sender.USER }?.text
+ ?: generatedTitle?.takeIf { it.isNotBlank() }
+ ?: messages.firstOrNull { it.sender == Sender.USER }?.text?.let(::firstWords)
val formattedDate: String
get() = DATE_FORMATTER.format(Instant.ofEpochMilli(createdAt))
private companion object {
+ /** Longest prompt excerpt that stands in for a title the backend did not write. */
+ const val FALLBACK_TITLE_WORDS = 10
+
+ private val WHITESPACE = Regex("\\s+")
+
+ /**
+ * [text] on one line, cut to [FALLBACK_TITLE_WORDS] words with an ellipsis when it was longer.
+ * Split lazily: this runs per row on every streamed token, and a pasted prompt can be huge.
+ */
+ fun firstWords(text: String): String {
+ val words = WHITESPACE.splitToSequence(text)
+ .filter { it.isNotEmpty() }
+ .take(FALLBACK_TITLE_WORDS + 1)
+ .toList()
+ if (words.size <= FALLBACK_TITLE_WORDS) return words.joinToString(" ")
+ return words.take(FALLBACK_TITLE_WORDS).joinToString(" ") + "…"
+ }
+
/**
* Immutable and thread-safe, unlike SimpleDateFormat, so one instance serves every row
* instead of one being built per row — the session list re-renders on every streamed
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/SessionRow.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/SessionRow.kt
index cc6c3472..0d5caa07 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/SessionRow.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/SessionRow.kt
@@ -83,6 +83,9 @@ object ChatSessionRows {
* @param selection what the list is doing and which rows are ticked.
* @param limit how many rows to build, applied *after* the ordering so growing it appends to
* what is already on screen rather than reshuffling it. See [SessionPaging].
+ * @param titlePending sessions whose title is still being written; they show [pendingTitle]
+ * and count as untitled, so a rename started meanwhile does not keep the placeholder.
+ * @param pendingTitle the loading label, resolved from strings.xml by the caller.
* @return one row per session in [newestFirst] order — a conversation started today is the one
* the user came to the list to find, and the order must not depend on which row was written
* last.
@@ -93,14 +96,17 @@ object ChatSessionRows {
untitledTitle: String,
selection: SessionSelection = SessionSelection.BROWSING,
limit: Int = Int.MAX_VALUE,
+ titlePending: Set = emptySet(),
+ pendingTitle: String = untitledTitle,
): List = sessions
.newestFirst()
.take(limit.coerceAtLeast(0))
.map { session ->
+ val pending = session.id in titlePending
SessionRow(
id = session.id,
- title = session.displayTitle ?: untitledTitle,
- isUntitled = session.displayTitle == null,
+ title = if (pending) pendingTitle else session.displayTitle ?: untitledTitle,
+ isUntitled = pending || session.displayTitle == null,
date = session.formattedDate,
messageCount = session.messages.size,
isActive = session.id == currentSessionId,
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt
index 347a9d6a..a2d1a4bf 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt
@@ -44,6 +44,8 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten
const val TOOLTIP_TAG_TAB = "agent_chat_tab"
// Tags for the interactive controls on the Agent chat screen (see ChatFragment).
+ /** The toolbar title, which names the conversation that is on screen. */
+ const val TOOLTIP_TAG_CHAT_TITLE = "agent_chat_title"
const val TOOLTIP_TAG_CONTEXT_FILES = "agent_context_files"
const val TOOLTIP_TAG_CHAT_INPUT = "agent_chat_input"
const val TOOLTIP_TAG_CHAT_SEND = "agent_chat_send"
@@ -68,6 +70,8 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten
const val TOOLTIP_TAG_MESSAGE_RETRY = "agent_message_retry"
const val TOOLTIP_TAG_MESSAGE_OPEN_SETTINGS = "agent_message_open_settings"
const val TOOLTIP_TAG_SYSTEM_LOG = "agent_system_log"
+ const val TOOLTIP_TAG_USER_MESSAGE_EXPAND = "agent_user_message_expand"
+ const val TOOLTIP_TAG_MESSAGE_COPY = "agent_message_copy"
// Tags for the interactive controls on the AI Settings screen (see AiSettingsFragment).
const val TOOLTIP_TAG_SETTINGS_BACK = "ai_settings_back"
@@ -259,6 +263,21 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten
)
)
),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_CHAT_TITLE,
+ summary = "The chat you are in — titled automatically after its first reply until you rename it.",
+ detail = """
+
The header names the conversation on screen, so switching
+ chats from the sidebar says which one you landed in.
+
After the first reply in a new chat, the AI backend writes a
+ short title for it; "Naming chat…" shows while it does.
+ Rename on its row in the sidebar gives it a name of your
+ own, and emptying that name goes back to the automatic title.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0)
+ )
+ ),
PluginTooltipEntry(
tag = TOOLTIP_TAG_CONTEXT_FILES,
summary = "Attach project files so the agent sees their contents with your next message.",
@@ -289,6 +308,9 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten
Anything that writes to the project asks for your approval
first, as does starting a Gradle sync or generating from a
template.
+
The field scrolls on its own once the prompt outgrows it, and
+ with a hardware keyboard attached Shift+Enter sends while
+ Enter starts a new line.
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0)
@@ -557,6 +579,32 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten
PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0)
)
),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_USER_MESSAGE_EXPAND,
+ summary = "Show the rest of your message, or fold it back to four lines.",
+ detail = """
+
A message of yours longer than four lines is shown folded, so a
+ long prompt doesn't push the agent's reply off screen.
+
The arrow points down while folded: tap it to show the whole
+ message. It points up once expanded: tap it again to fold it. The
+ whole message is always sent to the model, folded or not.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_MESSAGE_COPY,
+ summary = "Copy this message's text to the clipboard.",
+ detail = """
+
Copies the whole message, including any lines folded out of
+ view, so you can paste it into a file or send it again.
+
More actions for a message will appear beside this one.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0)
+ )
+ ),
PluginTooltipEntry(
tag = TOOLTIP_TAG_SETTINGS_BACK,
summary = "Close the Agent settings and go back where you came from.",
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/ChatShortcuts.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/ChatShortcuts.kt
new file mode 100644
index 00000000..97aee341
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/ChatShortcuts.kt
@@ -0,0 +1,19 @@
+package com.itsaky.androidide.plugins.aicore.shortcuts
+
+import android.view.KeyEvent
+
+/**
+ * Every hardware-keyboard chord the Agent chat answers to, in one place: a new shortcut is a
+ * constant here plus a [runs] binding wherever the view is wired, and nothing else changes.
+ *
+ * Keep the chords in step with the in-app help — the chat input's tooltip and the Tier-3 guide
+ * both name them, and a shortcut nothing documents is one nobody finds.
+ */
+internal object ChatShortcuts {
+
+ /**
+ * Sends the prompt. Shift+Enter rather than Enter: the field is multi-line, so plain Enter has
+ * to stay the way a new line is added.
+ */
+ val SEND_MESSAGE = KeyboardShortcut(KeyEvent.KEYCODE_ENTER, KeyEvent.META_SHIFT_ON)
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/KeyboardShortcut.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/KeyboardShortcut.kt
new file mode 100644
index 00000000..6dedfe76
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/KeyboardShortcut.kt
@@ -0,0 +1,38 @@
+package com.itsaky.androidide.plugins.aicore.shortcuts
+
+import android.view.KeyEvent
+
+/**
+ * One hardware-keyboard chord: a key, and exactly the modifiers that must be held with it.
+ *
+ * @property keyCode the `KeyEvent.KEYCODE_*` this chord ends on.
+ * @property modifiers the `KeyEvent.META_*_ON` bits that must be held, or 0 for the bare key.
+ */
+internal data class KeyboardShortcut(
+ val keyCode: Int,
+ val modifiers: Int = 0,
+) {
+
+ /**
+ * Whether a key event is this chord.
+ *
+ * Exact on the modifiers, so Shift+Enter and Ctrl+Shift+Enter stay two different chords and a
+ * shortcut can never fire for a chord it does not describe. Lock keys are outside the mask:
+ * Caps Lock changes what a key types, not which chord it is.
+ *
+ * @param keyCode the event's key code.
+ * @param metaState the event's whole meta state, lock keys and left/right bits included.
+ * @return true when the chord matches and the action behind it should run.
+ */
+ fun matches(keyCode: Int, metaState: Int): Boolean =
+ keyCode == this.keyCode && (metaState and MODIFIER_MASK) == modifiers
+
+ private companion object {
+ /** The modifiers a chord may ask for; anything else in the meta state is ignored. */
+ const val MODIFIER_MASK =
+ KeyEvent.META_SHIFT_ON or
+ KeyEvent.META_CTRL_ON or
+ KeyEvent.META_ALT_ON or
+ KeyEvent.META_META_ON
+ }
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/KeyboardShortcutHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/KeyboardShortcutHandler.kt
new file mode 100644
index 00000000..c9f76b7b
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/KeyboardShortcutHandler.kt
@@ -0,0 +1,50 @@
+package com.itsaky.androidide.plugins.aicore.shortcuts
+
+import android.view.KeyEvent
+
+/**
+ * A chord and what it does.
+ *
+ * @property shortcut the chord to listen for.
+ * @property onTriggered run once per press, never per repeat.
+ */
+internal data class ShortcutBinding(
+ val shortcut: KeyboardShortcut,
+ val onTriggered: () -> Unit,
+)
+
+/** Reads as `SEND_MESSAGE runs ::sendPrompt` at the call site. */
+internal infix fun KeyboardShortcut.runs(action: () -> Unit) = ShortcutBinding(this, action)
+
+/**
+ * Decides which of [bindings] a key event belongs to, and runs it.
+ *
+ * Free of `KeyEvent` instances on purpose — it takes the four fields it reads — so the matching and
+ * the swallowing rules are unit-testable without a view or an Android runtime.
+ *
+ * @param bindings the chords this handler owns, in priority order.
+ */
+internal class KeyboardShortcutHandler(private val bindings: List) {
+
+ /**
+ * Handles one key event.
+ *
+ * Every event of a press this handler owns is swallowed, not only the one that ran the action:
+ * letting the rest through has the field act on the key as well, which is how a send shortcut
+ * still left a newline behind.
+ *
+ * @param keyCode the event's key code.
+ * @param keyAction the event's `KeyEvent.ACTION_*`.
+ * @param metaState the event's meta state.
+ * @param repeatCount the event's repeat count; a held key repeats and must not re-send.
+ * @return true when the event was consumed, which is what the key listener reports.
+ */
+ fun handle(keyCode: Int, keyAction: Int, metaState: Int, repeatCount: Int): Boolean {
+ val binding = bindings.firstOrNull { it.shortcut.matches(keyCode, metaState) }
+ ?: return false
+ if (keyAction == KeyEvent.ACTION_DOWN && repeatCount == 0) {
+ binding.onTriggered()
+ }
+ return true
+ }
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/ViewShortcuts.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/ViewShortcuts.kt
new file mode 100644
index 00000000..056a2763
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/ViewShortcuts.kt
@@ -0,0 +1,23 @@
+package com.itsaky.androidide.plugins.aicore.shortcuts
+
+import android.view.KeyEvent
+import android.view.View
+
+/**
+ * Hands this view's hardware-keyboard chords to a [KeyboardShortcutHandler].
+ *
+ * The only place in the feature that touches a view or a `KeyEvent` instance: everything about
+ * which chord is which, and what it runs, is decided by the values passed in.
+ *
+ * Replaces any key listener the view already had, so one call carries every chord it is to answer.
+ *
+ * @param bindings the chords, in priority order; see [runs].
+ */
+internal fun View.bindShortcuts(vararg bindings: ShortcutBinding) {
+ val handler = KeyboardShortcutHandler(bindings.toList())
+ setOnKeyListener { _, keyCode, event ->
+ // Normalized: a keyboard that reports only META_CTRL_LEFT_ON still counts as Ctrl.
+ val metaState = KeyEvent.normalizeMetaState(event.metaState)
+ handler.handle(keyCode, event.action, metaState, event.repeatCount)
+ }
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatTitle.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatTitle.kt
new file mode 100644
index 00000000..b5331b14
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatTitle.kt
@@ -0,0 +1,77 @@
+package com.itsaky.androidide.plugins.aicore.viewmodel
+
+/**
+ * Builds the request that asks the selected backend to name a chat, and cleans what comes back.
+ *
+ * Kept free of Android and of the service, so the parts that decide what the user sees as a title
+ * are unit-testable. The prompt is model-facing, so it stays here rather than in strings.xml.
+ */
+internal object ChatTitle {
+
+ /**
+ * Cloud models that reason before answering count that reasoning against this cap, so it is
+ * sized for them; a local model stops at end-of-turn long before reaching it.
+ */
+ const val MAX_TOKENS = 512
+
+ const val TEMPERATURE = 0.2f
+
+ /** A title the backend has not produced by then is abandoned; the chat keeps its fallback. */
+ const val TIMEOUT_MS = 30_000L
+
+ /** Longest excerpt of each side of the exchange sent to the model; the gist is at the start. */
+ const val EXCERPT_CHARS = 1_000
+
+ const val MAX_WORDS = 8
+
+ /** One toolbar line; longer still ellipsizes there, but the sidebar row wraps nothing. */
+ const val MAX_CHARS = 60
+
+ const val SYSTEM_PROMPT =
+ "You name chat conversations between a developer and a coding assistant. " +
+ "Reply with a short title of 2 to 6 words that says what the developer wants, " +
+ "in the developer's language. Plain text only: no quotes, no markdown, " +
+ "no trailing punctuation, no explanation."
+
+ private val THINKING = Regex("(?s).*?")
+ private val LABEL = Regex("^(?i)title\\s*:\\s*")
+ private val EDGE_MARKS = Regex("^[\\s#*_`\"'“”‘’>-]+|[\\s*_`\"'“”‘’.,;:!-]+$")
+ private val WHITESPACE = Regex("\\s+")
+
+ /**
+ * @param userText the conversation's first user message.
+ * @param replyText the agent's reply to it.
+ * @return the prompt, ending on `Title:` so a completion-style model answers with just that.
+ */
+ fun prompt(userText: String, replyText: String): String = buildString {
+ append("Conversation:\n")
+ append("Developer: ").append(userText.trim().take(EXCERPT_CHARS)).append('\n')
+ append("Assistant: ").append(replyText.trim().take(EXCERPT_CHARS)).append("\n\n")
+ append("Title:")
+ }
+
+ /**
+ * Reduces a model's answer to a title: its first non-empty line, without reasoning blocks, a
+ * `Title:` label, quotes, markdown or trailing punctuation, capped at [MAX_WORDS] and [MAX_CHARS].
+ *
+ * @param raw the backend's reply text.
+ * @return the title, or null when nothing usable is left.
+ */
+ fun sanitize(raw: String): String? {
+ val line = raw.replace(THINKING, "")
+ .lineSequence()
+ .map { it.trim() }
+ .firstOrNull { it.isNotEmpty() }
+ ?: return null
+ val cleaned = line.stripEdgeMarks()
+ .replace(LABEL, "")
+ .stripEdgeMarks()
+ .replace(WHITESPACE, " ")
+ if (cleaned.isEmpty()) return null
+ val words = cleaned.split(' ')
+ val capped = if (words.size > MAX_WORDS) words.take(MAX_WORDS).joinToString(" ") else cleaned
+ return capped.take(MAX_CHARS).trimEnd().ifEmpty { null }
+ }
+
+ private fun String.stripEdgeMarks(): String = replace(EDGE_MARKS, "")
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt
index 3f6633f8..4c3bc4b7 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt
@@ -46,6 +46,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
@@ -58,11 +59,14 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.future.await
import kotlinx.coroutines.isActive
+import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONObject
private const val TAG = "$LOG_PREFIX.ChatViewModel"
@@ -169,6 +173,14 @@ class ChatViewModel(
private val _history = MutableStateFlow>(emptyList())
val history: StateFlow> = _history.asStateFlow()
+ private val _titlePending = MutableStateFlow>(emptySet())
+
+ /**
+ * Sessions whose title is being written: from their first message until the backend's title
+ * lands or the attempt ends. The UI shows a loading title for these instead of the raw prompt.
+ */
+ val titlePending: StateFlow> = _titlePending.asStateFlow()
+
val currentSession: StateFlow = combine(_sessions, _currentSessionId) { sessions, id ->
sessions.firstOrNull { it.id == id }
}.stateIn(viewModelScope, SharingStarted.Lazily, null)
@@ -235,6 +247,32 @@ class ChatViewModel(
/** The in-flight agent run (streaming + tool loop), so it can be cancelled. */
private var generationJob: Job? = null
+ /**
+ * The in-flight chat title request. The next run waits for it before generating: backends keep
+ * per-request state process-wide (Gemini's current job, the local model's token cap).
+ */
+ @Volatile
+ private var titleRequest: TitleRequest? = null
+
+ /** A title being written, and the session it is for. */
+ private class TitleRequest(val sessionId: String, val job: Job)
+
+ /**
+ * Whether a chat model turn is generating. The service's cancel is global, so Stop and Clear
+ * Chat call it only then; otherwise it would cancel a title request, or another plugin's call.
+ */
+ @Volatile
+ private var modelTurnInFlight = false
+
+ /** Sessions asked for a title this process; a failing backend is not re-asked every run. */
+ private val titleRequested = mutableSetOf()
+
+ /**
+ * User messages the reader unfolded, kept here so a fold survives the chat view being rebuilt.
+ * Main only. Never pruned: it grows by one id per tap, and message ids are never reused.
+ */
+ private val expandedUserMessageIds = mutableSetOf()
+
/** The in-flight backend availability check, so a resume can supersede the previous one. */
private var backendCheckJob: Job? = null
@@ -505,9 +543,36 @@ class ChatViewModel(
* @param messageId the message to remove; an unknown id is a no-op.
*/
private fun removeMessageFromSession(messageId: String) {
- _messages.value = _messages.value.filter { it.id != messageId }
- val session = currentSessionOrNull() ?: return
- replaceCurrentSessionMessages(session.messages.filter { it.id != messageId })
+ removeMessages { it.id == messageId }
+ }
+
+ /**
+ * Drops every message [doomed] accepts, from the transcript and from the session behind it.
+ *
+ * @param doomed picks the messages to remove; the two lists mirror each other, so the
+ * transcript is what decides whether anything matched.
+ * @return true when at least one message was removed.
+ */
+ private fun removeMessages(doomed: (ChatMessage) -> Boolean): Boolean {
+ val remaining = _messages.value.filterNot(doomed)
+ if (remaining.size == _messages.value.size) return false
+ _messages.value = remaining
+ val session = currentSessionOrNull() ?: return true
+ replaceCurrentSessionMessages(session.messages.filterNot(doomed))
+ return true
+ }
+
+ /**
+ * Removes the "backend is not ready" notices, which a ready backend has made wrong.
+ *
+ * Called from [publishBackendStatus] on the edge into readiness, and from [adoptSession] for a
+ * transcript that was not on screen when that edge passed — between them, no conversation in
+ * the project keeps a warning about a backend that now works. Main thread only.
+ */
+ internal fun clearBackendSetupNotices() {
+ if (!removeMessages { it.isSetupError }) return
+ AgentTrace.detail("UI", "backend configured; dropped the setup notices")
+ schedulePersist()
}
/**
@@ -557,6 +622,9 @@ class ChatViewModel(
* Surfaces a setup problem both ways: a persistent SYSTEM bubble and [AgentState.Error] for the
* fragment's Snackbar. Used by [sendMessage]'s pre-flight guards, which reject before any backend
* runs, so the downstream `onError`/UserFeedback path never fires.
+ *
+ * Flagged as a setup error, which is what [clearBackendSetupNotices] removes it by once the
+ * backend is configured.
* @param text the error text to show.
*/
private fun emitSystemError(text: String) {
@@ -564,7 +632,8 @@ class ChatViewModel(
id = UUID.randomUUID().toString(),
text = text,
sender = Sender.SYSTEM,
- status = MessageStatus.ERROR
+ status = MessageStatus.ERROR,
+ isSetupError = true
)
_messages.value = _messages.value + errorMessage
syncMessageToSession(errorMessage)
@@ -976,18 +1045,26 @@ class ChatViewModel(
transform: (BackendStatus) -> BackendStatus,
): Boolean = withContext(Dispatchers.Main.immediate) {
if (sequence != backendCheckSequence) return@withContext false
- _backendStatus.value = transform(_backendStatus.value)
+ val previous = _backendStatus.value
+ _backendStatus.value = transform(previous)
+ // Only on the edge into readiness: that is the moment a stored notice became wrong, and
+ // the guard keeps every later check off the transcript.
+ if (_backendStatus.value.isAvailable && !previous.isAvailable) clearBackendSetupNotices()
true
}
/**
* Send a user message and get agent response.
+ *
+ * @param userMessage the prompt to send.
+ * @return true once a run has been started; false when a pre-flight guard rejected the prompt,
+ * which is what keeps the composer's text in place for an unconfigured backend.
*/
- fun sendMessage(userMessage: String) {
+ fun sendMessage(userMessage: String): Boolean {
val llmService = getLlmService()
if (llmService == null) {
emitSystemError(str(R.string.error_llm_service_not_available))
- return
+ return false
}
if (!_backendStatus.value.isAvailable) {
@@ -1003,16 +1080,16 @@ class ChatViewModel(
SelectedBackend.None -> str(R.string.backend_none_installed)
}
)
- return
+ return false
}
if (userMessage.isBlank()) {
- return
+ return false
}
// Reject re-entry while a generation is still in flight.
if (!isGenerating.compareAndSet(false, true)) {
- return
+ return false
}
AgentTrace.beginRun(currentBackendId, userMessage, contextFiles.size)
@@ -1027,6 +1104,9 @@ class ChatViewModel(
val tools = agentTools
val epoch = generationEpoch.incrementAndGet()
generationJob = viewModelScope.launch(Dispatchers.IO) {
+ // The session this run may title, and whether a title request took that job over.
+ var titleSessionId: String? = null
+ var titleRequestStarted = false
try {
// Add user message to the UI.
val userChatMessage = ChatMessage(
@@ -1036,10 +1116,15 @@ class ChatViewModel(
status = MessageStatus.SENT
)
withContext(Dispatchers.Main) {
+ // Before the message lands, so the header never shows it as the title first.
+ titleSessionId = currentSessionOrNull()?.takeIf { needsTitle(it) }?.id
+ titleSessionId?.let { id -> _titlePending.value = _titlePending.value + id }
_messages.value = _messages.value + userChatMessage
syncMessageToSession(userChatMessage)
setState(AgentState.Processing(str(R.string.msg_generating)))
}
+ // Queued behind a title still being written; the prompt shows as generating meanwhile.
+ awaitTitleRequest()
val config = LlmInferenceService.LlmConfig(currentBackendId).apply {
// The grammar shapes a local tool call but not its values, so paths get sampled.
@@ -1094,6 +1179,11 @@ class ChatViewModel(
events = AgentRunReporter(runNotices),
)
AgentTrace.endRun(loopResult.reason.name, loopResult.turns)
+ if (loopResult.completed && generationEpoch.get() == epoch) {
+ titleRequestStarted = withContext(Dispatchers.Main) {
+ requestTitleIfUntitled(llmService)
+ }
+ }
} finally {
// Persist history only if this run wasn't superseded (epoch bumped).
if (generationEpoch.get() == epoch) {
@@ -1122,9 +1212,118 @@ class ChatViewModel(
withContext(NonCancellable + Dispatchers.Main) {
finishActivity()
persistState()
+ // No title is coming for a run that failed or stopped; the header shows the prompt.
+ if (!titleRequestStarted) titleSessionId?.let(::settleTitle)
}
}
}
+ return true
+ }
+
+ /**
+ * Asks the selected backend to name the current chat, once, after its first completed reply.
+ * Leaves chats the user named, or that already have a title, alone. Main-thread only; the
+ * request itself runs on IO as [titleRequest], which the next run waits out before generating.
+ *
+ * @param llmService the inference service the run just used.
+ * @return whether a request was started; it then settles [titlePending] itself when it ends.
+ */
+ internal fun requestTitleIfUntitled(llmService: LlmInferenceService): Boolean {
+ val session = currentSessionOrNull() ?: return false
+ if (!needsTitle(session)) return false
+ val userText = session.messages.firstOrNull { it.sender == Sender.USER }?.text ?: return false
+ val replyText = session.messages.lastOrNull { it.sender == Sender.AGENT && it.text.isNotBlank() }
+ ?.text ?: return false
+ titleRequested.add(session.id)
+ val sessionId = session.id
+ // Lazy, so titleRequest is set before the finally below can compare against it.
+ val job = viewModelScope.launch(Dispatchers.IO, start = CoroutineStart.LAZY) {
+ val self = coroutineContext.job
+ try {
+ generateTitle(llmService, sessionId, userText, replyText)
+ } finally {
+ // A request clearMessages() dropped must not end the placeholder of the run after it.
+ withContext(NonCancellable + Dispatchers.Main) {
+ if (titleRequest?.job === self) settleTitle(sessionId)
+ }
+ }
+ }
+ titleRequest = TitleRequest(sessionId, job)
+ job.start()
+ return true
+ }
+
+ /**
+ * Holds a new prompt until a title still being written has finished, so the two never generate
+ * at once. Bounded by the title's own [ChatTitle.TIMEOUT_MS]; the title job settles itself.
+ */
+ internal suspend fun awaitTitleRequest() {
+ val request = titleRequest?.takeIf { it.job.isActive } ?: return
+ AgentTrace.stage("TITLE", "session=${request.sessionId} holding a queued prompt")
+ request.job.join()
+ }
+
+ /** Whether [session] is still to be titled: not named by the user, untitled, and not yet asked. */
+ private fun needsTitle(session: ChatSession): Boolean =
+ session.name == null && session.generatedTitle == null && session.id !in titleRequested
+
+ /** Ends [sessionId]'s loading title; it now shows its generated title or its clamped prompt. */
+ private fun settleTitle(sessionId: String) {
+ _titlePending.value = _titlePending.value - sessionId
+ }
+
+ /**
+ * Asks for a title and stores it. Every way out short of a title leaves the chat on its prompt,
+ * so failures are logged, not surfaced: nothing about the conversation itself went wrong.
+ */
+ private suspend fun generateTitle(
+ llmService: LlmInferenceService,
+ sessionId: String,
+ userText: String,
+ replyText: String,
+ ) {
+ val config = LlmInferenceService.LlmConfig(currentBackendId).apply {
+ temperature = ChatTitle.TEMPERATURE
+ maxTokens = ChatTitle.MAX_TOKENS
+ systemPrompt = ChatTitle.SYSTEM_PROMPT
+ }
+ val response = try {
+ withTimeoutOrNull(ChatTitle.TIMEOUT_MS) {
+ llmService.generateCompletion(ChatTitle.prompt(userText, replyText), config).await()
+ }
+ } catch (ce: CancellationException) {
+ throw ce
+ } catch (e: Exception) {
+ logWarn("title request failed for session $sessionId", e)
+ return
+ }
+ if (response == null) {
+ // The timeout already cancelled this future; cancelGeneration() would hit other plugins'.
+ logWarn("title request timed out for session $sessionId")
+ return
+ }
+ if (!response.success) {
+ logWarn("title request refused for session $sessionId: ${response.error}")
+ return
+ }
+ val title = ChatTitle.sanitize(response.text.orEmpty()) ?: return
+ withContext(Dispatchers.Main) { applyGeneratedTitle(sessionId, title) }
+ }
+
+ /**
+ * Stores [title] on the session, unless the user renamed it or it got a title meanwhile.
+ *
+ * @param sessionId the session the title was written for; it may have been deleted since.
+ * @param title the cleaned title.
+ */
+ private fun applyGeneratedTitle(sessionId: String, title: String) {
+ val session = _sessions.value.firstOrNull { it.id == sessionId } ?: return
+ if (session.name != null || session.generatedTitle != null) return
+ _sessions.value = _sessions.value.map {
+ if (it.id == sessionId) it.copy(generatedTitle = title) else it
+ }
+ AgentTrace.stage("TITLE", "session=$sessionId chars=${title.length}")
+ persistState()
}
/**
@@ -1290,6 +1489,12 @@ class ChatViewModel(
}
}
+ modelTurnInFlight = true
+ // After the flag: Stop bumps the epoch before reading it, so one of the two sees the other.
+ if (isStale()) {
+ modelTurnInFlight = false
+ throw CancellationException("stopped")
+ }
try {
// Every backend takes the structured form: the last turn as the prompt, the rest as
// history. A backend that reports no native calls simply never calls onToolCall, and
@@ -1331,7 +1536,11 @@ class ChatViewModel(
if (!deferred.isCompleted) deferred.completeExceptionally(e)
}
- return deferred.await()
+ return try {
+ deferred.await()
+ } finally {
+ modelTurnInFlight = false
+ }
}
/**
@@ -1510,6 +1719,20 @@ class ChatViewModel(
addSystemMessage(str(R.string.agent_no_progress), MessageStatus.SENT)
}
+ /** Whether the user message [messageId] is shown unfolded. Main only. */
+ fun isUserMessageExpanded(messageId: String): Boolean = messageId in expandedUserMessageIds
+
+ /**
+ * Unfolds the user message [messageId], or folds it back. Main only.
+ *
+ * @return whether it is unfolded now.
+ */
+ fun toggleUserMessageExpanded(messageId: String): Boolean {
+ if (expandedUserMessageIds.add(messageId)) return true
+ expandedUserMessageIds.remove(messageId)
+ return false
+ }
+
/**
* Clear all messages from the conversation.
*/
@@ -1520,6 +1743,20 @@ class ChatViewModel(
_history.value = emptyList()
// Without this the session keeps its messages and the cleared chat returns on the next sync.
replaceCurrentSessionMessages(emptyList())
+ // The title described the conversation just cleared; the next first reply writes a new one.
+ // Its reply would name the cleared chat, and its settle would end the next run's placeholder.
+ _currentSessionId.value?.let { sessionId ->
+ // Another chat's request is left to finish and settle its own placeholder.
+ titleRequest?.takeIf { it.sessionId == sessionId }?.let {
+ it.job.cancel()
+ titleRequest = null
+ }
+ titleRequested.remove(sessionId)
+ settleTitle(sessionId)
+ _sessions.value = _sessions.value.map {
+ if (it.id == sessionId) it.copy(generatedTitle = null) else it
+ }
+ }
forgetRetryPoint()
setState(AgentState.Idle)
// Written now rather than debounced: a clear is deliberate and must survive a force-stop.
@@ -1535,10 +1772,20 @@ class ChatViewModel(
AgentTrace.stage("CANCEL", "reason=$reason wasRunning=${_agentState.value.isRunning}")
generationEpoch.incrementAndGet()
approvalManager.cancelPendingApproval()
+ cancelGenerationJob()
+ stopStateTimer()
+ }
+
+ /**
+ * Cancels the run's job and, only while one of its model turns is generating, the backend
+ * stream too. A prompt still queued behind a title leaves that title running.
+ */
+ private fun cancelGenerationJob() {
+ // Read before the cancel: the turn's finally may clear it as soon as the job is cancelled.
+ val turnInFlight = modelTurnInFlight
generationJob?.cancel()
generationJob = null
- getLlmService()?.cancelGeneration()
- stopStateTimer()
+ if (turnInFlight) getLlmService()?.cancelGeneration()
}
/**
@@ -1583,6 +1830,8 @@ class ChatViewModel(
_messages.value = session.messages.toList()
// Emptying this is what had the model forget a conversation the user was looking at.
_history.value = rebuildHistoryFrom(session.messages)
+ // Also dropped here: the readiness edge may have fired while another chat was current.
+ if (_backendStatus.value.isAvailable) clearBackendSetupNotices()
// The rewind point names a run this transcript does not have, and would truncate it.
forgetRetryPoint()
persistState()
@@ -1610,8 +1859,8 @@ class ChatViewModel(
* Gives a session the name the user typed for it, or takes that name away again.
*
* @param sessionId the session to rename; an unknown id is a no-op.
- * @param name the new name. Blank clears it, so the session falls back to naming itself after
- * its first user turn — that is the only way back from a rename the user regrets.
+ * @param name the new name. Blank clears it, so the session falls back to its generated title,
+ * or its first user turn — that is the only way back from a rename the user regrets.
*/
fun renameSession(sessionId: String, name: String?) {
val trimmed = name?.trim()?.takeIf { it.isNotEmpty() }
@@ -1624,6 +1873,8 @@ class ChatViewModel(
_sessions.value = _sessions.value.map {
if (it.id == sessionId) it.copy(name = trimmed) else it
}
+ // A user name outranks the title being written, so its placeholder must not outlast it.
+ if (trimmed != null) settleTitle(sessionId)
// Written now rather than debounced: a rename is deliberate and may be the last thing the
// user does before leaving the tab, where no streamed token follows to flush it.
persistState()
@@ -1784,9 +2035,7 @@ class ChatViewModel(
setState(AgentState.Cancelling)
// Cancelling the job alone would strand an open approval dialog with nothing awaiting it.
approvalManager.cancelPendingApproval()
- generationJob?.cancel()
- generationJob = null
- getLlmService()?.cancelGeneration()
+ cancelGenerationJob()
stopStateTimer()
finalizeInProgressMessages()
setState(AgentState.Idle)
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/views/MaxWidthLinearLayout.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/views/MaxWidthLinearLayout.kt
new file mode 100644
index 00000000..9a1a031e
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/views/MaxWidthLinearLayout.kt
@@ -0,0 +1,28 @@
+package com.itsaky.androidide.plugins.aicore.views
+
+import android.content.Context
+import android.util.AttributeSet
+import android.widget.LinearLayout
+import com.itsaky.androidide.plugins.aicore.R
+
+/**
+ * A LinearLayout that wraps its content but never grows past R.fraction.chat_user_bubble_max_width
+ * of the width its parent offers. Done in code because library `app:` attributes (ConstraintLayout's
+ * included) are read by the host's copy of the library against the host's ids, so they are ignored.
+ */
+class MaxWidthLinearLayout @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+) : LinearLayout(context, attrs) {
+
+ private val maxWidthFraction = resources.getFraction(R.fraction.chat_user_bubble_max_width, 1, 1)
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
+ return
+ }
+ val cap = (MeasureSpec.getSize(widthMeasureSpec) * maxWidthFraction).toInt()
+ super.onMeasure(MeasureSpec.makeMeasureSpec(cap, MeasureSpec.AT_MOST), heightMeasureSpec)
+ }
+}
diff --git a/plugins/AI-Core/src/main/res/drawable/bg_user_message.xml b/plugins/AI-Core/src/main/res/drawable/bg_user_message.xml
new file mode 100644
index 00000000..13d2d6ff
--- /dev/null
+++ b/plugins/AI-Core/src/main/res/drawable/bg_user_message.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/plugins/AI-Core/src/main/res/drawable/ic_content_copy.xml b/plugins/AI-Core/src/main/res/drawable/ic_content_copy.xml
new file mode 100644
index 00000000..eb384e5f
--- /dev/null
+++ b/plugins/AI-Core/src/main/res/drawable/ic_content_copy.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/plugins/AI-Core/src/main/res/drawable/ic_side_panel.xml b/plugins/AI-Core/src/main/res/drawable/ic_side_panel_close.xml
similarity index 61%
rename from plugins/AI-Core/src/main/res/drawable/ic_side_panel.xml
rename to plugins/AI-Core/src/main/res/drawable/ic_side_panel_close.xml
index 550df651..bdbd1975 100644
--- a/plugins/AI-Core/src/main/res/drawable/ic_side_panel.xml
+++ b/plugins/AI-Core/src/main/res/drawable/ic_side_panel_close.xml
@@ -1,9 +1,16 @@
+
diff --git a/plugins/AI-Core/src/main/res/drawable/ic_side_panel_open.xml b/plugins/AI-Core/src/main/res/drawable/ic_side_panel_open.xml
new file mode 100644
index 00000000..e2d2fd5c
--- /dev/null
+++ b/plugins/AI-Core/src/main/res/drawable/ic_side_panel_open.xml
@@ -0,0 +1,16 @@
+
+
+
+
diff --git a/plugins/AI-Core/src/main/res/layout/fragment_chat.xml b/plugins/AI-Core/src/main/res/layout/fragment_chat.xml
index 4556b34d..06009a9c 100644
--- a/plugins/AI-Core/src/main/res/layout/fragment_chat.xml
+++ b/plugins/AI-Core/src/main/res/layout/fragment_chat.xml
@@ -31,11 +31,27 @@
android:paddingStart="16dp"
android:paddingEnd="8dp">
+
+
+
+
@@ -50,7 +66,7 @@
android:contentDescription="@string/desc_chat_sidebar"
android:padding="@dimen/chat_composer_icon_padding"
android:scaleType="fitCenter"
- android:src="@drawable/ic_side_panel"
+ android:src="@drawable/ic_side_panel_open"
android:tint="@color/plugin_on_surface" />
diff --git a/plugins/AI-Core/src/main/res/layout/list_item_chat_message.xml b/plugins/AI-Core/src/main/res/layout/list_item_chat_message.xml
index 83f6f088..6ad26e9b 100644
--- a/plugins/AI-Core/src/main/res/layout/list_item_chat_message.xml
+++ b/plugins/AI-Core/src/main/res/layout/list_item_chat_message.xml
@@ -1,7 +1,5 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+ android:layout_gravity="start" />
diff --git a/plugins/AI-Core/src/main/res/layout/list_item_chat_user_message.xml b/plugins/AI-Core/src/main/res/layout/list_item_chat_user_message.xml
new file mode 100644
index 00000000..7376f842
--- /dev/null
+++ b/plugins/AI-Core/src/main/res/layout/list_item_chat_user_message.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/AI-Core/src/main/res/layout/view_chat_message_actions.xml b/plugins/AI-Core/src/main/res/layout/view_chat_message_actions.xml
new file mode 100644
index 00000000..230d446b
--- /dev/null
+++ b/plugins/AI-Core/src/main/res/layout/view_chat_message_actions.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
diff --git a/plugins/AI-Core/src/main/res/layout/view_chat_message_body.xml b/plugins/AI-Core/src/main/res/layout/view_chat_message_body.xml
new file mode 100644
index 00000000..66bad7be
--- /dev/null
+++ b/plugins/AI-Core/src/main/res/layout/view_chat_message_body.xml
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/AI-Core/src/main/res/layout/view_chat_sidebar.xml b/plugins/AI-Core/src/main/res/layout/view_chat_sidebar.xml
index a8ba83ab..3c54674c 100644
--- a/plugins/AI-Core/src/main/res/layout/view_chat_sidebar.xml
+++ b/plugins/AI-Core/src/main/res/layout/view_chat_sidebar.xml
@@ -53,7 +53,7 @@
android:contentDescription="@string/desc_sidebar_close"
android:padding="@dimen/chat_sidebar_button_padding"
android:scaleType="fitCenter"
- android:src="@drawable/ic_side_panel"
+ android:src="@drawable/ic_side_panel_close"
android:tint="@color/plugin_on_surface_variant" />
48dp18sp
+ 16dp
+ 8dp8dp48dp
@@ -37,6 +39,21 @@
48dp32dp34dp
+
+ 75%
+
+ 40dp
+ 8dp
+ -8dp
+
+ 12dp
+
+ 2dp
+ 40dp
+ 11dp
+ 14dp
+ 10dp
+ 18dp56dp20dp
diff --git a/plugins/AI-Core/src/main/res/values/integers.xml b/plugins/AI-Core/src/main/res/values/integers.xml
index 9e0d5670..bb535acf 100644
--- a/plugins/AI-Core/src/main/res/values/integers.xml
+++ b/plugins/AI-Core/src/main/res/values/integers.xml
@@ -19,4 +19,7 @@
8200
+
+
+ 4
diff --git a/plugins/AI-Core/src/main/res/values/strings.xml b/plugins/AI-Core/src/main/res/values/strings.xml
index fbaf523b..0384428e 100644
--- a/plugins/AI-Core/src/main/res/values/strings.xml
+++ b/plugins/AI-Core/src/main/res/values/strings.xml
@@ -13,6 +13,9 @@
Stop the agentShow the message boxHide the message box
+ Show the whole message
+ Show less of the message
+ Copy messageChats, settings and a new chat
@@ -26,12 +29,13 @@
New chat
+ Naming chat…RenameDeleteCurrent chatRename chatChat name
- Leave it empty to name the chat after its first message again.
+ Leave it empty to use the automatic title again.RenameClear this chat?Everything said in this conversation is removed and you stay in it, empty. No copy is kept, so use “New chat” instead if you may want it back. This can\'t be undone.
@@ -80,7 +84,6 @@
Copy Text
- Edit MessageRetryOpen AI Settings
@@ -160,7 +163,6 @@
Max Tokens
- AI AgentNew ChatAdd context fileType a message or prompt…
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatSessionRowsTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatSessionRowsTest.kt
index 170f6116..c3b17026 100644
--- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatSessionRowsTest.kt
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatSessionRowsTest.kt
@@ -34,6 +34,20 @@ class ChatSessionRowsTest {
assertEquals("why does the build fail", rows.single().title)
}
+ @Test
+ fun givenASessionWhoseTitleIsBeingWritten_whenBuildingRows_thenItShowsThePlaceholderAsUntitled() {
+ val rows = ChatSessionRows.from(
+ listOf(session("s1", userText = "why does the build fail")),
+ currentSessionId = null,
+ untitledTitle = UNTITLED,
+ titlePending = setOf("s1"),
+ pendingTitle = "Naming chat…",
+ )
+
+ assertEquals("Naming chat…", rows.single().title)
+ assertTrue(rows.single().isUntitled)
+ }
+
@Test
fun givenARenamedSession_whenBuildingRows_thenTheNameWins() {
val rows = ChatSessionRows.from(
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatSessionTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatSessionTest.kt
index e8fb3f4b..0929d0b4 100644
--- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatSessionTest.kt
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatSessionTest.kt
@@ -38,6 +38,31 @@ class ChatSessionTest {
assertNull(session.displayTitle)
}
+ @Test
+ fun givenAGeneratedTitle_whenReadingTheTitle_thenItReplacesTheFirstMessage() {
+ val session = ChatSession(
+ messages = listOf(ChatMessage(text = "how do i fix the gradle sync", sender = Sender.USER)),
+ generatedTitle = "Fix Gradle sync"
+ )
+
+ assertEquals("Fix Gradle sync", session.displayTitle)
+ }
+
+ @Test
+ fun givenALongFirstMessageAndNoGeneratedTitle_whenReadingTheTitle_thenItIsCutToTenWords() {
+ val text = "please help me fix the gradle\nsync that fails after I upgraded the android plugin"
+ val session = ChatSession(messages = listOf(ChatMessage(text = text, sender = Sender.USER)))
+
+ assertEquals("please help me fix the gradle sync that fails after…", session.displayTitle)
+ }
+
+ @Test
+ fun givenAUserNameAndAGeneratedTitle_whenReadingTheTitle_thenTheUsersNameWins() {
+ val session = ChatSession(name = "My build issue", generatedTitle = "Fix Gradle sync")
+
+ assertEquals("My build issue", session.displayTitle)
+ }
+
@Test
fun testChatSessionTitle_WithUserMessage() {
val messages = mutableListOf(
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/KeyboardShortcutHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/KeyboardShortcutHandlerTest.kt
new file mode 100644
index 00000000..ad51af5a
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/KeyboardShortcutHandlerTest.kt
@@ -0,0 +1,78 @@
+package com.itsaky.androidide.plugins.aicore.shortcuts
+
+import android.view.KeyEvent
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Guards the dispatch rules: one run per press, and every event of an owned press swallowed — the
+ * half that is not swallowed is the newline a send shortcut used to leave behind.
+ */
+class KeyboardShortcutHandlerTest {
+
+ private var sends = 0
+ private var newChats = 0
+
+ // Chords of this test's own, not the catalog's: the handler's rules are what is under test,
+ // and they must not change meaning the day the chat rebinds a key.
+ private val shiftEnter = KeyboardShortcut(KeyEvent.KEYCODE_ENTER, KeyEvent.META_SHIFT_ON)
+ private val ctrlN = KeyboardShortcut(KeyEvent.KEYCODE_N, KeyEvent.META_CTRL_ON)
+
+ private val handler = KeyboardShortcutHandler(
+ listOf(shiftEnter runs { sends++ }, ctrlN runs { newChats++ })
+ )
+
+ private fun press(
+ keyCode: Int,
+ keyAction: Int,
+ metaState: Int = 0,
+ repeatCount: Int = 0,
+ ) = handler.handle(keyCode, keyAction, metaState, repeatCount)
+
+ @Test
+ fun givenAnOwnedChord_whenTheKeyGoesDown_thenItRunsOnceAndIsConsumed() {
+ val consumed = press(KeyEvent.KEYCODE_ENTER, KeyEvent.ACTION_DOWN, KeyEvent.META_SHIFT_ON)
+
+ assertTrue(consumed)
+ assertEquals(1, sends)
+ }
+
+ @Test
+ fun givenAnOwnedChord_whenTheKeyComesUp_thenItIsConsumedWithoutRunningAgain() {
+ press(KeyEvent.KEYCODE_ENTER, KeyEvent.ACTION_DOWN, KeyEvent.META_SHIFT_ON)
+
+ val consumed = press(KeyEvent.KEYCODE_ENTER, KeyEvent.ACTION_UP, KeyEvent.META_SHIFT_ON)
+
+ assertTrue(consumed)
+ assertEquals(1, sends)
+ }
+
+ @Test
+ fun givenAnOwnedChord_whenTheKeyIsHeldDown_thenTheRepeatsDoNotRunItAgain() {
+ press(KeyEvent.KEYCODE_ENTER, KeyEvent.ACTION_DOWN, KeyEvent.META_SHIFT_ON)
+
+ press(KeyEvent.KEYCODE_ENTER, KeyEvent.ACTION_DOWN, KeyEvent.META_SHIFT_ON, repeatCount = 1)
+
+ assertEquals(1, sends)
+ }
+
+ @Test
+ fun givenAnUnownedKey_whenItIsPressed_thenNothingRunsAndTheEventIsLeftAlone() {
+ val consumed = press(KeyEvent.KEYCODE_ENTER, KeyEvent.ACTION_DOWN)
+
+ // Not consumed: plain Enter belongs to the field, which adds the line.
+ assertFalse(consumed)
+ assertEquals(0, sends)
+ assertEquals(0, newChats)
+ }
+
+ @Test
+ fun givenSeveralChords_whenOneOfThemIsPressed_thenOnlyItsOwnActionRuns() {
+ press(KeyEvent.KEYCODE_N, KeyEvent.ACTION_DOWN, KeyEvent.META_CTRL_ON)
+
+ assertEquals(1, newChats)
+ assertEquals(0, sends)
+ }
+}
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/KeyboardShortcutTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/KeyboardShortcutTest.kt
new file mode 100644
index 00000000..da061dde
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/shortcuts/KeyboardShortcutTest.kt
@@ -0,0 +1,55 @@
+package com.itsaky.androidide.plugins.aicore.shortcuts
+
+import android.view.KeyEvent
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Guards what a chord accepts. The matching is exact on the modifiers, which is the whole reason
+ * plain Enter can keep adding a line while Shift+Enter sends.
+ */
+class KeyboardShortcutTest {
+
+ private val shiftEnter = KeyboardShortcut(KeyEvent.KEYCODE_ENTER, KeyEvent.META_SHIFT_ON)
+
+ @Test
+ fun givenShiftEnter_whenShiftAndEnterAreHeld_thenItMatches() {
+ // A real press also carries the side-specific bit, which must not spoil the match.
+ val metaState = KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON
+
+ assertTrue(shiftEnter.matches(KeyEvent.KEYCODE_ENTER, metaState))
+ }
+
+ @Test
+ fun givenShiftEnter_whenEnterIsPressedAlone_thenItDoesNotMatch() {
+ assertFalse(shiftEnter.matches(KeyEvent.KEYCODE_ENTER, 0))
+ }
+
+ @Test
+ fun givenShiftEnter_whenAFurtherModifierIsHeld_thenItDoesNotMatch() {
+ val metaState = KeyEvent.META_SHIFT_ON or KeyEvent.META_CTRL_ON
+
+ assertFalse(shiftEnter.matches(KeyEvent.KEYCODE_ENTER, metaState))
+ }
+
+ @Test
+ fun givenShiftEnter_whenCapsLockIsOn_thenItStillMatches() {
+ val metaState = KeyEvent.META_SHIFT_ON or KeyEvent.META_CAPS_LOCK_ON
+
+ assertTrue(shiftEnter.matches(KeyEvent.KEYCODE_ENTER, metaState))
+ }
+
+ @Test
+ fun givenShiftEnter_whenAnotherKeyCarriesShift_thenItDoesNotMatch() {
+ assertFalse(shiftEnter.matches(KeyEvent.KEYCODE_TAB, KeyEvent.META_SHIFT_ON))
+ }
+
+ @Test
+ fun givenAModifierlessChord_whenTheKeyIsPressedWithAModifier_thenItDoesNotMatch() {
+ val escape = KeyboardShortcut(KeyEvent.KEYCODE_ESCAPE)
+
+ assertTrue(escape.matches(KeyEvent.KEYCODE_ESCAPE, 0))
+ assertFalse(escape.matches(KeyEvent.KEYCODE_ESCAPE, KeyEvent.META_ALT_ON))
+ }
+}
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatTitleTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatTitleTest.kt
new file mode 100644
index 00000000..3131fcfa
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatTitleTest.kt
@@ -0,0 +1,56 @@
+package com.itsaky.androidide.plugins.aicore.viewmodel
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ChatTitleTest {
+
+ @Test
+ fun givenAPlainTitle_whenSanitized_thenItIsKeptAsIs() {
+ assertEquals("Fix Gradle sync failure", ChatTitle.sanitize("Fix Gradle sync failure"))
+ }
+
+ @Test
+ fun givenAQuotedLabelledMarkdownTitle_whenSanitized_thenOnlyTheWordsRemain() {
+ assertEquals("Add dark mode toggle", ChatTitle.sanitize("**Title:** \"Add dark mode toggle.\""))
+ }
+
+ @Test
+ fun givenAReasoningBlockAndExtraLines_whenSanitized_thenTheFirstAnswerLineIsTheTitle() {
+ val raw = "\nThe user wants a RecyclerView.\n\n\nBuild a RecyclerView list\nThis title..."
+
+ assertEquals("Build a RecyclerView list", ChatTitle.sanitize(raw))
+ }
+
+ @Test
+ fun givenAQuestionTitle_whenSanitized_thenItsQuestionMarkIsKept() {
+ assertEquals("Why does the build fail?", ChatTitle.sanitize("Why does the build fail?"))
+ }
+
+ @Test
+ fun givenAnOverlongAnswer_whenSanitized_thenItIsCappedInWordsAndCharacters() {
+ val title = ChatTitle.sanitize("one two three four five six seven eight nine ten")!!
+
+ assertEquals("one two three four five six seven eight", title)
+ assertTrue(ChatTitle.sanitize("x".repeat(200))!!.length <= ChatTitle.MAX_CHARS)
+ }
+
+ @Test
+ fun givenABlankOrPunctuationOnlyAnswer_whenSanitized_thenThereIsNoTitle() {
+ assertNull(ChatTitle.sanitize(" \n\n "))
+ assertNull(ChatTitle.sanitize("\"...\""))
+ assertNull(ChatTitle.sanitize("only reasoning"))
+ }
+
+ @Test
+ fun givenALongExchange_whenBuildingThePrompt_thenEachSideIsCutAndItEndsOnTheTitleCue() {
+ // Letters that appear in none of the prompt's own labels.
+ val prompt = ChatTitle.prompt("q".repeat(5_000), "z".repeat(5_000))
+
+ assertTrue(prompt.endsWith("Title:"))
+ assertEquals(ChatTitle.EXCERPT_CHARS, prompt.count { it == 'q' })
+ assertEquals(ChatTitle.EXCERPT_CHARS, prompt.count { it == 'z' })
+ }
+}
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelMessageFoldTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelMessageFoldTest.kt
new file mode 100644
index 00000000..090d832e
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelMessageFoldTest.kt
@@ -0,0 +1,52 @@
+package com.itsaky.androidide.plugins.aicore.viewmodel
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * The fold state of user bubbles lives in the ViewModel, so it outlives the adapter that draws it:
+ * a chat view rebuilt from scratch still shows the messages the reader unfolded.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class ChatViewModelMessageFoldTest {
+
+ @Before
+ fun setUp() {
+ // ChatViewModel's stateIn() calls run on viewModelScope, i.e. Dispatchers.Main.
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun givenAFoldedMessage_whenToggled_thenItIsUnfoldedAndReportsSo() {
+ val viewModel = ChatViewModel { null }
+
+ assertFalse(viewModel.isUserMessageExpanded("m1"))
+ assertTrue(viewModel.toggleUserMessageExpanded("m1"))
+ assertTrue(viewModel.isUserMessageExpanded("m1"))
+ }
+
+ @Test
+ fun givenAnUnfoldedMessage_whenToggledAgain_thenItFoldsWithoutTouchingOthers() {
+ val viewModel = ChatViewModel { null }
+ viewModel.toggleUserMessageExpanded("m1")
+ viewModel.toggleUserMessageExpanded("m2")
+
+ assertFalse(viewModel.toggleUserMessageExpanded("m1"))
+
+ assertFalse(viewModel.isUserMessageExpanded("m1"))
+ assertTrue(viewModel.isUserMessageExpanded("m2"))
+ }
+}
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelSetupNoticeTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelSetupNoticeTest.kt
new file mode 100644
index 00000000..3d50a649
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelSetupNoticeTest.kt
@@ -0,0 +1,118 @@
+package com.itsaky.androidide.plugins.aicore.viewmodel
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.itsaky.androidide.plugins.aicore.models.Sender
+import com.itsaky.androidide.plugins.services.SharedServices
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+/** Stand-in project namespace; the digest itself is ProjectKey's business, not this file's. */
+private const val TEST_PROJECT_KEY = "a1b2c3d4e5f60718"
+
+/**
+ * Covers what happens to a prompt, and to the warning it raises, when no backend is configured
+ * (ADFA-6214): the prompt is refused rather than swallowed, and the warning does not outlive the
+ * problem it reports.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class ChatViewModelSetupNoticeTest {
+
+ private lateinit var context: Context
+ private lateinit var sharedPreferences: SharedPreferences
+ private lateinit var editor: SharedPreferences.Editor
+
+ @Before
+ fun setUp() {
+ // ChatViewModel's stateIn() calls run on viewModelScope, i.e. Dispatchers.Main.
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ // No inference service is reachable, which is the pre-flight path under test; another
+ // test's leftover router in the process-global registry would take it away.
+ SharedServices.clear()
+ context = mockk(relaxed = true)
+ sharedPreferences = mockk(relaxed = true)
+ editor = mockk(relaxed = true)
+ every { context.getSharedPreferences(any(), any()) } returns sharedPreferences
+ every { sharedPreferences.getString(any(), any()) } returns null
+ every { sharedPreferences.edit() } returns editor
+ every { editor.putString(any(), any()) } returns editor
+ }
+
+ @After
+ fun tearDown() {
+ SharedServices.clear()
+ Dispatchers.resetMain()
+ }
+
+ private fun newViewModel() = ChatViewModel { null }.apply {
+ initializeStorage(context, TEST_PROJECT_KEY)
+ }
+
+ @Test
+ fun givenNoBackend_whenSendingAMessage_thenItIsRefusedAndOnlyASetupNoticeIsAdded() {
+ val viewModel = newViewModel()
+
+ val accepted = viewModel.sendMessage("hello")
+
+ // Refused is what the composer reads to keep the typed prompt in place.
+ assertFalse(accepted)
+ val messages = viewModel.messages.value
+ assertEquals(1, messages.size)
+ assertEquals(Sender.SYSTEM, messages[0].sender)
+ assertTrue(messages[0].isSetupError)
+ }
+
+ @Test
+ fun givenASetupNotice_whenTheBackendBecomesReady_thenTheNoticeLeavesTheTranscriptAndTheSession() {
+ val viewModel = newViewModel()
+ viewModel.sendMessage("hello")
+
+ viewModel.clearBackendSetupNotices()
+
+ assertTrue(viewModel.messages.value.isEmpty())
+ // Both, or the next sync republishes the transcript from the session and brings it back.
+ assertTrue(viewModel.sessions.value.single().messages.isEmpty())
+ }
+
+ @Test
+ fun givenTheBackendIsStillUnready_whenTheChatHoldingANoticeComesBack_thenItStays() {
+ val viewModel = newViewModel()
+ viewModel.sendMessage("hello")
+ val noticed = requireNotNull(viewModel.currentSessionId.value)
+
+ viewModel.createNewSession()
+ viewModel.switchToSession(noticed)
+
+ // Restoring a transcript drops these notices, but only once something answers as ready:
+ // nothing has been configured here, so the warning is still the truth.
+ val restored = viewModel.messages.value
+ assertEquals(1, restored.size)
+ assertTrue(restored.single().isSetupError)
+ }
+
+ @Test
+ fun givenNoSetupNotice_whenTheBackendBecomesReady_thenTheTranscriptIsLeftAlone() {
+ val viewModel = newViewModel()
+ val before = viewModel.messages.value
+ val sessionsBefore = viewModel.sessions.value
+
+ viewModel.clearBackendSetupNotices()
+
+ // Same values, not merely equal ones: every availability check runs this, and collectors
+ // must not be woken for a transcript nothing was removed from.
+ assertSame(before, viewModel.messages.value)
+ assertSame(sessionsBefore, viewModel.sessions.value)
+ }
+}
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelTitleQueueTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelTitleQueueTest.kt
new file mode 100644
index 00000000..8a44cbac
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelTitleQueueTest.kt
@@ -0,0 +1,172 @@
+package com.itsaky.androidide.plugins.aicore.viewmodel
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.itsaky.androidide.plugins.services.LlmInferenceService
+import com.itsaky.androidide.plugins.services.SharedServices
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.ConcurrentHashMap
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import kotlinx.coroutines.withTimeoutOrNull
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+/** Stand-in project namespace; the digest itself is ProjectKey's business, not this file's. */
+private const val TEST_PROJECT_KEY = "a1b2c3d4e5f60718"
+private const val SESSIONS_KEY = "chat_sessions_$TEST_PROJECT_KEY"
+private const val SESSION_ID = "s1"
+private const val OTHER_SESSION_ID = "s2"
+
+/** Holds long enough to show a prompt waiting, far short of the title's own timeout. */
+private const val HOLD_MS = 200L
+
+/**
+ * A prompt sent while the chat title is still being written queues behind it (ADFA-6214): the
+ * title finishes, then the prompt runs, and nothing is cancelled to make room for it.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class ChatViewModelTitleQueueTest {
+
+ private lateinit var context: Context
+ private lateinit var sharedPreferences: SharedPreferences
+ private lateinit var editor: SharedPreferences.Editor
+ private lateinit var llmService: LlmInferenceService
+ private lateinit var titleResponse: CompletableFuture
+
+ /** Backs the preferences mock; concurrent because the persist scope writes from its own thread. */
+ private val stored = ConcurrentHashMap()
+
+ @Before
+ fun setUp() {
+ // ChatViewModel's stateIn() calls run on viewModelScope, i.e. Dispatchers.Main.
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ context = mockk(relaxed = true)
+ sharedPreferences = mockk(relaxed = true)
+ editor = mockk(relaxed = true)
+ every { context.getSharedPreferences(any(), any()) } returns sharedPreferences
+ every { sharedPreferences.getString(any(), any()) } answers {
+ stored[firstArg()] ?: secondArg()
+ }
+ every { sharedPreferences.edit() } returns editor
+ every { editor.putString(any(), any()) } returns editor
+ // A title that arrives only when the test completes it.
+ titleResponse = CompletableFuture()
+ llmService = mockk(relaxed = true)
+ every { llmService.generateCompletion(any(), any()) } returns titleResponse
+ // Like the real service: the global cancel reaches whatever call is current, here the title.
+ every { llmService.cancelGeneration() } answers { titleResponse.cancel(true); Unit }
+ SharedServices.register(LlmInferenceService::class.java, llmService)
+ stored[SESSIONS_KEY] =
+ """[{"id":"$SESSION_ID","createdAt":1000,"projectKey":"$TEST_PROJECT_KEY","messages":[""" +
+ """{"id":"m1","text":"explain this build script","sender":"USER","status":"SENT","timestamp":1},""" +
+ """{"id":"m2","text":"it applies the plugin builder","sender":"AGENT","status":"SENT",""" +
+ """"timestamp":2,"durationMs":1200}]}]"""
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ SharedServices.clear()
+ }
+
+ private fun newViewModel() = ChatViewModel { null }.apply {
+ initializeStorage(context, TEST_PROJECT_KEY)
+ }
+
+ @Test
+ fun givenATitleInFlight_whenAPromptIsQueued_thenItWaitsForTheTitleWithoutCancellingIt() {
+ val viewModel = newViewModel()
+ assertTrue(viewModel.requestTitleIfUntitled(llmService))
+
+ val released = runBlocking { withTimeoutOrNull(HOLD_MS) { viewModel.awaitTitleRequest() } }
+
+ assertNull(released)
+ assertNull(viewModel.sessions.value.single { it.id == SESSION_ID }.generatedTitle)
+ verify(exactly = 0) { llmService.cancelGeneration() }
+ }
+
+ @Test
+ fun givenAQueuedPrompt_whenTheTitleArrives_thenThePromptIsReleasedAndTheTitleKept() {
+ val viewModel = newViewModel()
+ assertTrue(viewModel.requestTitleIfUntitled(llmService))
+
+ titleResponse.complete(LlmInferenceService.LlmResponse.success("Build script walkthrough", 4, 10))
+ runBlocking { viewModel.awaitTitleRequest() }
+
+ assertFalse(SESSION_ID in viewModel.titlePending.value)
+ assertEquals(
+ "Build script walkthrough",
+ viewModel.sessions.value.single { it.id == SESSION_ID }.generatedTitle,
+ )
+ }
+
+ @Test
+ fun givenATitleInFlight_whenTheChatIsCleared_thenTheRequestIsDroppedAndItsTitleNeverLands() {
+ val viewModel = newViewModel()
+ assertTrue(viewModel.requestTitleIfUntitled(llmService))
+
+ viewModel.clearMessages()
+ titleResponse.complete(LlmInferenceService.LlmResponse.success("Build script walkthrough", 4, 10))
+ val released = runBlocking { withTimeoutOrNull(HOLD_MS) { viewModel.awaitTitleRequest() } }
+
+ assertEquals(Unit, released)
+ assertNull(viewModel.sessions.value.single { it.id == SESSION_ID }.generatedTitle)
+ }
+
+ @Test
+ fun givenATitleInFlightForAnotherChat_whenThisChatIsCleared_thenThatTitleStillLands() {
+ stored[SESSIONS_KEY] = stored.getValue(SESSIONS_KEY).removeSuffix("]") +
+ """,{"id":"$OTHER_SESSION_ID","createdAt":500,"projectKey":"$TEST_PROJECT_KEY","messages":[]}]"""
+ val viewModel = newViewModel()
+ assertTrue(viewModel.requestTitleIfUntitled(llmService))
+
+ viewModel.switchToSession(OTHER_SESSION_ID)
+ viewModel.clearMessages()
+ titleResponse.complete(LlmInferenceService.LlmResponse.success("Build script walkthrough", 4, 10))
+ runBlocking { viewModel.awaitTitleRequest() }
+
+ assertEquals(
+ "Build script walkthrough",
+ viewModel.sessions.value.single { it.id == SESSION_ID }.generatedTitle,
+ )
+ verify(exactly = 0) { llmService.cancelGeneration() }
+ }
+
+ @Test
+ fun givenATitleInFlightAndNoModelTurn_whenStopIsPressed_thenTheTitleStillLands() {
+ val viewModel = newViewModel()
+ assertTrue(viewModel.requestTitleIfUntitled(llmService))
+
+ viewModel.stopProcessing(reason = "test")
+ titleResponse.complete(LlmInferenceService.LlmResponse.success("Build script walkthrough", 4, 10))
+ runBlocking { viewModel.awaitTitleRequest() }
+
+ assertEquals(
+ "Build script walkthrough",
+ viewModel.sessions.value.single { it.id == SESSION_ID }.generatedTitle,
+ )
+ verify(exactly = 0) { llmService.cancelGeneration() }
+ }
+
+ @Test
+ fun givenNoTitleInFlight_whenAPromptIsSent_thenItRunsAtOnceAndNothingIsCancelled() {
+ val viewModel = newViewModel()
+
+ runBlocking { viewModel.awaitTitleRequest() }
+
+ verify(exactly = 0) { llmService.cancelGeneration() }
+ }
+}