From ed69f3d699e06a472ecdd12129fd21a530425fef Mon Sep 17 00:00:00 2001 From: Ammar-create Date: Sat, 10 Oct 2026 19:01:43 +0000 Subject: [PATCH 1/5] feat(agent): add interactive /plan and /build modes with plan artifact approval - Add ConversationAgentMode tracking per-conversation BUILD and PLAN states - Add SlashCommandCompletionProvider: only suggests /plan in Build mode, only suggests /build in Plan mode - Add propose_plan interactive tool with inline expandable card, Accept Plan and Decline buttons - Hard write-lock and system prompt guidance when in Plan mode - Intercept /plan and /build commands in ChatPage composer with instant mode switching - Zero emojis; strictly follows native HugeIcons and Material 3 design --- .../me/rerere/rikkahub/data/ai/AgentMode.kt | 91 ++++++++ .../rikkahub/data/ai/tools/LocalTools.kt | 57 +++++ .../me/rerere/rikkahub/service/ChatService.kt | 33 ++- .../SlashCommandCompletionProvider.kt | 83 +++++++ .../ui/components/message/ChatMessageTools.kt | 211 ++++++++++++++++++ .../rerere/rikkahub/ui/pages/chat/ChatPage.kt | 59 ++++- app/src/main/res/values/strings.xml | 10 + 7 files changed, 533 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/me/rerere/rikkahub/data/ai/AgentMode.kt create mode 100644 app/src/main/java/me/rerere/rikkahub/ui/components/ai/completion/SlashCommandCompletionProvider.kt diff --git a/app/src/main/java/me/rerere/rikkahub/data/ai/AgentMode.kt b/app/src/main/java/me/rerere/rikkahub/data/ai/AgentMode.kt new file mode 100644 index 00000000000..6d538917a54 --- /dev/null +++ b/app/src/main/java/me/rerere/rikkahub/data/ai/AgentMode.kt @@ -0,0 +1,91 @@ +package me.rerere.rikkahub.data.ai + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.ConcurrentHashMap +import kotlin.uuid.Uuid + +enum class AgentMode { + BUILD, + PLAN; + + val isPlan: Boolean get() = this == PLAN + val isBuild: Boolean get() = this == BUILD +} + +object ConversationAgentMode { + private val modes = ConcurrentHashMap() + private val modeFlows = ConcurrentHashMap>() + + fun get(conversationId: Uuid): AgentMode = + modes[conversationId] ?: AgentMode.BUILD + + fun getFlow(conversationId: Uuid): StateFlow = + modeFlows.computeIfAbsent(conversationId) { + MutableStateFlow(get(conversationId)) + }.asStateFlow() + + fun set(conversationId: Uuid, mode: AgentMode) { + if (mode == AgentMode.BUILD) { + modes.remove(conversationId) + } else { + modes[conversationId] = mode + } + modeFlows[conversationId]?.value = mode + } + + fun isPlanMode(conversationId: Uuid): Boolean = + get(conversationId) == AgentMode.PLAN + + fun clear(conversationId: Uuid) { + modes.remove(conversationId) + modeFlows.remove(conversationId) + } + + fun isToolAllowedInPlanMode(toolName: String): Boolean { + if (toolName == "propose_plan") return true + if (toolName == "ask_user") return true + if (toolName == "get_time_info") return true + if (toolName == "memory_tool") return true + + // Read-only workspace inspection + if (toolName in setOf( + "workspace_read_file", + "workspace_read_folder", + "workspace_background_status", + ) + ) return true + + // Search & web reading + if (toolName in setOf( + "web_search", + "web_fetch", + "web_extract", + ) + ) return true + + // Browser navigation & content reading (without clicks / JS / form submits) + if (toolName in setOf( + "browser_open", + "browser_get_text", + "browser_get_dom", + "browser_get_links", + "browser_current_url", + "browser_wait_for", + "browser_screenshot", + ) + ) return true + + // Read-only screen inspection + if (toolName in setOf( + "read_window_tree", + "find_node", + "take_screenshot", + "skill_get_content", + ) + ) return true + + return false + } +} diff --git a/app/src/main/java/me/rerere/rikkahub/data/ai/tools/LocalTools.kt b/app/src/main/java/me/rerere/rikkahub/data/ai/tools/LocalTools.kt index 79a35709d2b..ce4fba9efe6 100644 --- a/app/src/main/java/me/rerere/rikkahub/data/ai/tools/LocalTools.kt +++ b/app/src/main/java/me/rerere/rikkahub/data/ai/tools/LocalTools.kt @@ -717,11 +717,68 @@ class LocalTools( ) } + fun createProposePlanTool(invocationContext: ToolInvocationContext) = Tool( + name = "propose_plan", + description = """ + Propose a structured implementation plan to the user for review. + Use this in Plan Mode after inspecting context and determining the steps needed. + The user will be presented with an interactive plan card in chat to review, accept, or decline. + If accepted, the conversation automatically transitions to Build Mode for execution. + """.trimIndent().replace("\n", " "), + parameters = { + InputSchema.Obj( + properties = buildJsonObject { + put("title", buildJsonObject { + put("type", "string") + put("description", "A concise title summarizing the plan") + }) + put("overview", buildJsonObject { + put("type", "string") + put("description", "A brief high-level summary of what the plan achieves") + }) + put("steps", buildJsonObject { + put("type", "array") + put("description", "Ordered list of concrete implementation steps") + put("items", buildJsonObject { + put("type", "string") + }) + }) + put("plan_markdown", buildJsonObject { + put("type", "string") + put("description", "Detailed markdown content of the plan") + }) + }, + required = listOf("title") + ) + }, + needsApproval = { true }, + execute = { + val convId = runCatching { + if (invocationContext.callerConversationId.isNotBlank()) { + kotlin.uuid.Uuid.parse(invocationContext.callerConversationId) + } else null + }.getOrNull() + if (convId != null) { + me.rerere.rikkahub.data.ai.ConversationAgentMode.set(convId, me.rerere.rikkahub.data.ai.AgentMode.BUILD) + } + listOf( + UIMessagePart.Text( + buildJsonObject { + put("status", "plan_approved") + put("mode", "BUILD") + put("message", "The plan was approved by the user. Switched to Build Mode. You may now proceed with execution.") + }.toString() + ) + ) + } + ) + fun getTools( options: List, invocationContext: ToolInvocationContext = ToolInvocationContext.EMPTY, ): List { val tools = mutableListOf() + tools.add(createProposePlanTool(invocationContext)) if (options.contains(LocalToolOption.JavascriptEngine)) { tools.add(javascriptTool) } diff --git a/app/src/main/java/me/rerere/rikkahub/service/ChatService.kt b/app/src/main/java/me/rerere/rikkahub/service/ChatService.kt index 49986db9199..57238f03ede 100644 --- a/app/src/main/java/me/rerere/rikkahub/service/ChatService.kt +++ b/app/src/main/java/me/rerere/rikkahub/service/ChatService.kt @@ -1165,6 +1165,10 @@ class ChatService( // skips the Pending → handleToolApproval path entirely. val priorGenerationJob = session.getJob() + if (approved && toolName == "propose_plan") { + me.rerere.rikkahub.data.ai.ConversationAgentMode.set(conversationId, me.rerere.rikkahub.data.ai.AgentMode.BUILD) + } + // Commit the broader-scope grant on a NonCancellable scope BEFORE the cancellable // mutation block. Previous design ran grantAlways() inside the cancellable // appScope.launch — a rapid second tap would cancel the first job and silently @@ -1605,8 +1609,27 @@ class ChatService( // anything else) gets its runtime context into the system prompt without // having to plumb a parameter all the way through sendMessage. Returns null // for in-app conversations that didn't register one. - systemAddendum = me.rerere.rikkahub.data.ai.tools - .ConversationSystemAddendum.get(conversationId), + systemAddendum = buildString { + val baseAddendum = me.rerere.rikkahub.data.ai.tools + .ConversationSystemAddendum.get(conversationId) + if (!baseAddendum.isNullOrBlank()) { + append(baseAddendum) + append("\n\n") + } + if (me.rerere.rikkahub.data.ai.ConversationAgentMode.isPlanMode(conversationId)) { + append( + """ + [AGENT MODE: PLAN] + You are currently operating in PLAN MODE. + - Your objective is to inspect, analyze, and formulate a clear, step-by-step implementation plan. + - You have read-only tools to examine the workspace, read files, and search information. + - State-modifying and execution tools are locked in this mode. Do not attempt to modify files or execute commands. + - Once your investigation is complete, call the `propose_plan` tool with a descriptive title, overview, and implementation steps. + - The user will review your proposal in chat and can accept it (which switches to Build mode for execution) or decline it with adjustments. + """.trimIndent() + ) + } + }.takeIf { it.isNotBlank() }, isToolAutoApproved = { toolName -> // YOLO mode ("I AM STUPID" toggle in Settings → Tool approvals): every // tool auto-approves. User opted into this explicitly. HARDLINE still @@ -1801,6 +1824,12 @@ class ChatService( ) ) } + }.let { allTools -> + if (me.rerere.rikkahub.data.ai.ConversationAgentMode.isPlanMode(conversationId)) { + allTools.filter { me.rerere.rikkahub.data.ai.ConversationAgentMode.isToolAllowedInPlanMode(it.name) } + } else { + allTools + } }, ).onCompletion { completionCause -> // 取消 Live Update 通知 diff --git a/app/src/main/java/me/rerere/rikkahub/ui/components/ai/completion/SlashCommandCompletionProvider.kt b/app/src/main/java/me/rerere/rikkahub/ui/components/ai/completion/SlashCommandCompletionProvider.kt new file mode 100644 index 00000000000..d789809b7ba --- /dev/null +++ b/app/src/main/java/me/rerere/rikkahub/ui/components/ai/completion/SlashCommandCompletionProvider.kt @@ -0,0 +1,83 @@ +package me.rerere.rikkahub.ui.components.ai.completion + +import androidx.compose.ui.text.TextRange +import me.rerere.hugeicons.HugeIcons +import me.rerere.hugeicons.stroke.Edit03 +import me.rerere.hugeicons.stroke.Tools +import me.rerere.rikkahub.data.ai.AgentMode + +class SlashCommandCompletionProvider( + private val currentMode: () -> AgentMode, +) : ChatCompletionProvider { + override val id: String = "slash_commands" + + override suspend fun complete(context: ChatCompletionContext): ChatCompletionList? { + if (context.hasSelection) return null + val command = findSlashCommand(context.text, context.cursor) ?: return null + val query = command.query.lowercase() + val mode = currentMode() + + val items = when (mode) { + AgentMode.BUILD -> { + // In Build mode, ONLY suggest /plan + if ("plan".startsWith(query)) { + listOf( + ChatCompletionItem( + label = "/plan", + insertText = "/plan", + detail = "Switch to Plan mode (analyze and design without modifying files)", + icon = HugeIcons.Edit03, + sortScore = 1000, + ) + ) + } else { + emptyList() + } + } + AgentMode.PLAN -> { + // In Plan mode, ONLY suggest /build + if ("build".startsWith(query)) { + listOf( + ChatCompletionItem( + label = "/build", + insertText = "/build", + detail = "Switch to Build mode (default agent execution and editing)", + icon = HugeIcons.Tools, + sortScore = 1000, + ) + ) + } else { + emptyList() + } + } + } + + if (items.isEmpty()) return null + return ChatCompletionList( + providerId = id, + replacementRange = command.range, + items = items, + ) + } + + private data class SlashCommand( + val query: String, + val range: TextRange, + ) + + private fun findSlashCommand(text: String, cursor: Int): SlashCommand? { + if (cursor < 0 || cursor > text.length) return null + val prefix = text.substring(0, cursor) + val start = prefix.lastIndexOf('/') + if (start < 0) return null + // Command slash must be at start of input or after whitespace/newline + if (start > 0 && !prefix[start - 1].isWhitespace()) return null + + val query = prefix.substring(start + 1) + if (query.any { it.isWhitespace() }) return null + return SlashCommand( + query = query, + range = TextRange(start, cursor), + ) + } +} diff --git a/app/src/main/java/me/rerere/rikkahub/ui/components/message/ChatMessageTools.kt b/app/src/main/java/me/rerere/rikkahub/ui/components/message/ChatMessageTools.kt index 402d209525a..26c0b8f1b7f 100644 --- a/app/src/main/java/me/rerere/rikkahub/ui/components/message/ChatMessageTools.kt +++ b/app/src/main/java/me/rerere/rikkahub/ui/components/message/ChatMessageTools.kt @@ -4,12 +4,15 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -22,6 +25,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -55,12 +59,16 @@ import me.rerere.common.http.jsonObjectOrNull import me.rerere.hugeicons.HugeIcons import me.rerere.hugeicons.stroke.BubbleChatQuestion import me.rerere.hugeicons.stroke.Cancel01 +import me.rerere.hugeicons.stroke.CancelCircle +import me.rerere.hugeicons.stroke.CheckmarkCircle01 +import me.rerere.hugeicons.stroke.Edit03 import me.rerere.hugeicons.stroke.Refresh03 import me.rerere.hugeicons.stroke.Tick01 import me.rerere.hugeicons.stroke.Tools import me.rerere.rikkahub.R import me.rerere.rikkahub.ui.components.message.tools.ToolUIContext import me.rerere.rikkahub.ui.components.message.tools.ToolUIRegistry +import me.rerere.rikkahub.ui.components.richtext.MarkdownBlock import me.rerere.rikkahub.ui.components.richtext.ZoomableAsyncImage import me.rerere.rikkahub.ui.components.ui.ChainOfThoughtScope import me.rerere.rikkahub.ui.components.ui.DotLoading @@ -77,6 +85,7 @@ private fun JsonElement?.getStringContent(key: String): String? = this?.jsonObjectOrNull?.get(key)?.jsonPrimitiveOrNull?.contentOrNull private const val ASK_USER_TOOL_NAME = "ask_user" +private const val PROPOSE_PLAN_TOOL_NAME = "propose_plan" @Composable fun ChainOfThoughtScope.ChatMessageServerToolStep(tool: UIMessagePart.ServerTool) { @@ -142,6 +151,11 @@ fun ChainOfThoughtScope.ChatMessageToolStep( return } + if (tool.toolName == PROPOSE_PLAN_TOOL_NAME) { + ProposePlanToolStep(tool = tool, loading = loading, onToolApproval = onToolApproval) + return + } + val renderer = remember(tool.toolName) { ToolUIRegistry.resolve(tool.toolName) } val context = remember(tool, loading) { ToolUIContext( @@ -746,3 +760,200 @@ private fun ToolDenyReasonDialog( } ) } + +@Composable +private fun ChainOfThoughtScope.ProposePlanToolStep( + tool: UIMessagePart.Tool, + loading: Boolean, + onToolApproval: ((toolCallId: String, approved: Boolean, reason: String, scope: me.rerere.rikkahub.service.ChatService.ApprovalScope, toolName: String) -> Unit)?, +) { + val isPending = tool.isPending + val isApproved = tool.approvalState is ToolApprovalState.Approved + val isDenied = tool.approvalState is ToolApprovalState.Denied + val arguments = tool.inputAsJson() + + val planTitle = remember(arguments) { + arguments.jsonObject["title"]?.jsonPrimitive?.contentOrNull ?: "Implementation Plan" + } + val planOverview = remember(arguments) { + arguments.jsonObject["overview"]?.jsonPrimitive?.contentOrNull ?: "" + } + val planSteps = remember(arguments) { + arguments.jsonObject["steps"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + } + val planMarkdown = remember(arguments) { + arguments.jsonObject["plan_markdown"]?.jsonPrimitive?.contentOrNull ?: "" + } + + var expanded by remember { mutableStateOf(true) } + var showDenyDialog by remember { mutableStateOf(false) } + + ControlledChainOfThoughtStep( + expanded = expanded, + onExpandedChange = { expanded = it }, + icon = { + if (loading) { + DotLoading(size = 10.dp) + } else { + Icon( + imageVector = HugeIcons.Edit03, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + label = { + Text( + text = "Plan: $planTitle", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.shimmer(isLoading = loading), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + content = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (planOverview.isNotBlank()) { + Text( + text = planOverview, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + if (planSteps.isNotEmpty()) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + planSteps.forEachIndexed { index, step -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = "${index + 1}.", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = step, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + if (planMarkdown.isNotBlank()) { + MarkdownBlock( + content = planMarkdown, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (isApproved) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(top = 4.dp), + ) { + Icon( + imageVector = HugeIcons.CheckmarkCircle01, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = "Plan Approved · Switched to Build mode", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } else if (isDenied) { + val reason = (tool.approvalState as ToolApprovalState.Denied).reason + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(top = 4.dp), + ) { + Icon( + imageVector = HugeIcons.CancelCircle, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = if (reason.isNotBlank()) "Plan Declined: $reason" else "Plan Declined", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } else if (isPending && onToolApproval != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 8.dp), + ) { + FilledTonalButton( + onClick = { + onToolApproval( + tool.toolCallId, + true, + "", + me.rerere.rikkahub.service.ChatService.ApprovalScope.Once, + tool.toolName, + ) + }, + ) { + Icon( + imageVector = HugeIcons.CheckmarkCircle01, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + Text("Accept Plan") + } + + OutlinedButton( + onClick = { + showDenyDialog = true + }, + ) { + Icon( + imageVector = HugeIcons.Cancel01, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + Text("Decline") + } + } + } + } + }, + ) + + if (showDenyDialog && onToolApproval != null) { + ToolDenyReasonDialog( + onDismiss = { showDenyDialog = false }, + onConfirm = { reason -> + showDenyDialog = false + onToolApproval( + tool.toolCallId, + false, + reason, + me.rerere.rikkahub.service.ChatService.ApprovalScope.Once, + tool.toolName, + ) + }, + ) + } +} diff --git a/app/src/main/java/me/rerere/rikkahub/ui/pages/chat/ChatPage.kt b/app/src/main/java/me/rerere/rikkahub/ui/pages/chat/ChatPage.kt index 93e3dd7e0cb..aa6efcf4090 100644 --- a/app/src/main/java/me/rerere/rikkahub/ui/pages/chat/ChatPage.kt +++ b/app/src/main/java/me/rerere/rikkahub/ui/pages/chat/ChatPage.kt @@ -74,6 +74,9 @@ import me.rerere.rikkahub.ui.components.ai.ChatAttachmentPickerActions import me.rerere.rikkahub.ui.components.ai.ChatInput import me.rerere.rikkahub.ui.components.ai.FilesPicker import me.rerere.rikkahub.ui.components.ai.SearchMode +import me.rerere.rikkahub.data.ai.AgentMode +import me.rerere.rikkahub.data.ai.ConversationAgentMode +import me.rerere.rikkahub.ui.components.ai.completion.SlashCommandCompletionProvider import me.rerere.rikkahub.ui.components.ai.completion.WorkspaceCompletionProvider import me.rerere.rikkahub.ui.components.ai.rememberChatAttachmentPickerActions import me.rerere.rikkahub.ui.context.LocalNavController @@ -292,16 +295,24 @@ private fun ChatPageContent( val allowAudioVideoAttachments = setting.getCurrentChatModel()?.findProvider(setting.providers) is ProviderSetting.Google - val completionProviders = remember(assistant.workspaceId, conversation.workspaceCwd, workspaceRepository) { - assistant.workspaceId?.let { workspaceId -> - listOf( - WorkspaceCompletionProvider( - workspaceId = workspaceId.toString(), - repository = workspaceRepository, - currentCwd = conversation.workspaceCwd, + val agentMode by ConversationAgentMode.getFlow(conversation.id).collectAsStateWithLifecycle() + val completionProviders = remember(assistant.workspaceId, conversation.workspaceCwd, workspaceRepository, agentMode) { + buildList { + add( + SlashCommandCompletionProvider( + currentMode = { ConversationAgentMode.get(conversation.id) } ) ) - }.orEmpty() + assistant.workspaceId?.let { workspaceId -> + add( + WorkspaceCompletionProvider( + workspaceId = workspaceId.toString(), + repository = workspaceRepository, + currentCwd = conversation.workspaceCwd, + ) + ) + } + } } TTSAutoPlay(vm = vm, setting = setting, conversation = conversation) @@ -392,7 +403,37 @@ private fun ChatPageContent( messageId = inputState.editingMessage!!, ) } else { - vm.handleMessageSend(inputState.getContents()) + val contents = inputState.getContents() + val textPart = contents.firstOrNull() as? UIMessagePart.Text + val rawText = textPart?.text?.trim().orEmpty() + + // Check slash commands + if (contents.size == 1 && textPart != null && rawText.equals("/plan", ignoreCase = true)) { + ConversationAgentMode.set(conversation.id, AgentMode.PLAN) + toaster.show("Switched to Plan mode", type = ToastType.Success) + inputState.clearInput() + return@ChatInput + } + if (contents.size == 1 && textPart != null && rawText.equals("/build", ignoreCase = true)) { + ConversationAgentMode.set(conversation.id, AgentMode.BUILD) + toaster.show("Switched to Build mode", type = ToastType.Success) + inputState.clearInput() + return@ChatInput + } + + val finalContents = if (textPart != null && rawText.startsWith("/plan ", ignoreCase = true)) { + ConversationAgentMode.set(conversation.id, AgentMode.PLAN) + toaster.show("Switched to Plan mode", type = ToastType.Success) + listOf(UIMessagePart.Text(rawText.removePrefix("/plan ").trim())) + contents.drop(1) + } else if (textPart != null && rawText.startsWith("/build ", ignoreCase = true)) { + ConversationAgentMode.set(conversation.id, AgentMode.BUILD) + toaster.show("Switched to Build mode", type = ToastType.Success) + listOf(UIMessagePart.Text(rawText.removePrefix("/build ").trim())) + contents.drop(1) + } else { + contents + } + + vm.handleMessageSend(finalContents) scope.launch { delay(100.milliseconds) chatListState.requestScrollToItem(conversation.currentMessages.size + 5) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 18b18eb2b66..19d1f3bee9e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2541,4 +2541,14 @@ Compatibility mode Try enabling this if the terminal fails to start. It may reduce performance. Applies to new terminal tabs and subsequent commands. Failed to save settings + Switched to Plan mode + Switched to Build mode + /plan + Switch to Plan mode (analyze and design without modifying files) + /build + Switch to Build mode (default agent execution and editing) + Accept Plan + Decline + Plan Approved · Switched to Build mode + Plan Declined From 6a11c8bc694b322e60c47eb272b95a678f3b17a1 Mon Sep 17 00:00:00 2001 From: Ammar-create Date: Sat, 10 Oct 2026 19:03:17 +0000 Subject: [PATCH 2/5] test(agent): add unit tests for AgentMode and SlashCommandCompletionProvider --- .../rerere/rikkahub/data/ai/AgentModeTest.kt | 77 +++++++++++++++++++ .../SlashCommandCompletionProviderTest.kt | 64 +++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 app/src/test/java/me/rerere/rikkahub/data/ai/AgentModeTest.kt create mode 100644 app/src/test/java/me/rerere/rikkahub/ui/components/ai/completion/SlashCommandCompletionProviderTest.kt diff --git a/app/src/test/java/me/rerere/rikkahub/data/ai/AgentModeTest.kt b/app/src/test/java/me/rerere/rikkahub/data/ai/AgentModeTest.kt new file mode 100644 index 00000000000..6badbb99c3d --- /dev/null +++ b/app/src/test/java/me/rerere/rikkahub/data/ai/AgentModeTest.kt @@ -0,0 +1,77 @@ +package me.rerere.rikkahub.data.ai + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.uuid.Uuid + +class AgentModeTest { + + @Test + fun defaultModeIsBuild() { + val convId = Uuid.random() + assertEquals(AgentMode.BUILD, ConversationAgentMode.get(convId)) + assertFalse(ConversationAgentMode.isPlanMode(convId)) + } + + @Test + fun switchingToPlanModeReflectsState() { + val convId = Uuid.random() + ConversationAgentMode.set(convId, AgentMode.PLAN) + assertEquals(AgentMode.PLAN, ConversationAgentMode.get(convId)) + assertTrue(ConversationAgentMode.isPlanMode(convId)) + + // Switching back to Build + ConversationAgentMode.set(convId, AgentMode.BUILD) + assertEquals(AgentMode.BUILD, ConversationAgentMode.get(convId)) + assertFalse(ConversationAgentMode.isPlanMode(convId)) + } + + @Test + fun clearResetsStateToBuild() { + val convId = Uuid.random() + ConversationAgentMode.set(convId, AgentMode.PLAN) + assertTrue(ConversationAgentMode.isPlanMode(convId)) + + ConversationAgentMode.clear(convId) + assertEquals(AgentMode.BUILD, ConversationAgentMode.get(convId)) + assertFalse(ConversationAgentMode.isPlanMode(convId)) + } + + @Test + fun planModeAllowedToolsOnlyAllowSafeInspection() { + // Allowed tools in Plan Mode + assertTrue(ConversationAgentMode.isToolAllowedInPlanMode("propose_plan")) + assertTrue(ConversationAgentMode.isToolAllowedInPlanMode("ask_user")) + assertTrue(ConversationAgentMode.isToolAllowedInPlanMode("get_time_info")) + assertTrue(ConversationAgentMode.isToolAllowedInPlanMode("workspace_read_file")) + assertTrue(ConversationAgentMode.isToolAllowedInPlanMode("workspace_read_folder")) + assertTrue(ConversationAgentMode.isToolAllowedInPlanMode("workspace_background_status")) + assertTrue(ConversationAgentMode.isToolAllowedInPlanMode("web_search")) + assertTrue(ConversationAgentMode.isToolAllowedInPlanMode("web_fetch")) + assertTrue(ConversationAgentMode.isToolAllowedInPlanMode("web_extract")) + assertTrue(ConversationAgentMode.isToolAllowedInPlanMode("read_window_tree")) + assertTrue(ConversationAgentMode.isToolAllowedInPlanMode("find_node")) + assertTrue(ConversationAgentMode.isToolAllowedInPlanMode("take_screenshot")) + assertTrue(ConversationAgentMode.isToolAllowedInPlanMode("browser_open")) + assertTrue(ConversationAgentMode.isToolAllowedInPlanMode("browser_get_text")) + + // Disallowed modifying/executing tools in Plan Mode + assertFalse(ConversationAgentMode.isToolAllowedInPlanMode("workspace_write_file")) + assertFalse(ConversationAgentMode.isToolAllowedInPlanMode("workspace_edit_file")) + assertFalse(ConversationAgentMode.isToolAllowedInPlanMode("workspace_create_folder")) + assertFalse(ConversationAgentMode.isToolAllowedInPlanMode("workspace_shell")) + assertFalse(ConversationAgentMode.isToolAllowedInPlanMode("workspace_run_background")) + assertFalse(ConversationAgentMode.isToolAllowedInPlanMode("workspace_background_kill")) + assertFalse(ConversationAgentMode.isToolAllowedInPlanMode("shizuku_exec")) + assertFalse(ConversationAgentMode.isToolAllowedInPlanMode("tap")) + assertFalse(ConversationAgentMode.isToolAllowedInPlanMode("click_node")) + assertFalse(ConversationAgentMode.isToolAllowedInPlanMode("swipe")) + assertFalse(ConversationAgentMode.isToolAllowedInPlanMode("scroll")) + assertFalse(ConversationAgentMode.isToolAllowedInPlanMode("set_text")) + assertFalse(ConversationAgentMode.isToolAllowedInPlanMode("browser_click")) + assertFalse(ConversationAgentMode.isToolAllowedInPlanMode("browser_type")) + assertFalse(ConversationAgentMode.isToolAllowedInPlanMode("browser_submit")) + } +} diff --git a/app/src/test/java/me/rerere/rikkahub/ui/components/ai/completion/SlashCommandCompletionProviderTest.kt b/app/src/test/java/me/rerere/rikkahub/ui/components/ai/completion/SlashCommandCompletionProviderTest.kt new file mode 100644 index 00000000000..6cddc697c5e --- /dev/null +++ b/app/src/test/java/me/rerere/rikkahub/ui/components/ai/completion/SlashCommandCompletionProviderTest.kt @@ -0,0 +1,64 @@ +package me.rerere.rikkahub.ui.components.ai.completion + +import androidx.compose.ui.text.TextRange +import kotlinx.coroutines.runBlocking +import me.rerere.rikkahub.data.ai.AgentMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class SlashCommandCompletionProviderTest { + + @Test + fun inBuildMode_onlyPlanIsSuggested() = runBlocking { + var currentMode = AgentMode.BUILD + val provider = SlashCommandCompletionProvider(currentMode = { currentMode }) + + // Typing "/" + val result = provider.complete(ChatCompletionContext("/", TextRange(1))) + assertNotNull(result) + assertEquals(1, result!!.items.size) + assertEquals("/plan", result.items[0].label) + + // Typing "/p" + val pResult = provider.complete(ChatCompletionContext("/p", TextRange(2))) + assertNotNull(pResult) + assertEquals("/plan", pResult!!.items[0].label) + + // Typing "/b" in Build mode returns nothing (/build is never offered) + val bResult = provider.complete(ChatCompletionContext("/b", TextRange(2))) + assertNull(bResult) + } + + @Test + fun inPlanMode_onlyBuildIsSuggested() = runBlocking { + var currentMode = AgentMode.PLAN + val provider = SlashCommandCompletionProvider(currentMode = { currentMode }) + + // Typing "/" + val result = provider.complete(ChatCompletionContext("/", TextRange(1))) + assertNotNull(result) + assertEquals(1, result!!.items.size) + assertEquals("/build", result.items[0].label) + + // Typing "/b" + val bResult = provider.complete(ChatCompletionContext("/b", TextRange(2))) + assertNotNull(bResult) + assertEquals("/build", bResult!!.items[0].label) + + // Typing "/p" in Plan mode returns nothing (/plan is never offered) + val pResult = provider.complete(ChatCompletionContext("/p", TextRange(2))) + assertNull(pResult) + } + + @Test + fun nonSlashInput_returnsNull() = runBlocking { + val provider = SlashCommandCompletionProvider(currentMode = { AgentMode.BUILD }) + + // Normal text + assertNull(provider.complete(ChatCompletionContext("hello", TextRange(5)))) + // Slash inside a word + assertNull(provider.complete(ChatCompletionContext("path/sub", TextRange(8)))) + } +} From 17f3e30d4e69b9498039a3ee3eb9e9a72f871412 Mon Sep 17 00:00:00 2001 From: Ammar-create Date: Sat, 10 Oct 2026 19:12:12 +0000 Subject: [PATCH 3/5] ci: add GitHub Actions workflow to build debug APK --- .github/workflows/build-debug.yml | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/build-debug.yml diff --git a/.github/workflows/build-debug.yml b/.github/workflows/build-debug.yml new file mode 100644 index 00000000000..2b3c59488bc --- /dev/null +++ b/.github/workflows/build-debug.yml @@ -0,0 +1,49 @@ +name: Build Debug APK + +on: + push: + branches: [ feat/*, main, master ] + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 11 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: web-ui/pnpm-lock.yaml + + - name: Install web-ui dependencies + working-directory: web-ui + run: pnpm install + + - name: Build Debug APK + run: | + chmod +x gradlew + ./gradlew assembleDebug --stacktrace + + - name: Upload Debug APK + uses: actions/upload-artifact@v4 + with: + name: rikkahub-agent-debug-apk + path: app/build/outputs/apk/debug/*.apk + retention-days: 7 From 4ab38f81523f9d528628b406513a850eb90670a6 Mon Sep 17 00:00:00 2001 From: Ammar-create Date: Sat, 10 Oct 2026 19:18:47 +0000 Subject: [PATCH 4/5] ci: add oven-sh/setup-bun to install bun on PATH --- .github/workflows/build-debug.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-debug.yml b/.github/workflows/build-debug.yml index 2b3c59488bc..0f4e059d98b 100644 --- a/.github/workflows/build-debug.yml +++ b/.github/workflows/build-debug.yml @@ -20,6 +20,9 @@ jobs: distribution: 'temurin' java-version: '21' + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + - name: Set up pnpm uses: pnpm/action-setup@v4 with: @@ -29,12 +32,12 @@ jobs: uses: actions/setup-node@v4 with: node-version: 22 - cache: pnpm - cache-dependency-path: web-ui/pnpm-lock.yaml - name: Install web-ui dependencies working-directory: web-ui - run: pnpm install + run: | + bun install + pnpm install - name: Build Debug APK run: | From 3eb2445a22b12eec0dabc3d4cf3fa07285a69777 Mon Sep 17 00:00:00 2001 From: Ammar-create Date: Sat, 10 Oct 2026 19:35:29 +0000 Subject: [PATCH 5/5] fix(tools): safe call nullable callerConversationId in propose_plan tool --- .github/workflows/build-debug.yml | 2 +- .../main/java/me/rerere/rikkahub/data/ai/tools/LocalTools.kt | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-debug.yml b/.github/workflows/build-debug.yml index 0f4e059d98b..50397f2049c 100644 --- a/.github/workflows/build-debug.yml +++ b/.github/workflows/build-debug.yml @@ -48,5 +48,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: rikkahub-agent-debug-apk - path: app/build/outputs/apk/debug/*.apk + path: app/build/outputs/apk/**/*.apk retention-days: 7 diff --git a/app/src/main/java/me/rerere/rikkahub/data/ai/tools/LocalTools.kt b/app/src/main/java/me/rerere/rikkahub/data/ai/tools/LocalTools.kt index ce4fba9efe6..dbd109d372c 100644 --- a/app/src/main/java/me/rerere/rikkahub/data/ai/tools/LocalTools.kt +++ b/app/src/main/java/me/rerere/rikkahub/data/ai/tools/LocalTools.kt @@ -754,9 +754,7 @@ class LocalTools( needsApproval = { true }, execute = { val convId = runCatching { - if (invocationContext.callerConversationId.isNotBlank()) { - kotlin.uuid.Uuid.parse(invocationContext.callerConversationId) - } else null + invocationContext.callerConversationId?.takeIf { it.isNotBlank() }?.let { kotlin.uuid.Uuid.parse(it) } }.getOrNull() if (convId != null) { me.rerere.rikkahub.data.ai.ConversationAgentMode.set(convId, me.rerere.rikkahub.data.ai.AgentMode.BUILD)