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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.itsaky.androidide.plugins.aicore.fragments

/**
* The Agent chat over the whole screen, opened from the bottom sheet's chat so the transcript gets
* the height the editor, its toolbar and the tabs take there. A class of its own because the host
* opens a plugin screen by class name alone, with no arguments to say which kind it is.
*/
class FullScreenChatFragment : ChatFragment() {
override val isFullScreen: Boolean = true
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten
const val TOOLTIP_TAG_CHAT_SEND = "agent_chat_send"
/** The toolbar button that opens the sidebar; it took the overflow menu's place and tag. */
const val TOOLTIP_TAG_CHAT_MENU = "agent_chat_menu"
/** The toolbar button that opens the chat full screen, and closes it again there. */
const val TOOLTIP_TAG_CHAT_FULL_SCREEN = "agent_chat_full_screen"
const val TOOLTIP_TAG_CHAT_SESSIONS = "agent_chat_sessions"
/** The Export entry in a chat row's options menu. */
const val TOOLTIP_TAG_CHAT_EXPORT = "agent_chat_export"
Expand Down Expand Up @@ -408,6 +410,22 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten
PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0)
)
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_CHAT_FULL_SCREEN,
summary = "Shows this chat over the whole screen, or returns to the editor from there.",
detail = """
<p>In the bottom sheet, opens the same chat on a screen of its own,
so the conversation gets the height the editor, its toolbar and the
tabs take up. It is the same conversation: a reply still being
written carries on, and anything you send there is in the chat when
you come back.</p>
<p>On that screen the same button, or <b>Back</b>, returns you to the
editor.</p>
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0)
)
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_SIDEBAR_ADD,
summary = "Start a chat: a new one, or one imported from a .txt file.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.future.await
import kotlinx.coroutines.isActive
import kotlinx.coroutines.job
Expand Down Expand Up @@ -428,10 +429,36 @@ class ChatViewModel(
private fun isBusy(): Boolean =
!isIdle(isGenerating.value, _agentState.value, pendingApprovalRequest.value)

private var _contextFiles = listOf<File>()
private val _contextFiles = MutableStateFlow<List<File>>(emptyList())

/** Files the user attached; read back by the fragment to rebuild its chips on re-attach. */
val contextFiles: List<File> get() = _contextFiles
/** Files attached to the next prompt, shown as chips by every chat screen. */
val contextFiles: StateFlow<List<File>> = _contextFiles.asStateFlow()

private val _composerDraft = MutableStateFlow("")

/** The text in the composer, shared by the bottom-sheet chat and the full-screen one. */
val composerDraft: StateFlow<String> = _composerDraft.asStateFlow()

/**
* A sent prompt loaded back into the composer for editing.
*
* @property messageId the prompt Send replaces or forks.
* @property draftBefore the composer text put back when the edit ends.
* @property filesBefore the attached files put back when the edit ends.
*/
data class EditInProgress(
val messageId: String,
val draftBefore: String,
val filesBefore: List<File>,
)

private val _editInProgress = MutableStateFlow<EditInProgress?>(null)

/** The edit every chat screen shows, or null outside edit mode. */
val editInProgress: StateFlow<EditInProgress?> = _editInProgress.asStateFlow()

/** Unique to this instance, so saved UI state can tell whether this ViewModel outlived it. */
val instanceToken: String = UUID.randomUUID().toString()

private var stateUpdateJob: Job? = null

Expand Down Expand Up @@ -642,8 +669,54 @@ class ChatViewModel(
* Set context files to include in prompts.
*/
fun setContextFiles(files: List<File>) {
// Copied: the caller passes its own mutable list, which it keeps editing.
_contextFiles = files.toList()
_contextFiles.value = files.distinct()
}

/** Attaches [files] to the next prompt, skipping any already attached. */
fun addContextFiles(files: List<File>) {
_contextFiles.update { (it + files).distinct() }
}

/** Detaches [file] from the next prompt. */
fun removeContextFile(file: File) {
_contextFiles.update { it - file }
}

/** Records [text] as what the composer holds. */
fun setComposerDraft(text: String) {
_composerDraft.value = text
}

/**
* Loads [message] and its files into the composer for editing. Moving an edit to another
* prompt keeps the draft and files from before the first one.
*/
fun beginEdit(message: ChatMessage) {
val previous = _editInProgress.value
_editInProgress.value = EditInProgress(
messageId = message.id,
draftBefore = previous?.draftBefore ?: _composerDraft.value,
filesBefore = previous?.filesBefore ?: _contextFiles.value,
)
_contextFiles.value = message.contextFiles.orEmpty().map(::File).filter { it.isFile }
_composerDraft.value = message.text
}

/** Leaves edit mode, putting back the draft and files the composer held before it began. */
fun endEdit() {
val edit = _editInProgress.value ?: return
_editInProgress.value = null
_composerDraft.value = edit.draftBefore
_contextFiles.value = edit.filesBefore
}

/**
* Re-enters [edit] after the process was killed, with [editFiles] attached; the composer
* restores the edited text itself.
*/
fun restoreEdit(edit: EditInProgress, editFiles: List<File>) {
_editInProgress.value = edit
_contextFiles.value = editFiles
}

/**
Expand Down Expand Up @@ -966,7 +1039,7 @@ class ChatViewModel(
// Reject re-entry while a generation is still in flight.
if (!isGenerating.compareAndSet(false, true)) return false
// An overflow's turns are still in the history, and the estimate that let it through missed.
launchRun(llmService, userMessage, contextFiles, forceCompaction = lastRunOverflowed)
launchRun(llmService, userMessage, contextFiles.value, forceCompaction = lastRunOverflowed)
return true
}

Expand Down Expand Up @@ -1519,7 +1592,7 @@ class ChatViewModel(
return EditResult.NOT_EDITABLE
}
// Read before the cut: the composer leaves edit mode on it, handing back the draft's files.
val runFiles = contextFiles
val runFiles = contextFiles.value
// Checked before the rewind, so a refused send changes nothing.
val llmService = passPreflight(newText) ?: return EditResult.REFUSED
// Claimed before the cut too, so a chat is never cut for a run that then cannot start.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.itsaky.androidide.plugins.aicore.views

import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup

/**
* A user message: its bubble at the end edge and its actions beside the bubble, on the start side
* and level with its last line, so a short prompt costs one row of the transcript instead of two.
* When the actions do not fit beside the bubble they go under it, end-aligned.
*
* Holds exactly two children, the bubble and then the actions. Written as a ViewGroup because the
* choice depends on both measured widths, and library `app:` attributes such as Flexbox's are
* read against the host's ids in a plugin layout, so they are ignored.
*/
class UserMessageRow @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : ViewGroup(context, attrs) {

/** Whether the last measure put the actions beside the bubble rather than under it. */
private var actionsBeside = false

private val bubble: View get() = getChildAt(0)
private val actions: View get() = getChildAt(1)

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
check(childCount == 2) { "UserMessageRow holds the bubble and its actions, found $childCount views" }
val width = MeasureSpec.getSize(widthMeasureSpec)

measureChildWithMargins(bubble, widthMeasureSpec, 0, heightMeasureSpec, 0)
val bubbleHeight = bubble.measuredHeight + bubble.verticalMargins

val contentHeight = if (actions.visibility == GONE) {
actionsBeside = false
bubbleHeight
} else {
measureChildWithMargins(actions, widthMeasureSpec, 0, heightMeasureSpec, 0)
val besideWidth = bubble.measuredWidth + bubble.horizontalMargins +
actions.measuredWidth + actions.horizontalMargins
actionsBeside = besideWidth <= width - paddingLeft - paddingRight
if (actionsBeside) {
maxOf(bubbleHeight, actions.measuredHeight)
} else {
bubbleHeight + actions.verticalMargins + actions.measuredHeight
}
}
setMeasuredDimension(
resolveSize(width, widthMeasureSpec),
resolveSize(contentHeight + paddingTop + paddingBottom, heightMeasureSpec),
)
}

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
val rtl = layoutDirection == LAYOUT_DIRECTION_RTL
val bubbleParams = bubble.layoutParams as MarginLayoutParams
// Beside, the bubble and the actions share one bottom line, whichever of them is taller.
val bubbleBottom = if (actionsBeside) {
height - paddingBottom - bubbleParams.bottomMargin
} else {
paddingTop + bubbleParams.topMargin + bubble.measuredHeight
}
val bubbleLeft = if (rtl) {
paddingLeft + bubbleParams.leftMargin
} else {
width - paddingRight - bubbleParams.rightMargin - bubble.measuredWidth
}
bubble.layout(bubbleLeft, bubbleBottom - bubble.measuredHeight, bubbleLeft + bubble.measuredWidth, bubbleBottom)

if (actions.visibility == GONE) return
val actionsParams = actions.layoutParams as MarginLayoutParams
val actionsTop: Int
val actionsLeft: Int
if (actionsBeside) {
actionsTop = bubbleBottom - actions.measuredHeight
actionsLeft = if (rtl) {
bubble.right + bubbleParams.rightMargin + actionsParams.leftMargin
} else {
bubble.left - bubbleParams.leftMargin - actionsParams.rightMargin - actions.measuredWidth
}
} else {
actionsTop = bubbleBottom + bubbleParams.bottomMargin + actionsParams.topMargin
actionsLeft = if (rtl) {
paddingLeft + actionsParams.leftMargin
} else {
width - paddingRight - actionsParams.rightMargin - actions.measuredWidth
}
}
actions.layout(actionsLeft, actionsTop, actionsLeft + actions.measuredWidth, actionsTop + actions.measuredHeight)
}

override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams = MarginLayoutParams(context, attrs)

override fun generateDefaultLayoutParams(): LayoutParams =
MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)

override fun generateLayoutParams(p: LayoutParams): LayoutParams = MarginLayoutParams(p)

override fun checkLayoutParams(p: LayoutParams?): Boolean = p is MarginLayoutParams

private val View.horizontalMargins: Int
get() = (layoutParams as MarginLayoutParams).let { it.leftMargin + it.rightMargin }

private val View.verticalMargins: Int
get() = (layoutParams as MarginLayoutParams).let { it.topMargin + it.bottomMargin }
}
9 changes: 9 additions & 0 deletions plugins/AI-Core/src/main/res/drawable/ic_close_fullscreen.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M22,3.41l-5.29,5.29L20,12h-8V4l3.29,3.29L20.59,2L22,3.41zM3.41,22l5.29,-5.29L12,20v-8H4l3.29,3.29L2,20.59L3.41,22z"/>
</vector>
9 changes: 9 additions & 0 deletions plugins/AI-Core/src/main/res/drawable/ic_open_in_full.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M21,11V3h-8l3.29,3.29l-10,10L3,13v8h8l-3.29,-3.29l10,-10L21,11z"/>
</vector>
12 changes: 12 additions & 0 deletions plugins/AI-Core/src/main/res/layout/fragment_chat.xml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@
android:textSize="@dimen/chat_title_text_size"
android:textStyle="bold" />

<!-- Opens this chat over the whole screen; there, ChatFragment turns it into the way back. -->
<ImageButton
android:id="@+id/btn_chat_full_screen"
android:layout_width="@dimen/chat_composer_button_size"
android:layout_height="@dimen/chat_composer_button_size"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/desc_enter_full_screen"
android:padding="@dimen/chat_composer_icon_padding"
android:scaleType="fitCenter"
android:src="@drawable/ic_open_in_full"
android:tint="@color/plugin_on_surface" />

<!-- Takes the spot the overflow menu used to hold; everything that was in that menu is
in the sidebar this opens. -->
<ImageButton
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
<!--
A user message: the agent row's views inside a bordered bubble at the end edge. The bubble wraps
its text, growing leftwards, and only breaks lines once it reaches the MaxWidthLinearLayout cap.
Under it, end-aligned like the bubble, a row of per-message actions.
Beside it on the start side, level with its last line, a row of per-message actions; under it
only when they do not fit there (see UserMessageRow).
Framework attributes only: a library's app: attributes are silently dropped in plugin layouts.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<com.itsaky.androidide.plugins.aicore.views.UserMessageRow xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="8dp"
Expand All @@ -18,7 +18,6 @@
android:id="@+id/user_bubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:background="@drawable/bg_user_message"
android:orientation="vertical"
android:paddingStart="@dimen/chat_user_bubble_padding_horizontal"
Expand Down Expand Up @@ -48,7 +47,6 @@
<include
layout="@layout/view_chat_message_actions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end" />
android:layout_height="wrap_content" />

</LinearLayout>
</com.itsaky.androidide.plugins.aicore.views.UserMessageRow>
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Per-message actions, included under both agent and user messages; each row's include sets the
gravity. New actions go here as further buttons. ChatAdapter hides the row until a reply is done.
Per-message actions, included under agent messages and beside user messages; each row places
them. New actions go here as further buttons. ChatAdapter hides the row until a reply is done.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/message_actions"
Expand Down
3 changes: 3 additions & 0 deletions plugins/AI-Core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
<!-- What a screen reader says for that label. -->
<string name="desc_message_version">Version %1$d of %2$d</string>
<string name="desc_chat_sidebar">Chats, settings and a new chat</string>
<string name="desc_enter_full_screen">Show the chat full screen</string>
<string name="desc_exit_full_screen">Back to the editor</string>

<!-- Sidebar actions -->
<string name="menu_settings">Settings</string>
Expand Down Expand Up @@ -186,6 +188,7 @@
<string name="pref_agent_title">Agent</string>
<string name="pref_agent_summary">AI backend, model and API key</string>
<string name="msg_settings_unavailable">Couldn\'t open Agent settings. Open them from Preferences → Configuration → Agent.</string>
<string name="msg_full_screen_unavailable">Couldn\'t open the chat full screen.</string>
<!-- Matches the MCP plugin's own Preferences row, which opens the same screen -->
<string name="pref_mcp_title">MCP servers</string>
<string name="msg_plugin_settings_unavailable">Couldn\'t open %1$s. Open it from Preferences → Configuration → %1$s.</string>
Expand Down
Loading
Loading