Merge pull request #1069 from Hy4ri/dev

release: Hermes Mobile v1.26.0
This commit is contained in:
M57 authored and GitHub committed 2026-09-05 15:13:37 +03:00
commit a15e609c9a
45 files changed
+4070 -873

No files matched your search

@@ -55,7 +55,7 @@ class ServerStoreMigration(
runCatching { ThemePreference.valueOf(themePrefString!!) }
.getOrDefault(ThemePreference.SYSTEM)
val useDynamicColors = prefs.getBoolean("use_dynamic_colors", true)
val useDynamicColors = prefs.getBoolean("use_dynamic_colors", false)
val themePresetString = prefs.getString("theme_preset", ThemePreset.DEFAULT.name)
val themePreset =
@@ -13,7 +13,7 @@ data class ServerStoreState(
val baseUrl: String? = null,
val autoReconnect: Boolean = true,
val themePreference: ThemePreference = ThemePreference.SYSTEM,
val useDynamicColors: Boolean = true,
val useDynamicColors: Boolean = false,
val themePreset: ThemePreset = ThemePreset.DEFAULT,
val connectionProfiles: List<ConnectionProfile> = emptyList(),
val selectedProfileId: String? = null,
@@ -68,7 +68,7 @@ object AuthManager {
private val _themePreferenceFlow = MutableStateFlow<ThemePreference>(ThemePreference.SYSTEM)
val themePreferenceFlow: StateFlow<ThemePreference> = _themePreferenceFlow.asStateFlow()
private val _useDynamicColorsFlow = MutableStateFlow<Boolean>(true)
private val _useDynamicColorsFlow = MutableStateFlow<Boolean>(false)
val useDynamicColorsFlow: StateFlow<Boolean> = _useDynamicColorsFlow.asStateFlow()
private val _themePresetFlow = MutableStateFlow<ThemePreset>(ThemePreset.DEFAULT)
@@ -18,4 +18,7 @@ interface ChatMessageDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(messages: List<ChatMessageEntity>)
@Query("DELETE FROM chat_messages WHERE session_id = :sessionId")
suspend fun deleteMessagesForSession(sessionId: String)
}
@@ -194,6 +194,7 @@ interface HermesApiService {
// legacy backends that don't know the param — they ignore it.
@Query("order") order: String? = null,
@Query("include_compacted") includeCompacted: Boolean? = true,
@Query("profile") profile: String? = null,
): Response<SessionMessagesResponse>
@GET("api/sessions/stats")
@@ -134,17 +134,90 @@ object EventParser {
"clarify.request" -> {
// Gateway sends "question"/"choices" — fall back to "text"/"options" for any
// older client or test that still uses the legacy field names. (Issue #206)
val text =
payload?.get("question") as? String
?: payload?.get("text") as? String
val rawOptions =
payload?.get("choices")
?: payload?.get("options")
// Batch clarify (issue #18450): gateway sends "questions" array of {qid, question, choices, multi_select}.
val rawQuestions = payload?.get("questions") as? List<*>
val clarifyId = payload?.get("clarify_id") as? String ?: payload?.get("request_id") as? String
val parsedQuestions =
if (rawQuestions != null && rawQuestions.isNotEmpty()) {
rawQuestions.mapIndexedNotNull { index, item ->
val map = item as? Map<*, *> ?: return@mapIndexedNotNull null
val qText = map["question"] as? String ?: return@mapIndexedNotNull null
val qid = map["qid"] as? String ?: "q$index"
@Suppress("UNCHECKED_CAST")
val qChoices = (map["choices"] as? List<*>)?.filterIsInstance<String>() ?: emptyList()
val qMulti = map["multi_select"] as? Boolean ?: false
WsEvent.ClarifyQuestion(
qid = qid,
question = qText,
choices = qChoices,
multiSelect = qMulti,
)
}
} else {
val text =
payload?.get("question") as? String
?: payload?.get("text") as? String
val rawOptions = payload?.get("choices") ?: payload?.get("options")
@Suppress("UNCHECKED_CAST")
val options = (rawOptions as? List<*>)?.filterIsInstance<String>() ?: emptyList()
val qid = payload?.get("qid") as? String ?: payload?.get("question_id") as? String
val multi = payload?.get("multi_select") as? Boolean ?: false
if ((!text.isNullOrBlank() || options.isNotEmpty()) && qid != null) {
listOf(
WsEvent.ClarifyQuestion(
qid = qid,
question = text.orEmpty(),
choices = options,
multiSelect = multi,
),
)
} else {
emptyList()
}
}
val primary = parsedQuestions.firstOrNull()
val legacyText =
if (parsedQuestions.size > 1) {
parsedQuestions.mapIndexed { index, q -> "${index + 1}. ${q.question}" }.joinToString("\n\n")
} else if (primary != null) {
primary.question
} else {
payload?.get("question") as? String
?: payload?.get("text") as? String
}
@Suppress("UNCHECKED_CAST")
val options = (rawOptions as? List<*>)?.filterIsInstance<String>()
WsEvent.ClarifyRequest(text, options, clarifyId, sessionId)
val legacyOptions =
primary?.choices
?: (payload?.get("choices") ?: payload?.get("options"))
.let { (it as? List<*>)?.filterIsInstance<String>() }
val legacyQid =
primary?.qid
?: payload?.get("qid") as? String
?: payload?.get("question_id") as? String
val legacyMulti =
primary?.multiSelect
?: payload?.get("multi_select") as? Boolean
?: false
WsEvent.ClarifyRequest(
text = legacyText,
options = legacyOptions,
clarifyId = clarifyId,
sessionId = sessionId,
questionId = legacyQid,
multiSelect = legacyMulti,
questions = parsedQuestions,
)
}
"clarify.expire" -> {
val clarifyId = payload?.get("request_id") as? String ?: payload?.get("clarify_id") as? String
WsEvent.ClarifyExpire(clarifyId, sessionId)
}
"status.update" -> {
@@ -210,7 +283,40 @@ object EventParser {
@Suppress("UNCHECKED_CAST")
val patternKeys = (payload?.get("pattern_keys") as? List<*>)?.filterIsInstance<String>()
WsEvent.ApprovalRequest(command, description, patternKeys, sessionId)
val requestId = payload?.get("request_id") as? String
@Suppress("UNCHECKED_CAST")
val rawChoices = (payload?.get("choices") as? List<*>)?.filterIsInstance<String>()
val allowPermanent = payload?.get("allow_permanent") as? Boolean
val allowSession = payload?.get("allow_session") as? Boolean
val smartDenied = payload?.get("smart_denied") as? Boolean
// Backend default (server.py `_approval_request_payload`):
// smart-denied → once/deny only; else once + session? + always? + deny.
val choices =
rawChoices ?: run {
if (smartDenied == true) {
listOf("once", "deny")
} else {
buildList {
add("once")
if (allowSession != false) {
add("session")
if (allowPermanent != false) add("always")
}
add("deny")
}
}
}
WsEvent.ApprovalRequest(
command,
description,
patternKeys,
sessionId,
requestId,
choices,
allowPermanent,
smartDenied,
)
}
"sudo.request" -> {
@@ -218,9 +324,21 @@ object EventParser {
WsEvent.SudoRequest(requestId, sessionId)
}
"sudo.expire" -> {
val requestId = payload?.get("request_id") as? String
WsEvent.SudoExpire(requestId, sessionId)
}
"secret.request" -> {
val requestId = payload?.get("request_id") as? String
WsEvent.SecretRequest(requestId, sessionId)
val envVar = payload?.get("env_var") as? String
val prompt = payload?.get("prompt") as? String
WsEvent.SecretRequest(requestId, sessionId, envVar, prompt)
}
"secret.expire" -> {
val requestId = payload?.get("request_id") as? String
WsEvent.SecretExpire(requestId, sessionId)
}
else -> {
@@ -1226,9 +1226,7 @@ object HermesWsClient {
disconnectIfIdleInBackground()
}
else -> {
Unit
}
else -> {}
}
// tryEmit on a DROP_OLDEST flow only returns false when the
// buffer is full AND no subscriber is draining; with extraBuffer=512
@@ -135,11 +135,26 @@ sealed class WsEvent {
// ── Interactive ──────────────────────────────────────────────────────
data class ClarifyQuestion(
val qid: String = "q0",
val question: String = "",
val choices: List<String> = emptyList(),
val multiSelect: Boolean = false,
)
data class ClarifyRequest(
val text: String?,
val options: List<String>?,
val clarifyId: String? = null,
val sessionId: String? = null,
val questionId: String? = null,
val multiSelect: Boolean = false,
val questions: List<ClarifyQuestion> = emptyList(),
) : WsEvent()
data class ClarifyExpire(
val clarifyId: String? = null,
val sessionId: String? = null,
) : WsEvent()
/**
@@ -215,6 +230,30 @@ sealed class WsEvent {
val description: String?,
val patternKeys: List<String>?,
val sessionId: String?,
/**
* Backend `request_id` — unique across sessions. Sent back as
* `request_id` in `approval.respond` so the gateway resolves the
* EXACT pending instead of FIFO-oldest (desktop parity:
* `prompts.ts` `receiveApprovalRequest` + `approval.tsx` respond).
* Null on legacy payloads → omit, FIFO-compatible.
*/
val requestId: String? = null,
/**
* Backend-advertised choices (e.g. `["once","session","always","deny"]`,
* smart-denied → `["once","deny"]`). Null = legacy → UI falls back
* to Run/Deny.
*/
val choices: List<String>? = null,
/**
* False when the backend won't honor a permanent allow (tirith
* warning) → hide "Always allow" (desktop `allowPermanent` parity).
*/
val allowPermanent: Boolean? = null,
/**
* True when this is an owner override of a Smart DENY — backend
* restricts to once/deny only (desktop `smartDenied` parity).
*/
val smartDenied: Boolean? = null,
) : WsEvent()
// ── Sudo / secret requests ─────────────────────────────────────────
@@ -229,14 +268,37 @@ sealed class WsEvent {
val sessionId: String?,
) : WsEvent()
/**
* Backend sudo timeout (120s `_block`) — clears the matching dialog.
* Payload: `{ request_id }`. Only clears when ids match so a late
* expire for an old prompt never kills the current one (desktop parity).
*/
data class SudoExpire(
val requestId: String?,
val sessionId: String?,
) : WsEvent()
/**
* Backend needs a secret value (password / token) to continue a turn
* (desktop: `secret.request` → `secret.respond {request_id, value}`).
* Mobile previously dropped this and the agent hung forever.
* `envVar`/`prompt` mirror desktop: title = envVar ?: secretTitle,
* body = prompt ?: secretDesc.
*/
data class SecretRequest(
val requestId: String?,
val sessionId: String?,
val envVar: String? = null,
val prompt: String? = null,
) : WsEvent()
/**
* Backend secret timeout — clears the matching dialog.
* Payload: `{ request_id }`. Match-only clear like [SudoExpire].
*/
data class SecretExpire(
val requestId: String?,
val sessionId: String?,
) : WsEvent()
// ── Gateway-level errors ───────────────────────────────────────────
@@ -34,6 +34,12 @@ object WsMethods {
const val PROMPT_BTW = "prompt.btw"
const val CLARIFY_RESPOND = "clarify.respond"
const val APPROVAL_RESPOND = "approval.respond"
/** Ack that the client rendered a pending approval (desktop parity). */
const val APPROVAL_RECEIVED = "approval.received"
/** List unresolved approvals for a session (reconnect replay). */
const val APPROVAL_PENDING = "approval.pending"
const val SUDO_RESPOND = "sudo.respond"
const val SECRET_RESPOND = "secret.respond"
@@ -119,6 +119,26 @@ class ChatNotificationService : Service() {
showReplyNotification(getString(R.string.notif_clarification_needed), null)
}
is WsEvent.SudoRequest -> {
showReplyNotification(getString(R.string.notif_input_needed), null)
}
is WsEvent.SecretRequest -> {
val body =
event.prompt?.takeIf { it.isNotBlank() }
?: event.envVar?.takeIf { it.isNotBlank() }
?: getString(R.string.notif_input_needed)
showReplyNotification(body, null)
}
is WsEvent.ApprovalRequest -> {
val preview =
event.description?.takeIf { it.isNotBlank() }
?: event.command?.takeIf { it.isNotBlank() }
?: getString(R.string.notif_input_needed)
showReplyNotification(preview.take(100), null)
}
else -> {}
}
}
@@ -95,8 +95,8 @@ private fun themeFor(preset: ThemePreset): ThemePalette = when (preset) {
}
```
Dynamic (Material You) color on API 31+ overrides the preset scheme when
`useDynamicColors = true`. Semantic status colors are always resolved from the
Dynamic (Material You) color on API 31+ can optionally override the preset scheme when
`useDynamicColors = true` (defaults to `false`). Semantic status colors are always resolved from the
active preset via `LocalHermesStatusColors`.
## Adding a new theme
@@ -74,7 +74,7 @@ internal fun resolveStatusColors(
@Composable
fun HermesControlTheme(
themePreference: ThemePreference = LocalThemePreference.current,
useDynamicColors: Boolean = true,
useDynamicColors: Boolean = false,
themePreset: ThemePreset = ThemePreset.DEFAULT,
chatFontScale: Float = LocalChatFontScale.current,
content: @Composable () -> Unit,
@@ -12,6 +12,10 @@ data class GroupChatToolCall(
val name: String,
val summary: String? = null,
val isRunning: Boolean = false,
val command: String? = null,
val output: String? = null,
val exitCode: Int? = null,
val isError: Boolean = false,
)
/**
@@ -28,9 +32,13 @@ data class GroupChatMessage(
val isStreaming: Boolean = false,
val thread: String? = null,
val isSystem: Boolean = false,
val isPass: Boolean = false,
val toolCalls: List<GroupChatToolCall> = emptyList(),
)
const val STOPPED_SYSTEM_TEXT = "Discussion stopped"
const val CAPPED_SYSTEM_TEXT = "Discussion paused (turn limit reached) — reply to continue"
/**
* Pure helper for mention extraction and responder resolution matching Desktop parity.
*/
File diff suppressed because it is too large. Load diff
@@ -18,6 +18,7 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.chat.tool.ToolResultSummary
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
@@ -52,11 +53,14 @@ data class MemberSession(
const val DEFAULT_MAX_BOT_MESSAGES = 6
const val DEFAULT_MAX_CONTINUATION_PASSES = 2
private const val LEASE_STEP_TTL_MS = 45_000L
private const val TURN_TIMEOUT_MS = 45_000L
const val MAX_ALLOWED_BOT_MESSAGES = 50
const val MAX_ALLOWED_CONTINUATION_PASSES = 20
const val WARN_MAX_BOT_MESSAGES = 20
const val WARN_MAX_CONTINUATION_PASSES = 6
private const val LEASE_STEP_TTL_MS = 180_000L
private const val GROUP_TURN_TIMEOUT_MS = 180_000L
private const val GROUP_TURN_HARD_CAP_MS = 20 * 60_000L
private const val HISTORY_LIMIT = 10
private const val CAPPED_SYSTEM_TEXT = "Discussion paused (turn limit reached) — reply to continue"
private const val STOPPED_SYSTEM_TEXT = "Discussion stopped"
class GroupChatViewModel(
private var groupName: String = "",
@@ -84,6 +88,9 @@ class GroupChatViewModel(
// Map of session_id -> bot profile
private val sessionToBot = mutableMapOf<String, ProfileInfo>()
// Map of session_id -> last activity epoch ms (streaming token, tool call, thinking)
private val sessionLastActivity = mutableMapOf<String, Long>()
init {
if (groupName.isNotBlank()) {
loadGroup()
@@ -104,10 +111,26 @@ class GroupChatViewModel(
inFlightTurns.clear()
activeStreamMsgId.clear()
sessionToBot.clear()
sessionLastActivity.clear()
_uiState.value = GroupChatUiState(groupName = name)
loadGroup()
}
private fun extractToolCommand(data: Map<String, Any?>?): String? {
if (data == null) return null
val cmd = (data["command"] as? String)?.trim()
if (!cmd.isNullOrBlank()) return cmd
val code = (data["code"] as? String)?.trim()
if (!code.isNullOrBlank()) return code
val query = (data["query"] as? String)?.trim()
if (!query.isNullOrBlank()) return query
val path = (data["path"] as? String)?.trim()
if (!path.isNullOrBlank()) return path
val pattern = (data["pattern"] as? String)?.trim()
if (!pattern.isNullOrBlank()) return pattern
return null
}
private fun extractToolSummary(data: Map<String, Any?>?): String? {
if (data == null) return null
val cmd = (data["command"] as? String)?.trim()
@@ -121,6 +144,21 @@ class GroupChatViewModel(
return null
}
private fun extractToolOutput(data: Map<String, Any?>?): String? {
if (data == null) return null
val rawStr =
(data["output"] as? String)
?: (data["stdout"] as? String)
?: (data["result"] as? String)
if (!rawStr.isNullOrBlank()) {
return rawStr
}
val resultElement = data["result"]?.toJsonElement() ?: data["output"]?.toJsonElement() ?: data.toJsonElement()
val summary = ToolResultSummary.formatToolResultSummary(resultElement)
return summary.ifBlank { null }
}
private fun observeWsEvents() {
viewModelScope.launch(ioDispatcher) {
HermesWsClient.events.collect { event ->
@@ -128,6 +166,7 @@ class GroupChatViewModel(
is WsEvent.MessageToken -> {
val sid = event.sessionId?.trim('\"', ' ')
if (sid != null) {
sessionLastActivity[sid] = System.currentTimeMillis()
val streamId = activeStreamMsgId[sid]
val bot = sessionToBot[sid]
if (streamId != null && bot != null && event.token.isNotEmpty()) {
@@ -163,6 +202,7 @@ class GroupChatViewModel(
is WsEvent.ToolStart -> {
val sid = event.sessionId?.trim('\"', ' ')
if (sid != null) {
sessionLastActivity[sid] = System.currentTimeMillis()
val streamId = activeStreamMsgId[sid]
val bot = sessionToBot[sid]
val toolName = event.name ?: "tool"
@@ -170,11 +210,13 @@ class GroupChatViewModel(
(event.data?.get("tool_id") as? String)?.ifBlank { null }
?: UUID.randomUUID().toString()
val summary = extractToolSummary(event.data)
val command = extractToolCommand(event.data)
val toolCall =
GroupChatToolCall(
id = toolId,
name = toolName,
summary = summary,
command = command,
isRunning = true,
)
if (streamId != null && bot != null) {
@@ -211,9 +253,21 @@ class GroupChatViewModel(
is WsEvent.ToolComplete -> {
val sid = event.sessionId?.trim('\"', ' ')
if (sid != null) {
sessionLastActivity[sid] = System.currentTimeMillis()
val streamId = activeStreamMsgId[sid]
val toolId = event.data?.get("tool_id") as? String
val toolName = event.name
val output = extractToolOutput(event.data)
val exitCode =
when (val rawCode = event.data?.get("exit_code")) {
is Number -> rawCode.toInt()
is String -> rawCode.toIntOrNull()
else -> null
}
val isError =
(exitCode != null && exitCode != 0) ||
(event.data?.get("is_error") as? Boolean == true) ||
(event.data?.get("error") != null)
if (streamId != null) {
_uiState.update { state ->
val existing = state.messages.find { it.id == streamId }
@@ -224,7 +278,12 @@ class GroupChatViewModel(
(toolId == null && tool.name == toolName && tool.isRunning) ||
(toolId == null && toolName == null && tool.isRunning)
) {
tool.copy(isRunning = false)
tool.copy(
isRunning = false,
output = output ?: tool.output,
exitCode = exitCode ?: tool.exitCode,
isError = isError || tool.isError,
)
} else {
tool
}
@@ -424,8 +483,8 @@ class GroupChatViewModel(
maxPasses: Int,
systemPrompt: String? = null,
) {
val clampedMessages = maxMessages.coerceIn(1, 20)
val clampedPasses = maxPasses.coerceIn(0, 10)
val clampedMessages = maxMessages.coerceIn(1, MAX_ALLOWED_BOT_MESSAGES)
val clampedPasses = maxPasses.coerceIn(0, MAX_ALLOWED_CONTINUATION_PASSES)
val cleanPrompt = systemPrompt?.trim()?.ifBlank { null }
_uiState.update {
@@ -681,10 +740,34 @@ class GroupChatViewModel(
}
}
var replyText =
withTimeoutOrNull(TURN_TIMEOUT_MS) {
turnDeferred.await()
var replyText: String? = null
var deadline = turnStartTime + GROUP_TURN_TIMEOUT_MS
val hardCapDeadline = turnStartTime + GROUP_TURN_HARD_CAP_MS
while (System.currentTimeMillis() < deadline) {
val pollResult =
withTimeoutOrNull(2000L) {
turnDeferred.await()
}
if (pollResult != null) {
replyText = pollResult
break
}
if (activeEpoch != epoch) break
// Adaptive extension: if bot is actively working (tokens/tools streamed in last 60s), extend deadline
val lastActivity =
listOfNotNull(
activeRuntimeId?.let { sessionLastActivity[it] },
activeStoredId?.let { sessionLastActivity[it] },
).maxOrNull() ?: turnStartTime
val isBusy = (System.currentTimeMillis() - lastActivity) < 60_000L
if (isBusy) {
deadline =
minOf(hardCapDeadline, maxOf(deadline, System.currentTimeMillis() + GROUP_TURN_TIMEOUT_MS))
}
}
inFlightTurns.remove(runtimeId)
storedId?.let { inFlightTurns.remove(it) }
@@ -698,6 +781,7 @@ class GroupChatViewModel(
sessionId = tid,
limit = 10,
order = "latest",
profile = bot.name,
)
}
if (res is NetworkResult.Success) {
@@ -751,6 +835,28 @@ class GroupChatViewModel(
state.copy(messages = updatedList)
}
return replyText.trim()
} else if (replyText != null && isPass(replyText)) {
_uiState.update { state ->
val existing = state.messages.find { it.id == streamMsgId }
val passMsg =
GroupChatMessage(
id = streamMsgId,
senderName = bot.name,
senderDisplayName = bot.effectiveTitle,
isUser = false,
isSystem = true,
isPass = true,
text = "${bot.effectiveTitle} passed",
)
val updatedList =
if (existing != null) {
state.messages.map { if (it.id == streamMsgId) passMsg else it }
} else {
state.messages + passMsg
}
state.copy(messages = updatedList)
}
return null
} else {
_uiState.update { state ->
state.copy(messages = state.messages.filter { it.id != streamMsgId })
@@ -768,11 +874,13 @@ class GroupChatViewModel(
activeStreamMsgId.remove(it)
sessionToBot.remove(it)
inFlightTurns.remove(it)
sessionLastActivity.remove(it)
}
activeStoredId?.let {
activeStreamMsgId.remove(it)
sessionToBot.remove(it)
inFlightTurns.remove(it)
sessionLastActivity.remove(it)
}
}
}
@@ -13,6 +13,8 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -31,6 +33,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Psychology
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -41,7 +44,9 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -58,6 +63,7 @@ import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@@ -65,8 +71,10 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
@@ -75,6 +83,7 @@ import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LightOnSurface
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.onColorFor
import com.m57.hermescontrol.util.BidiUtils
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.time.Instant
@@ -176,67 +185,79 @@ fun ChatBubble(
color = Color.Transparent,
tonalElevation = 0.dp,
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
SelectionContainer {
Text(
text = highlightedText,
color = userBubbleTextColor,
style = MaterialTheme.typography.bodyMedium,
)
}
// Render inline attachments
if (!message.attachments.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
message.attachments.forEach { attachment ->
InlineAttachment(
attachment = attachment,
textColor = userBubbleTextColor,
onOpen = { onOpenAttachment(it) },
onSave = { onSaveAttachment(it) },
savingPath = savingAttachmentPath,
openingPath = openingAttachmentPath,
canSave = canSaveAttachment,
onImageClick = onImageClick,
val isRtl = remember(message.content) { BidiUtils.isRtlText(message.content) }
val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
SelectionContainer {
Text(
text = highlightedText,
color = userBubbleTextColor,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection =
if (isRtl) {
TextDirection.Rtl
} else {
TextDirection.Ltr
},
),
)
Spacer(modifier = Modifier.height(4.dp))
}
}
if (!message.isStreaming) {
Row(
modifier =
Modifier
.align(Alignment.End)
.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(null, message.content)),
)
}
copied = true
},
modifier = Modifier.size(20.dp),
// Render inline attachments
if (!message.attachments.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
message.attachments.forEach { attachment ->
InlineAttachment(
attachment = attachment,
textColor = userBubbleTextColor,
onOpen = { onOpenAttachment(it) },
onSave = { onSaveAttachment(it) },
savingPath = savingAttachmentPath,
openingPath = openingAttachmentPath,
canSave = canSaveAttachment,
onImageClick = onImageClick,
)
Spacer(modifier = Modifier.height(4.dp))
}
}
if (!message.isStreaming) {
Row(
modifier =
Modifier
.align(Alignment.End)
.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = stringResource(R.string.content_desc_copy),
modifier = Modifier.size(12.dp),
tint = userBubbleTextColor.copy(alpha = 0.7f),
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(null, message.content)),
)
}
copied = true
},
modifier = Modifier.size(20.dp),
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = stringResource(R.string.content_desc_copy),
modifier = Modifier.size(12.dp),
tint = userBubbleTextColor.copy(alpha = 0.7f),
)
}
Spacer(modifier = Modifier.width(2.dp))
Text(
text =
formatTimestamp(
message.timestamp,
DateFormat.is24HourFormat(LocalContext.current),
),
color = userBubbleTextColor.copy(alpha = 0.6f),
style = MaterialTheme.typography.labelSmall,
)
}
Spacer(modifier = Modifier.width(2.dp))
Text(
text =
formatTimestamp(
message.timestamp,
DateFormat.is24HourFormat(LocalContext.current),
),
color = userBubbleTextColor.copy(alpha = 0.6f),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@@ -304,6 +325,7 @@ private fun SelfImprovementReviewCard(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun SystemBubble(
message: ChatMessage,
@@ -315,6 +337,9 @@ internal fun SystemBubble(
return
}
val approvalInfo = message.approvalInfo
var confirmAlways by remember(message.id) { mutableStateOf(false) }
Column(
modifier =
modifier
@@ -331,52 +356,145 @@ internal fun SystemBubble(
),
)
// Approval action buttons
if (message.approvalInfo != null) {
// Approval action buttons — dynamic from backend `choices`
// (desktop `approval.tsx` parity: once/session/always/deny,
// smart-denied → once/deny only, allowPermanent=false hides Always).
if (approvalInfo != null) {
Spacer(Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
val rawChoices = approvalInfo.choices ?: listOf("once", "deny")
val visibleChoices =
rawChoices.filter { choice ->
when (choice) {
"always" -> approvalInfo.allowPermanent != false
else -> true
}
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
FilledTonalButton(
onClick = { onRespondApproval("approve") },
modifier =
Modifier
.height(36.dp)
.testTag("approve_button"),
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Approve")
}
for (choice in visibleChoices) {
when (choice) {
"deny" -> {
FilledTonalButton(
onClick = { onRespondApproval("deny") },
modifier =
Modifier
.height(36.dp)
.testTag("deny_button"),
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Deny")
}
}
FilledTonalButton(
onClick = { onRespondApproval("deny") },
modifier =
Modifier
.height(36.dp)
.testTag("deny_button"),
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Deny")
"session" -> {
FilledTonalButton(
onClick = { onRespondApproval("session") },
modifier =
Modifier
.height(36.dp)
.testTag("session_button"),
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
),
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Allow session")
}
}
"always" -> {
FilledTonalButton(
onClick = { confirmAlways = true },
modifier =
Modifier
.height(36.dp)
.testTag("always_button"),
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
),
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Always allow")
}
}
else -> {
// "once" (+ legacy "approve" → normalized to once in VM)
FilledTonalButton(
onClick = { onRespondApproval(choice) },
modifier =
Modifier
.height(36.dp)
.testTag("approve_button"),
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text(if (choice == "approve") "Approve" else "Run")
}
}
}
}
}
// "Always allow" persists the pattern permanently — confirm first
// (desktop confirm-modal parity).
if (confirmAlways) {
AlertDialog(
onDismissRequest = { confirmAlways = false },
title = { Text("Always allow this command?") },
text = {
Text(
"This persists the pattern permanently so future " +
"matching commands run without asking.",
)
},
confirmButton = {
TextButton(
onClick = {
confirmAlways = false
onRespondApproval("always")
},
) {
Text("Always allow")
}
},
dismissButton = {
TextButton(onClick = { confirmAlways = false }) {
Text("Cancel")
}
},
)
}
}
}
}
@@ -5,13 +5,21 @@ import java.util.UUID
/**
* Metadata for a [ChatMessage] that represents an approval request.
* When present, the UI renders Approve/Deny buttons inline.
* When present, the UI renders approval buttons inline.
* Transient — not persisted to SQLite.
*/
data class ApprovalInfo(
val command: String?,
val description: String?,
val patternKeys: List<String>?,
/** Backend `request_id` — sent back in `approval.respond` to pin the exact pending. */
val requestId: String? = null,
/** Backend-advertised choices (`once`/`session`/`always`/`deny`). Null = legacy Run/Deny. */
val choices: List<String>? = null,
/** False → hide "Always allow" (tirith warning, desktop parity). */
val allowPermanent: Boolean? = null,
/** True → Smart DENY override, once/deny only. */
val smartDenied: Boolean? = null,
)
/**
@@ -66,6 +74,11 @@ data class ChatMessage(
* them as centered timeline chips instead of user bubbles.
*/
val displayKind: String? = null,
/**
* Timestamp (ms) when the assistant message finished streaming.
* When present, rendered at the bottom beside the copy button.
*/
val finishTimestamp: Long? = null,
)
/**
@@ -33,4 +33,9 @@ open class ChatPersistenceRepository(
/** Load cached messages for a session from Room. */
suspend fun loadMessages(sessionId: String): List<ChatMessage> =
dao.getMessagesForSession(sessionId).map { it.toUiModel() }
/** Clear all cached messages for a session (e.g. after /undo rewind). */
suspend fun clearMessagesForSession(sessionId: String) {
dao.deleteMessagesForSession(sessionId)
}
}
@@ -38,6 +38,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -289,6 +290,14 @@ fun ChatScreen(
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(""))
}
LaunchedEffect(state.pendingPrefillText) {
val prefill = state.pendingPrefillText
if (prefill != null) {
inputFieldValue = ChatInputPolicy.commandFieldValue(prefill)
viewModel.consumePendingPrefill()
}
}
var isListening by rememberSaveable { mutableStateOf(false) }
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
@@ -711,6 +720,7 @@ fun ChatScreen(
viewModel = viewModel,
clarifyRequest = state.clarifyRequest,
onRespondClarify = viewModel::respondToClarify,
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
onDismissClarify = viewModel::dismissClarify,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
@@ -806,7 +816,6 @@ fun ChatScreen(
isAgentTyping = state.isAgentTyping,
isConnected = state.isConnected,
commandCatalog = state.commandCatalog,
availableBots = state.availableBots,
slashUsageCounts = state.slashUsageCounts,
pendingAttachments = state.pendingAttachments,
onCameraTap = {
@@ -911,6 +920,25 @@ fun ChatScreen(
)
}
// Expensive / Data-policy model confirmation dialog (issue #589 follow-up)
if (state.modelSwitchConfirmMessage != null) {
AlertDialog(
onDismissRequest = { viewModel.dismissModelSwitchConfirm() },
title = { Text(stringResource(R.string.model_expensive_title)) },
text = { Text(state.modelSwitchConfirmMessage!!) },
confirmButton = {
Button(onClick = { viewModel.confirmModelSwitchExpensive() }) {
Text(stringResource(R.string.model_confirm_continue))
}
},
dismissButton = {
TextButton(onClick = { viewModel.dismissModelSwitchConfirm() }) {
Text(stringResource(R.string.common_cancel))
}
},
)
}
if (showContextSheet && state.contextBreakdown != null) {
ContextDetailSheet(
breakdown = state.contextBreakdown!!,
@@ -312,7 +312,6 @@ data class ChatUiState(
val typingEffectDelayMs: Int = 30,
// Commands catalog
val commandCatalog: CommandCatalog = CommandCatalog(),
val availableBots: List<ProfileInfo> = emptyList(),
// Per-command usage counts for the slash-autocomplete ranking (issue
// #865). Empty until the local store loads; commands without recorded
// usage keep their catalog order.
@@ -327,6 +326,7 @@ data class ChatUiState(
val modelPickerProviders: List<ModelProvider> = emptyList(),
val modelPickerPinned: List<PinnedModel> = emptyList(),
val modelPickerLoading: Boolean = false,
val modelSwitchConfirmMessage: String? = null,
// Current session's active model label (provider/model), shown in the chip
val currentSessionModel: String? = null,
// Per-model reasoning capabilities for the current session's model
@@ -362,6 +362,8 @@ data class ChatUiState(
// Session resume recovery (desktop parity: bounded auto-retry + error UI)
val resumeError: String? = null,
val isResumeRetrying: Boolean = false,
/** Text staged to prefill the composer (e.g. from /undo). */
val pendingPrefillText: String? = null,
) {
/** Convenience — derived from [connectionStatus]. */
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
@@ -375,11 +377,40 @@ data class SessionUi(
val depth: Int = 0,
)
data class ClarifyQuestionUi(
val qid: String = "q0",
val question: String = "",
val choices: List<String> = emptyList(),
val multiSelect: Boolean = false,
)
data class ClarifyUi(
val text: String,
val options: List<String>,
val options: List<String> = emptyList(),
val clarifyId: String? = null,
)
val questionId: String? = null,
val multiSelect: Boolean = false,
val questions: List<ClarifyQuestionUi> = emptyList(),
) {
/**
* Normalized list of questions to display. Guarantees at least one question
* entry even for legacy single-question clarify events.
*/
val resolvedQuestions: List<ClarifyQuestionUi>
get() =
if (questions.isNotEmpty()) {
questions
} else {
listOf(
ClarifyQuestionUi(
qid = questionId ?: "q0",
question = text,
choices = options,
multiSelect = multiSelect,
),
)
}
}
/**
* State for the context-aware side-question bottom sheet (issue #1015, `/btw`).
@@ -413,6 +444,8 @@ data class SudoPromptUi(
data class SecretPromptUi(
val requestId: String?,
val sessionId: String?,
val envVar: String? = null,
val prompt: String? = null,
)
/**
@@ -462,6 +495,15 @@ class ChatViewModel(
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
private var sessionGeneration = 0L
private data class ActiveModelSwitch(
val spec: String,
val previousModel: String?,
)
private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>()
private var activeModelSwitchConfirmation: ActiveModelSwitch? = null
private var optimisticPreviousModel: String? = null
private var resumeRequestSequence = 0L
private var activeResumeRequestSequence = 0L
private var hydrationRequestSequence = 0L
@@ -788,9 +830,7 @@ class ChatViewModel(
streamingController.flushPendingTokens()
}
else -> {
Unit
}
else -> {}
}
// First, let the reducer compute the new state and any effects
@@ -898,6 +938,23 @@ class ChatViewModel(
// chip stays hidden until the real window lands.
viewModelScope.launch { fetchContextUsage(skipRestFallback = true) }
}
// Session.info can carry `pending_approval` (reconnect
// reconciliation) — surface it unless already on screen.
val pendingApproval = info["pending_approval"] as? Map<*, *>
if (pendingApproval != null) {
val rid = pendingApproval["request_id"] as? String
val alreadyShown =
rid != null &&
_uiState.value.messages.any { it.approvalInfo?.requestId == rid }
if (!alreadyShown) {
handleApprovalRequest(
parseApprovalMap(
pendingApproval,
runtimeSessionId ?: _uiState.value.currentSessionId,
),
)
}
}
}
}
@@ -969,10 +1026,18 @@ class ChatViewModel(
handleSudoRequest(event)
}
is WsEvent.SudoExpire -> {
handleSudoExpire(event)
}
is WsEvent.SecretRequest -> {
handleSecretRequest(event)
}
is WsEvent.SecretExpire -> {
handleSecretExpire(event)
}
is WsEvent.GatewayError -> {
// Reducer already set errorMessage; no extra VM work needed.
}
@@ -1171,6 +1236,26 @@ class ChatViewModel(
val generation = request?.generation ?: sessionGeneration
resumedGeneration = generation
finishResumeWhenHydrated(generation)
// Reconnect replay: resume payload can carry `pending_approval`
// (server `_session_info_payload`); surface it, then ask for
// the full queue in case more are parked.
val pendingApproval = resultMap?.get("pending_approval") as? Map<*, *>
if (pendingApproval != null) {
val rid = pendingApproval["request_id"] as? String
val alreadyShown =
rid != null &&
_uiState.value.messages.any { it.approvalInfo?.requestId == rid }
if (!alreadyShown) {
handleApprovalRequest(
parseApprovalMap(
pendingApproval,
runtimeSessionId ?: sessionId,
),
)
}
}
val activeSessionId = runtimeSessionId ?: sessionId
if (activeSessionId != null) replayPendingApproval(activeSessionId)
}
WsMethods.SESSION_INTERRUPT -> {
@@ -1209,6 +1294,29 @@ class ChatViewModel(
addSystemMessage("Approval submitted")
}
}
WsMethods.APPROVAL_PENDING -> {
handleApprovalPendingResult(result)
}
WsMethods.CONFIG_SET -> {
val pending = pendingModelSwitchRequests.remove(id)
val map = result as? Map<*, *> ?: return
val key = map["key"] as? String
if (key == "model") {
val confirmRequired = map["confirm_required"] as? Boolean ?: false
if (confirmRequired) {
val confirmMessage =
(map["confirm_message"] as? String)
?: (map["warning"] as? String)
?: "This model requires confirmation to switch. Continue?"
activeModelSwitchConfirmation = pending
_uiState.update {
it.copy(modelSwitchConfirmMessage = confirmMessage)
}
}
}
}
}
}
@@ -1242,6 +1350,12 @@ class ChatViewModel(
handleSlashCommand(target)
}
"prefill" -> {
val message = map["message"] as? String ?: ""
val notice = map["notice"] as? String ?: ""
handlePrefillResult(message, notice)
}
else -> {
val output = map["output"] as? String ?: map.toString()
addAssistantMessage(output)
@@ -1583,6 +1697,11 @@ class ChatViewModel(
}
}
if (result is SlashResult.Undo) {
handleUndoCommand(result.count)
return
}
// Block desktop/CLI-only + TUI-only commands that don't function on
// mobile (issue #576, deliverable #3). These are also hidden from the
// suggestion menu, but a user can still type one — intercept it here
@@ -1654,6 +1773,10 @@ class ChatViewModel(
handleSideQuestionCommand(result.question)
}
is SlashResult.Undo -> {
handleUndoCommand(result.count)
}
is SlashResult.RpcDispatch -> {
dispatchViaRpc(command)
}
@@ -1890,7 +2013,10 @@ class ChatViewModel(
* `<model> --provider <slug> --session`
* (matching the TUI client's `modelValueForConfigSet`).
*/
private fun handleModelSwitch(command: String) {
private fun handleModelSwitch(
command: String,
confirmExpensive: Boolean = false,
) {
val sessionId = runtimeSessionId
if (sessionId == null) {
addAssistantMessage("No active session. Use `/new` to create one.")
@@ -1907,11 +2033,25 @@ class ChatViewModel(
} else {
command.trim()
}
val previousModel = optimisticPreviousModel ?: _uiState.value.currentSessionModel
optimisticPreviousModel = null
val params =
mutableMapOf<String, Any>(
"key" to "model",
"value" to spec,
"session_id" to sessionId,
)
if (confirmExpensive) {
params["confirm_expensive_model"] = true
}
viewModelScope.launch(ioDispatcher) {
wsClient.send(
WsMethods.CONFIG_SET,
mapOf("key" to "model", "value" to spec, "session_id" to sessionId),
onSent = { id -> trackRequest(id, WsMethods.CONFIG_SET) },
params,
onSent = { id ->
trackRequest(id, WsMethods.CONFIG_SET)
pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel)
},
)
}
}
@@ -1951,6 +2091,67 @@ class ChatViewModel(
}
}
private fun handleUndoCommand(count: String) {
val sessionId = runtimeSessionId
if (sessionId == null) {
addAssistantMessage("No active session to undo.")
return
}
viewModelScope.launch {
slashUsageStore.recordUse("/undo")
}
viewModelScope.launch(ioDispatcher) {
try {
val result =
wsClient
.request(
WsMethods.COMMAND_DISPATCH,
mapOf("name" to "undo", "arg" to count, "session_id" to sessionId),
).await()
handleDispatchResult(result)
} catch (e: HermesWsClient.HermesRpcException) {
addAssistantMessage(e.message ?: "Failed to undo.")
} catch (e: Exception) {
addAssistantMessage(e.message ?: "Failed to undo.")
}
}
}
private fun handlePrefillResult(
message: String,
notice: String,
) {
val storageSessionId = _uiState.value.currentSessionId
if (message.isNotBlank()) {
_uiState.update { it.copy(pendingPrefillText = message) }
}
val feedback =
when {
notice.isNotBlank() && message.isNotBlank() -> "$notice (Rewound to: \"$message\")"
notice.isNotBlank() -> notice
message.isNotBlank() -> "↶ Rewound to: \"$message\""
else -> "↶ Rewound"
}
if (storageSessionId != null) {
val generation = sessionGeneration
viewModelScope.launch {
withContext(ioDispatcher) {
repo.clearMessagesForSession(storageSessionId)
}
_uiState.update { it.copy(messages = emptyList()) }
loadSessionMessages(storageSessionId, generation)
addSystemMessage(feedback, persist = true)
fetchContextUsage()
}
} else {
addSystemMessage(feedback, persist = false)
}
}
fun consumePendingPrefill() {
_uiState.update { it.copy(pendingPrefillText = null) }
}
// ── Session management ───────────────────────────────────────────────
fun interruptSession() {
@@ -2082,15 +2283,6 @@ class ChatViewModel(
WsMethods.COMMANDS_CATALOG,
onSent = { id -> trackRequest(id, WsMethods.COMMANDS_CATALOG) },
)
// Fetch available bot profiles for @ autocomplete
val profilesResult = safeApiCall { ApiClient.hermesApi.getProfiles() }
if (profilesResult is NetworkResult.Success) {
val profiles =
profilesResult.data.profiles.orEmpty().filter {
!AuthManager.isProfileHidden(it.name) && it.botMeta()?.hidden != true
}
_uiState.update { it.copy(availableBots = profiles) }
}
}
}
@@ -2266,6 +2458,7 @@ class ChatViewModel(
provider: String,
model: String,
) {
optimisticPreviousModel = _uiState.value.currentSessionModel
_uiState.update {
it.copy(
showModelPicker = false,
@@ -2281,6 +2474,28 @@ class ChatViewModel(
handleSlashCommand("/model $model --provider $provider --session")
}
fun dismissModelSwitchConfirm() {
val pending = activeModelSwitchConfirmation
activeModelSwitchConfirmation = null
_uiState.update {
it.copy(
modelSwitchConfirmMessage = null,
currentSessionModel = pending?.previousModel ?: it.currentSessionModel,
)
}
syncCurrentModelCapabilities()
fetchContextUsage()
}
fun confirmModelSwitchExpensive() {
val pending = activeModelSwitchConfirmation
activeModelSwitchConfirmation = null
_uiState.update { it.copy(modelSwitchConfirmMessage = null) }
if (pending != null) {
handleModelSwitch(pending.spec, confirmExpensive = true)
}
}
/**
* Set the reasoning effort level for the current session.
*
@@ -2529,7 +2744,11 @@ class ChatViewModel(
private fun sealStreamingMessageIfAny() {
val streaming = _streamingState.value.streamingMessage ?: return
if (streaming.content.isBlank() && streaming.reasoningText.isBlank()) return
val finalized = streaming.copy(isStreaming = false)
val finalized =
streaming.copy(
isStreaming = false,
finishTimestamp = System.currentTimeMillis(),
)
_uiState.update { it.copy(messages = (it.messages + finalized).dedupeById()) }
val sid = _uiState.value.currentSessionId
if (sid != null) {
@@ -2576,6 +2795,7 @@ class ChatViewModel(
showSessionPicker = false,
showModelPicker = false,
modelPickerLoading = false,
modelSwitchConfirmMessage = null,
currentSessionModel = null,
currentModelCapabilities = null,
reasoningLevel = null,
@@ -2590,6 +2810,7 @@ class ChatViewModel(
todos = emptyList(),
resumeError = null,
isResumeRetrying = false,
pendingPrefillText = null,
)
}
return generation
@@ -3235,39 +3456,107 @@ class ChatViewModel(
*/
fun dismissClarify() {
val sessionId = _uiState.value.currentSessionId ?: return
val clarifyId = _uiState.value.clarifyRequest?.clarifyId
val clarify = _uiState.value.clarifyRequest
val clarifyId = clarify?.clarifyId
// Raw batch questions (non-empty only for true batch payloads).
// Legacy singles keep questions empty and rely on questionId (nullable).
val isBatch = !clarify?.questions.isNullOrEmpty()
val displayQuestions = clarify?.resolvedQuestions.orEmpty()
_uiState.update { it.copy(clarifyRequest = null) }
addSystemMessage("Clarify dismissed — no answer sent", persist = true)
viewModelScope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"response" to CLARIFY_DISMISS_RESPONSE,
"answer" to CLARIFY_DISMISS_RESPONSE,
if (isBatch) {
// Send dismissal for every question in the batch
for (q in displayQuestions) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"response" to CLARIFY_DISMISS_RESPONSE,
"answer" to CLARIFY_DISMISS_RESPONSE,
"question_id" to q.qid,
)
if (clarifyId != null) {
params["clarify_id"] = clarifyId
params["request_id"] = clarifyId
}
wsClient.send(
method = WsMethods.CLARIFY_RESPOND,
params = params,
onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) },
)
}
} else {
// Legacy: only send question_id when the original payload had one.
val questionId = clarify?.questionId
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"response" to CLARIFY_DISMISS_RESPONSE,
"answer" to CLARIFY_DISMISS_RESPONSE,
)
if (clarifyId != null) {
params["clarify_id"] = clarifyId
params["request_id"] = clarifyId
}
if (questionId != null) {
params["question_id"] = questionId
}
wsClient.send(
method = WsMethods.CLARIFY_RESPOND,
params = params,
onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) },
)
if (clarifyId != null) {
params["clarify_id"] = clarifyId
params["request_id"] = clarifyId
}
wsClient.send(
method = WsMethods.CLARIFY_RESPOND,
params = params,
onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) },
)
}
}
fun respondToClarify(option: String) {
val clarify = _uiState.value.clarifyRequest
// Only use synthesized qid when this is a true batch or legacy with explicit qid.
val qid =
if (clarify != null &&
(clarify.questionId != null || clarify.questions.isNotEmpty())
) {
clarify.resolvedQuestions.firstOrNull()?.qid ?: clarify.questionId
} else {
null
}
if (qid != null && clarify?.resolvedQuestions?.size == 1) {
respondToClarifyBatch(mapOf(qid to option))
} else {
respondToClarifyBatch(emptyMap(), singleFallbackAnswer = option)
}
}
fun respondToClarifyBatch(
answers: Map<String, String>,
singleFallbackAnswer: String? = null,
) {
val sessionId = _uiState.value.currentSessionId ?: return
val clarifyId = _uiState.value.clarifyRequest?.clarifyId
val clarify = _uiState.value.clarifyRequest
val clarifyId = clarify?.clarifyId
val isBatch = !clarify?.questions.isNullOrEmpty()
val questions = clarify?.resolvedQuestions.orEmpty()
_uiState.update { it.copy(clarifyRequest = null) }
val displayContent =
if (questions.size > 1) {
questions
.mapIndexed { index, q ->
val ans = answers[q.qid]?.trim().orEmpty()
"${index + 1}. ${ans.ifEmpty { "(Skipped)" }}"
}.joinToString("\n")
} else {
val loneAns = answers.values.firstOrNull()?.trim() ?: singleFallbackAnswer?.trim().orEmpty()
loneAns
}
val userMessage =
ChatMessage(
role = MessageRole.USER,
content = option,
content = displayContent,
)
_uiState.update { state ->
@@ -3282,21 +3571,49 @@ class ChatViewModel(
}
viewModelScope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"response" to option,
"answer" to option,
if (isBatch) {
for (q in questions) {
val ans = answers[q.qid]?.trim().orEmpty()
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"response" to ans,
"answer" to ans,
"question_id" to q.qid,
)
if (clarifyId != null) {
params["clarify_id"] = clarifyId
params["request_id"] = clarifyId
}
wsClient.send(
method = WsMethods.CLARIFY_RESPOND,
params = params,
onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) },
)
}
} else {
// Legacy single: only include question_id when explicitly present.
val qid = clarify?.questionId
val ans = answers.values.firstOrNull() ?: singleFallbackAnswer.orEmpty()
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"response" to ans,
"answer" to ans,
)
if (clarifyId != null) {
params["clarify_id"] = clarifyId
params["request_id"] = clarifyId
}
if (qid != null) {
params["question_id"] = qid
}
wsClient.send(
method = WsMethods.CLARIFY_RESPOND,
params = params,
onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) },
)
if (clarifyId != null) {
params["clarify_id"] = clarifyId
params["request_id"] = clarifyId
}
wsClient.send(
method = WsMethods.CLARIFY_RESPOND,
params = params,
onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) },
)
}
}
@@ -3327,6 +3644,10 @@ class ChatViewModel(
command = event.command,
description = event.description,
patternKeys = event.patternKeys,
requestId = event.requestId,
choices = event.choices,
allowPermanent = event.allowPermanent,
smartDenied = event.smartDenied,
),
)
_uiState.update { state ->
@@ -3335,12 +3656,107 @@ class ChatViewModel(
isAgentTyping = false,
)
}
// Desktop parity (`prompts.ts` receiveApprovalRequest): ack the render
// so the backend knows this client holds the prompt. Fire-and-forget.
val requestId = event.requestId
val sessionId = runtimeSessionId ?: event.sessionId ?: _uiState.value.currentSessionId
if (requestId != null && sessionId != null) {
viewModelScope.launch(ioDispatcher) {
runCatching {
wsClient.send(
method = WsMethods.APPROVAL_RECEIVED,
params =
mapOf(
"session_id" to sessionId,
"request_id" to requestId,
),
)
}
}
}
}
/**
* Convert a backend approval map (snake_case, from `approval.pending` or
* `session.info` `pending_approval`) into a typed event. Mirrors
* [EventParser] defaults so replay and live paths agree.
*/
private fun parseApprovalMap(
map: Map<*, *>,
sessionId: String?,
): WsEvent.ApprovalRequest {
@Suppress("UNCHECKED_CAST")
val patternKeys = (map["pattern_keys"] as? List<*>)?.filterIsInstance<String>()
@Suppress("UNCHECKED_CAST")
val rawChoices = (map["choices"] as? List<*>)?.filterIsInstance<String>()
val allowPermanent = map["allow_permanent"] as? Boolean
val allowSession = map["allow_session"] as? Boolean
val smartDenied = map["smart_denied"] as? Boolean
val choices =
rawChoices ?: run {
if (smartDenied == true) {
listOf("once", "deny")
} else {
buildList {
add("once")
if (allowSession != false) {
add("session")
if (allowPermanent != false) add("always")
}
add("deny")
}
}
}
return WsEvent.ApprovalRequest(
command = map["command"] as? String,
description = map["description"] as? String,
patternKeys = patternKeys,
sessionId = sessionId,
requestId = map["request_id"] as? String,
choices = choices,
allowPermanent = allowPermanent,
smartDenied = smartDenied,
)
}
/**
* Reconnect replay (desktop `replayPendingApproval` parity): ask the
* gateway for unresolved approvals and surface the oldest one. Called
* after resume and after each respond (the queue can hold more).
*/
private fun replayPendingApproval(sessionId: String) {
val targetSessionId = runtimeSessionId ?: sessionId
viewModelScope.launch(ioDispatcher) {
wsClient.send(
method = WsMethods.APPROVAL_PENDING,
params = mapOf("session_id" to targetSessionId),
onSent = { id -> trackRequest(id, WsMethods.APPROVAL_PENDING) },
)
}
}
private fun handleApprovalPendingResult(result: Any?) {
@Suppress("UNCHECKED_CAST")
val approvals = (result as? Map<*, *>)?.get("approvals") as? List<*>
val first = approvals?.filterIsInstance<Map<*, *>>()?.firstOrNull() ?: return
val requestId = first["request_id"] as? String ?: return
val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId
// Don't duplicate a prompt already on screen.
val alreadyShown =
_uiState.value.messages.any { it.approvalInfo?.requestId == requestId }
if (alreadyShown) return
handleApprovalRequest(parseApprovalMap(first, sessionId))
}
fun respondToApproval(action: String) {
val state = _uiState.value
val approvalMsg = state.messages.lastOrNull { it.approvalInfo != null } ?: return
val sessionId = state.currentSessionId ?: return
val sessionId = runtimeSessionId ?: state.currentSessionId ?: return
// Desktop sends `once` for a single run; legacy mobile sent `approve`
// (any non-deny still unblocks, but stay on-spec going forward).
val choice = if (action == "approve") "once" else action
val requestId = approvalMsg.approvalInfo?.requestId
// Clear buttons immediately
_uiState.update { s ->
@@ -3357,16 +3773,20 @@ class ChatViewModel(
}
viewModelScope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"choice" to choice,
"all" to false,
)
if (requestId != null) params["request_id"] = requestId
wsClient.send(
method = WsMethods.APPROVAL_RESPOND,
params =
mapOf(
"session_id" to sessionId,
"choice" to action,
"all" to false,
),
params = params,
onSent = { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) },
)
// The queue can hold more pendings — surface the next one.
replayPendingApproval(sessionId)
}
}
@@ -3385,6 +3805,23 @@ class ChatViewModel(
}
}
/**
* Backend sudo timeout (120s) — clear only the matching dialog so a
* late expire for an old prompt never kills the current one.
* Desktop parity: `patchOverlayState(prev => prev.sudo?.requestId === id ? null : prev)`.
*/
private fun handleSudoExpire(event: WsEvent.SudoExpire) {
_uiState.update { state ->
val current = state.sudoPrompt ?: return@update state
if (event.requestId != null && current.requestId != null &&
event.requestId != current.requestId
) {
return@update state
}
state.copy(sudoPrompt = null)
}
}
/**
* The agent needs a secret value (token/password). Previously dropped →
* agent hung forever. Now we surface a secure dialog and reply via
@@ -3393,18 +3830,82 @@ class ChatViewModel(
private fun handleSecretRequest(event: WsEvent.SecretRequest) {
_uiState.update {
it.copy(
secretPrompt = SecretPromptUi(event.requestId, event.sessionId),
secretPrompt =
SecretPromptUi(
event.requestId,
event.sessionId,
event.envVar,
event.prompt,
),
isAgentTyping = false,
)
}
}
fun dismissSudo() {
_uiState.update { it.copy(sudoPrompt = null) }
/**
* Backend secret timeout — match-only clear like [handleSudoExpire].
*/
private fun handleSecretExpire(event: WsEvent.SecretExpire) {
_uiState.update { state ->
val current = state.secretPrompt ?: return@update state
if (event.requestId != null && current.requestId != null &&
event.requestId != current.requestId
) {
return@update state
}
state.copy(secretPrompt = null)
}
}
/**
* Cancel → send empty password (desktop parity:
* `prompt-overlays.tsx` `send('')`). Backend treats empty sudo as
* failed sudo (no command runs), so closing the dialog is a safe
* refusal that unblocks the turn instantly instead of hanging 120s
* until `sudo.expire`.
*/
fun dismissSudo() {
val prompt = _uiState.value.sudoPrompt ?: return
val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId
_uiState.update { it.copy(sudoPrompt = null) }
if (sessionId == null) return
viewModelScope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"password" to "",
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsClient.send(
method = WsMethods.SUDO_RESPOND,
params = params,
onSent = { id -> trackRequest(id, WsMethods.SUDO_RESPOND) },
)
}
}
/**
* Cancel → send empty value (desktop parity). Backend `secret_cb`
* returns `skipped=True` on empty, unblocking the turn instantly.
*/
fun dismissSecret() {
val prompt = _uiState.value.secretPrompt ?: return
val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId
_uiState.update { it.copy(secretPrompt = null) }
if (sessionId == null) return
viewModelScope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"value" to "",
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsClient.send(
method = WsMethods.SECRET_RESPOND,
params = params,
onSent = { id -> trackRequest(id, WsMethods.SECRET_RESPOND) },
)
}
}
/**
@@ -67,6 +67,7 @@ object ChatWsEventReducer {
is WsEvent.ToolComplete -> event.sessionId
is WsEvent.ToolOutputRisk -> event.sessionId
is WsEvent.ClarifyRequest -> event.sessionId
is WsEvent.ClarifyExpire -> event.sessionId
is WsEvent.ToolProgress -> event.sessionId
is WsEvent.ToolGenerating -> event.sessionId
is WsEvent.SubagentEvent -> event.sessionId
@@ -128,6 +129,8 @@ object ChatWsEventReducer {
is WsEvent.ClarifyRequest -> onClarifyRequest(state, streamingState, event)
is WsEvent.ClarifyExpire -> onClarifyExpire(state, streamingState, event)
is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event)
is WsEvent.BtwComplete -> onBtwComplete(state, streamingState, event)
@@ -160,8 +163,12 @@ object ChatWsEventReducer {
// SudoRequest / SecretRequest are handled by the ViewModel (issue #524)
is WsEvent.SudoRequest -> ReducerResult(state = state, streamingState = streamingState)
is WsEvent.SudoExpire -> ReducerResult(state = state, streamingState = streamingState)
is WsEvent.SecretRequest -> ReducerResult(state = state, streamingState = streamingState)
is WsEvent.SecretExpire -> ReducerResult(state = state, streamingState = streamingState)
// ReactionEvent is handled by the ViewModel — purely cosmetic animation
is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState)
@@ -206,7 +213,11 @@ object ChatWsEventReducer {
var orphan: ChatMessage? = null
val preState =
if (streamingState.streamingMessage?.content?.isNotEmpty() == true) {
val finalized = streamingState.streamingMessage.copy(isStreaming = false)
val finalized =
streamingState.streamingMessage.copy(
isStreaming = false,
finishTimestamp = System.currentTimeMillis(),
)
orphan = finalized
state.copy(
messages = state.messages.upsertById(finalized),
@@ -369,10 +380,12 @@ object ChatWsEventReducer {
content = text,
isStreaming = false,
reasoningText = reasoning,
finishTimestamp = System.currentTimeMillis(),
) ?: ChatMessage(
role = MessageRole.ASSISTANT,
content = text,
reasoningText = reasoning,
finishTimestamp = System.currentTimeMillis(),
)
val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId
@@ -418,7 +431,12 @@ object ChatWsEventReducer {
} else {
streaming.reasoningText
}
val msg = streaming.copy(isStreaming = false, reasoningText = reasoning)
val msg =
streaming.copy(
isStreaming = false,
reasoningText = reasoning,
finishTimestamp = System.currentTimeMillis(),
)
val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId
if (sid != null) {
@@ -481,6 +499,7 @@ object ChatWsEventReducer {
streamingState.streamingMessage.copy(
isStreaming = false,
reasoningText = reasoning,
finishTimestamp = System.currentTimeMillis(),
)
orphanToPersist = finalized
state.copy(
@@ -624,8 +643,25 @@ object ChatWsEventReducer {
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.ClarifyRequest,
): ReducerResult =
ReducerResult(
): ReducerResult {
val questions =
if (event.questions.isNotEmpty()) {
event.questions.map {
ClarifyQuestionUi(
qid = it.qid,
question = it.question,
choices = it.choices,
multiSelect = it.multiSelect,
)
}
} else {
// Legacy single-question payload: keep questions empty so the wire
// layer can tell legacy apart from a true batch. UI synthesizes
// via ClarifyUi.resolvedQuestions; wire uses text/options/questionId.
emptyList()
}
return ReducerResult(
state =
state.copy(
clarifyRequest =
@@ -633,10 +669,28 @@ object ChatWsEventReducer {
text = event.text.orEmpty(),
options = event.options.orEmpty(),
clarifyId = event.clarifyId,
questionId = event.questionId,
multiSelect = event.multiSelect,
questions = questions,
),
isAgentTyping = false,
),
)
}
private fun onClarifyExpire(
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.ClarifyExpire,
): ReducerResult =
if (event.clarifyId == null || state.clarifyRequest?.clarifyId == event.clarifyId) {
ReducerResult(
state = state.copy(clarifyRequest = null),
streamingState = streamingState,
)
} else {
ReducerResult(state = state, streamingState = streamingState)
}
// ── ReviewSummary (Self-improvement background review) ──────────────
@@ -25,6 +25,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -33,6 +34,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
@@ -44,7 +46,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.hrm.latex.renderer.LatexAutoWrap
@@ -57,6 +61,7 @@ import com.m57.hermescontrol.data.remote.GatewayFileClient
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.SearchHighlightColors
import com.m57.hermescontrol.theme.searchHighlightColors
import com.m57.hermescontrol.util.BidiUtils
private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""")
private val TABLE_COL_WIDTH = 160.dp
@@ -66,6 +71,8 @@ private val BULLET_LINE_RE = Regex("""^(\s*)[-*+]\s+(.*)$""")
private val ORDERED_LINE_RE = Regex("""^(\s*)(\d+)\.\s+(.*)$""")
private val LIST_ITEM_LINE_RE = Regex("""^(\s*)(?:[-*+]\s+(?:\[([ xX])\]\s+)?|(\d+)\.\s+)(.*)$""")
private fun bidiTextDirection(isRtl: Boolean): TextDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr
/**
* Renders chat assistant text as Markdown — but ONLY once the message has finished streaming.
* While [isStreaming] is true we show the raw text to avoid flicker / re-parse churn, then swap
@@ -89,12 +96,19 @@ fun MarkdownText(
val statusColors = LocalHermesStatusColors.current
val highlights = searchHighlightColors(statusColors)
if (isStreaming) {
Text(
text = text,
color = textColor,
style = MaterialTheme.typography.bodyMedium,
modifier = modifier,
)
val isRtl = remember(text) { BidiUtils.isRtlText(text) }
val streamingDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides streamingDirection) {
Text(
text = if (isRtl) BidiUtils.anchorTrailingRtl(text) else text,
color = textColor,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
modifier = modifier,
)
}
return
}
@@ -143,154 +157,191 @@ fun MarkdownText(
5 -> 15.sp
else -> 14.sp
}
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium
.copy(fontSize = fontSize, fontWeight = FontWeight.Bold),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.padding(vertical = 2.dp),
)
}
is MdBlock.Bullet -> {
val indent = (block.level * 16).dp
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = indent)
.padding(vertical = 1.dp),
verticalAlignment = Alignment.Top,
) {
val bulletChar =
when (block.level % 3) {
0 -> "•"
1 -> "◦"
else -> "▪"
}
Text(
text = bulletChar,
color = textColor,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp),
)
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium,
style =
MaterialTheme.typography.bodyMedium
.copy(
fontSize = fontSize,
fontWeight = FontWeight.Bold,
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
modifier = Modifier.padding(vertical = 2.dp),
)
}
}
is MdBlock.Bullet -> {
val indent = (block.level * 16).dp
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = indent)
.padding(vertical = 1.dp),
verticalAlignment = Alignment.Top,
) {
val bulletChar =
when (block.level % 3) {
0 -> "•"
1 -> "◦"
else -> "▪"
}
Text(
text = bulletChar,
color = textColor,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp),
)
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
}
}
}
is MdBlock.Task -> {
val indent = (block.level * 16).dp
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = indent)
.padding(vertical = 1.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector =
if (block.checked) {
Icons.Outlined.CheckBox
} else {
Icons.Outlined.CheckBoxOutlineBlank
},
contentDescription = null,
tint =
if (block.checked) {
MaterialTheme.colorScheme.primary
} else {
textColor.copy(
alpha = 0.6f,
)
},
modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp),
)
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = indent)
.padding(vertical = 1.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector =
if (block.checked) {
Icons.Outlined.CheckBox
} else {
Icons.Outlined.CheckBoxOutlineBlank
},
contentDescription = null,
tint =
if (block.checked) {
MaterialTheme.colorScheme.primary
} else {
textColor.copy(
alpha = 0.6f,
)
},
modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp),
)
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
}
}
}
is MdBlock.Ordered -> {
val indent = (block.level * 16).dp
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = indent)
.padding(vertical = 1.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = "${block.index}.",
color = textColor,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp),
)
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = indent)
.padding(vertical = 1.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = "${block.index}.",
color = textColor,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp),
)
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
}
}
}
is MdBlock.Quote -> {
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min).padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.width(3.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(2.dp))
.background(textColor.copy(alpha = 0.35f)),
)
Spacer(modifier = Modifier.width(8.dp))
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min).padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.width(3.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(2.dp))
.background(textColor.copy(alpha = 0.35f)),
)
Spacer(modifier = Modifier.width(8.dp))
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
fontStyle = FontStyle.Italic,
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
}
}
}
@@ -347,22 +398,41 @@ fun MarkdownText(
}
is MdBlock.DefList -> {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
block.items.forEach { item ->
Text(
text = item.term,
color = textColor,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold),
)
item.definitions.forEach { def ->
val isRtl =
remember(block.items) {
block.items.any { item ->
BidiUtils.isRtlText(item.term) ||
item.definitions.any { BidiUtils.isRtlText(it) }
}
}
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
block.items.forEach { item ->
val itemRtl = BidiUtils.isRtlText(item.term)
Text(
text = def,
text = if (itemRtl) BidiUtils.anchorTrailingRtl(item.term) else item.term,
color = textColor,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 16.dp, bottom = 2.dp),
style =
MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold,
textDirection = bidiTextDirection(itemRtl),
),
)
item.definitions.forEach { def ->
val defRtl = BidiUtils.isRtlText(def)
Text(
text = if (defRtl) BidiUtils.anchorTrailingRtl(def) else def,
color = textColor,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(defRtl),
),
modifier = Modifier.padding(start = 16.dp, bottom = 2.dp),
)
}
Spacer(modifier = Modifier.height(2.dp))
}
Spacer(modifier = Modifier.height(2.dp))
}
}
}
@@ -384,40 +454,54 @@ fun MarkdownText(
modifier = Modifier.padding(bottom = 2.dp),
)
block.notes.forEach { note ->
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
Text(
text = "[${note.id}] ",
color = textColor,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
)
MarkdownInlineText(
text = note.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodySmall,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
val isRtl = remember(note.text) { BidiUtils.isRtlText(note.text) }
val noteDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides noteDirection) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
Text(
text = "[${note.id}] ",
color = textColor,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
)
MarkdownInlineText(
text = note.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodySmall.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
is MdBlock.Paragraph -> {
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
)
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
)
}
}
}
}
@@ -462,63 +546,85 @@ private fun MarkdownInlineText(
highlights: SearchHighlightColors,
modifier: Modifier = Modifier,
) {
val segments = remember(text) { splitInlineMath(text) }
if (segments.none { it is InlineMathSegment.Math }) {
Text(
text =
remember(text, searchQuery, isCurrentMatch, textColor, linkColor) {
parseInlineSource(text, textColor, searchQuery, isCurrentMatch, linkColor, highlights)
},
color = textColor,
style = style,
modifier = modifier,
val isRtl = remember(text) { BidiUtils.isRtlText(text) }
val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
val resolvedStyle =
style.copy(
textDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr,
)
return
}
val config =
LatexConfig(
fontSize = style.fontSize,
theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent),
accessibilityEnabled = true,
)
val inlineContent = mutableMapOf<String, InlineTextContent>()
segments.forEachIndexed { index, segment ->
if (segment is InlineMathSegment.Math) {
latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it }
val processedText =
remember(text, isRtl) {
if (isRtl) BidiUtils.anchorTrailingRtl(text) else text
}
}
val annotated =
buildAnnotatedString {
segments.forEachIndexed { index, segment ->
when (segment) {
is InlineMathSegment.Math -> {
val id = "latex-$index"
if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex)
}
is InlineMathSegment.Text -> {
append(
parseInlineSource(
segment.value,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
),
CompositionLocalProvider(LocalLayoutDirection provides direction) {
val segments = remember(processedText) { splitInlineMath(processedText) }
if (segments.none { it is InlineMathSegment.Math }) {
Text(
text =
remember(processedText, searchQuery, isCurrentMatch, textColor, linkColor, isRtl) {
parseInlineSource(
text = processedText,
textColor = textColor,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
isRtl = isRtl,
)
},
color = textColor,
style = resolvedStyle,
modifier = modifier,
)
return@CompositionLocalProvider
}
val config =
LatexConfig(
fontSize = style.fontSize,
theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent),
accessibilityEnabled = true,
)
val inlineContent = mutableMapOf<String, InlineTextContent>()
segments.forEachIndexed { index, segment ->
if (segment is InlineMathSegment.Math) {
latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it }
}
}
val annotated =
buildAnnotatedString {
segments.forEachIndexed { index, segment ->
when (segment) {
is InlineMathSegment.Math -> {
val id = "latex-$index"
if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex)
}
is InlineMathSegment.Text -> {
append(
parseInlineSource(
text = segment.value,
textColor = textColor,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
isRtl = isRtl,
),
)
}
}
}
}
}
Text(
text = annotated,
inlineContent = inlineContent,
color = textColor,
style = style,
modifier = modifier,
)
Text(
text = annotated,
inlineContent = inlineContent,
color = textColor,
style = resolvedStyle,
modifier = modifier,
)
}
}
@Composable
@@ -526,40 +632,35 @@ private fun MarkdownTable(
block: MdBlock.Table,
textColor: Color,
) {
val isRtl =
remember(block) {
block.header.any { BidiUtils.isRtlText(it) } ||
block.rows.any { row -> row.any { BidiUtils.isRtlText(it) } }
}
val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
val headerBg = textColor.copy(alpha = 0.08f)
val alignments = block.alignments
Column(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = 4.dp),
) {
// Header row
Row(modifier = Modifier.background(headerBg)) {
block.header.forEachIndexed { idx, cell ->
Text(
text = cell,
textAlign = tableTextAlign(alignments.getOrNull(idx)),
color = textColor,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
modifier =
Modifier
.width(TABLE_COL_WIDTH)
.padding(6.dp),
)
}
}
HorizontalDivider(color = textColor.copy(alpha = 0.25f))
// Body rows
block.rows.forEach { row ->
Row {
row.forEachIndexed { idx, cell ->
CompositionLocalProvider(LocalLayoutDirection provides tableDirection) {
Column(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = 4.dp),
) {
// Header row
Row(modifier = Modifier.background(headerBg)) {
block.header.forEachIndexed { idx, cell ->
val cellRtl = BidiUtils.isRtlText(cell)
Text(
text = cell,
text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
textAlign = tableTextAlign(alignments.getOrNull(idx)),
color = textColor,
style = MaterialTheme.typography.bodySmall,
style =
MaterialTheme.typography.bodySmall.copy(
fontWeight = FontWeight.Bold,
textDirection = bidiTextDirection(cellRtl),
),
modifier =
Modifier
.width(TABLE_COL_WIDTH)
@@ -567,7 +668,29 @@ private fun MarkdownTable(
)
}
}
HorizontalDivider(color = textColor.copy(alpha = 0.1f))
HorizontalDivider(color = textColor.copy(alpha = 0.25f))
// Body rows
block.rows.forEach { row ->
Row {
row.forEachIndexed { idx, cell ->
val cellRtl = BidiUtils.isRtlText(cell)
Text(
text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
textAlign = tableTextAlign(alignments.getOrNull(idx)),
color = textColor,
style =
MaterialTheme.typography.bodySmall.copy(
textDirection = bidiTextDirection(cellRtl),
),
modifier =
Modifier
.width(TABLE_COL_WIDTH)
.padding(6.dp),
)
}
}
HorizontalDivider(color = textColor.copy(alpha = 0.1f))
}
}
}
}
@@ -1116,6 +1239,7 @@ internal fun parseInline(
isCurrentMatch: Boolean,
linkColor: Color,
highlights: SearchHighlightColors,
isRtl: Boolean = BidiUtils.isRtlText(text),
): AnnotatedString =
parseInlineSource(
text =
@@ -1130,6 +1254,7 @@ internal fun parseInline(
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
isRtl = isRtl,
)
private fun parseInlineSource(
@@ -1139,6 +1264,7 @@ private fun parseInlineSource(
isCurrentMatch: Boolean,
linkColor: Color,
highlights: SearchHighlightColors,
isRtl: Boolean = BidiUtils.isRtlText(text),
): AnnotatedString {
val searchHighlightColor =
if (isCurrentMatch) {
@@ -1156,8 +1282,10 @@ private fun parseInlineSource(
src.startsWith("***", i) -> {
val end = src.indexOf("***", i + 3)
if (end != -1) {
val raw = src.substring(i + 3, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
append(src.substring(i + 3, end))
append(toAppend)
}
i = end + 3
} else {
@@ -1170,8 +1298,10 @@ private fun parseInlineSource(
src.startsWith("**", i) -> {
val end = src.indexOf("**", i + 2)
if (end != -1) {
val raw = src.substring(i + 2, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(src.substring(i + 2, end))
append(toAppend)
}
i = end + 2
} else {
@@ -1184,8 +1314,10 @@ private fun parseInlineSource(
src.startsWith("~~", i) -> {
val end = src.indexOf("~~", i + 2)
if (end != -1) {
val raw = src.substring(i + 2, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
append(src.substring(i + 2, end))
append(toAppend)
}
i = end + 2
} else {
@@ -1198,8 +1330,10 @@ private fun parseInlineSource(
src.startsWith("*", i) -> {
val end = src.indexOf('*', i + 1)
if (end != -1 && end > i + 1) {
val raw = src.substring(i + 1, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
append(src.substring(i + 1, end))
append(toAppend)
}
i = end + 1
} else {
@@ -1212,8 +1346,10 @@ private fun parseInlineSource(
src.startsWith("==", i) -> {
val end = src.indexOf("==", i + 2)
if (end != -1) {
val raw = src.substring(i + 2, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(SpanStyle(background = highlights.markupBackground)) {
append(src.substring(i + 2, end))
append(toAppend)
}
i = end + 2
} else {
@@ -1254,13 +1390,15 @@ private fun parseInlineSource(
src.startsWith("<kbd>", i) -> {
val end = src.indexOf("</kbd>", i)
if (end != -1) {
val raw = src.substring(i + 5, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
background = textColor.copy(alpha = 0.12f),
),
) {
append(src.substring(i + 5, end))
append(toAppend)
}
i = end + 6
} else {
@@ -1297,6 +1435,7 @@ private fun parseInlineSource(
val urlEnd = src.indexOf(')', close + 2)
if (urlEnd != -1) {
val label = src.substring(i + 1, close)
val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label
val url = src.substring(close + 2, urlEnd)
pushLink(LinkAnnotation.Url(url))
withStyle(
@@ -1305,7 +1444,7 @@ private fun parseInlineSource(
textDecoration = TextDecoration.Underline,
),
) {
append(label)
append(labelToAppend)
}
pop()
i = urlEnd + 1
@@ -1323,6 +1462,8 @@ private fun parseInlineSource(
src.startsWith("`", i) -> {
val end = src.indexOf('`', i + 1)
if (end != -1) {
val raw = src.substring(i + 1, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
@@ -1330,7 +1471,7 @@ private fun parseInlineSource(
background = textColor.copy(alpha = 0.08f),
),
) {
append(src.substring(i + 1, end))
append(toAppend)
}
i = end + 1
} else {
@@ -1343,6 +1484,7 @@ private fun parseInlineSource(
URL_PATTERN.matchAt(src, i) != null -> {
val match = URL_PATTERN.matchAt(src, i)!!
val url = match.value
val urlToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(url) else url
pushLink(LinkAnnotation.Url(url))
withStyle(
SpanStyle(
@@ -1350,12 +1492,40 @@ private fun parseInlineSource(
textDecoration = TextDecoration.Underline,
),
) {
append(url)
append(urlToAppend)
}
pop()
i = match.range.last + 1
}
// Plain text / words: when inside RTL text, wrap sequences of Latin/LTR words in LTR isolate
// so an English word at the start/middle of an Arabic line doesn't flip the layout direction.
isRtl && Character.isLetterOrDigit(src.codePointAt(i)) &&
Character.getDirectionality(src.codePointAt(i)) == Character.DIRECTIONALITY_LEFT_TO_RIGHT -> {
var end = i + Character.charCount(src.codePointAt(i))
while (end < src.length) {
val cp = src.codePointAt(end)
val dir = Character.getDirectionality(cp)
if (dir == Character.DIRECTIONALITY_LEFT_TO_RIGHT ||
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER ||
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_SEPARATOR ||
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_TERMINATOR ||
(
dir == Character.DIRECTIONALITY_WHITESPACE && end + 1 < src.length &&
Character.getDirectionality(src.codePointAt(end + 1)) ==
Character.DIRECTIONALITY_LEFT_TO_RIGHT
)
) {
end += Character.charCount(cp)
} else {
break
}
}
val ltrSnippet = src.substring(i, end)
append(BidiUtils.wrapLtrIsolate(ltrSnippet))
i = end
}
// search highlight
searchQuery.isNotEmpty() &&
src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> {
@@ -66,6 +66,11 @@ class SlashCommandDispatcher {
SlashResult.SideQuestion(question = arg)
}
"/undo" -> {
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
SlashResult.Undo(count = arg)
}
else -> {
SlashResult.RpcDispatch
}
@@ -143,4 +148,12 @@ sealed class SlashResult {
data class SideQuestion(
val question: String,
) : SlashResult()
/**
* Rewind conversation turns via the `undo` command.dispatch RPC.
* Rewinds server transcript, reloads active messages, and prefills the composer.
*/
data class Undo(
val count: String,
) : SlashResult()
}
@@ -43,6 +43,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -53,11 +54,15 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
@@ -68,6 +73,7 @@ import com.m57.hermescontrol.data.ws.CommandBlocklist
import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
import com.m57.hermescontrol.ui.common.BotAvatar
import com.m57.hermescontrol.util.BidiUtils
/**
* The chat input bar with a two-row layout: input+send on top,
@@ -85,7 +91,6 @@ fun ChatInputBar(
commandCatalog: CommandCatalog,
slashUsageCounts: Map<String, Int> = emptyMap(),
pendingAttachments: List<Attachment> = emptyList(),
availableBots: List<ProfileInfo> = emptyList(),
onCameraTap: () -> Unit = {},
onImageTap: () -> Unit = {},
onFileTap: () -> Unit = {},
@@ -184,90 +189,6 @@ fun ChatInputBar(
}
}
// Bot mention @ suggestions
val mentionQuery =
remember(inputFieldValue.text, inputFieldValue.selection.end) {
ChatInputPolicy.extractMentionQuery(
inputFieldValue.text,
inputFieldValue.selection.end,
)
}
androidx.compose.animation.AnimatedVisibility(
visible = mentionQuery != null && availableBots.isNotEmpty(),
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
) {
val filteredBots =
remember(mentionQuery, availableBots) {
if (mentionQuery == null) {
emptyList()
} else {
availableBots.filter { bot ->
bot.name.startsWith(mentionQuery, ignoreCase = true) ||
bot.effectiveTitle.contains(mentionQuery, ignoreCase = true)
}
}
}
if (filteredBots.isNotEmpty()) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
border =
BorderStroke(
1.dp,
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
),
) {
LazyColumn(
modifier = Modifier.heightIn(max = 200.dp),
) {
items(filteredBots, key = { it.name }) { bot ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
onInputChange(
ChatInputPolicy.applyMention(inputFieldValue, bot.name),
)
}.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BotAvatar(
name = bot.name,
avatar = bot.botMeta()?.avatar,
size = 28.dp,
showPresence = false,
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = "@${bot.name}",
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
if (bot.effectiveTitle != bot.name) {
Text(
text = bot.effectiveTitle,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
// Attachment preview chips
AnimatedVisibility(
visible = pendingAttachments.isNotEmpty(),
@@ -318,91 +239,129 @@ fun ChatInputBar(
}
}
BasicTextField(
value = inputFieldValue,
onValueChange = onInputChange,
modifier =
Modifier
.weight(1f)
.heightIn(min = 42.dp, max = 120.dp)
.padding(vertical = 4.dp)
.onFocusChanged { isFocused = it.isFocused }
.testTag("chat_input"),
enabled = isConnected,
textStyle =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
),
singleLine = false,
maxLines = 4,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Surface(
shape = RoundedCornerShape(18.dp),
border =
BorderStroke(
width = if (isFocused) 2.dp else 1.dp,
color =
if (isFocused) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
},
),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.padding(start = 12.dp, end = 4.dp, top = 9.dp, bottom = 9.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.weight(1f)) {
if (inputFieldValue.text.isEmpty()) {
Text(
text = placeholderText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
innerTextField()
}
val ambientLayoutDirection = LocalLayoutDirection.current
val inputLayoutDirection =
remember(inputFieldValue.text, ambientLayoutDirection) {
BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection)
}
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
// Send button INSIDE the field. Shown whenever a send is
// possible — text typed OR an attachment pending (issue
// #956): the old text-only gate hid the button entirely
// for attachment-only sends.
AnimatedContent(
targetState = canSend,
transitionSpec = {
(scaleIn(initialScale = 0.8f) + fadeIn())
.togetherWith(scaleOut(targetScale = 0.8f) + fadeOut())
},
label = "send_toggle",
) { showSend ->
if (showSend) {
IconButton(
onClick = onSend,
enabled = canSend,
colors = IconButtonDefaults.filledTonalIconButtonColors(),
modifier =
Modifier
.size(36.dp)
.testTag("send_button"),
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
BasicTextField(
value = inputFieldValue,
onValueChange = onInputChange,
modifier =
Modifier
.weight(1f)
.heightIn(min = 42.dp, max = 120.dp)
.padding(vertical = 4.dp)
.onFocusChanged { isFocused = it.isFocused }
.testTag("chat_input"),
enabled = isConnected,
textStyle =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
),
singleLine = false,
maxLines = 4,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
Surface(
shape = RoundedCornerShape(18.dp),
border =
BorderStroke(
width = if (isFocused) 2.dp else 1.dp,
color =
if (isFocused) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
},
),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.padding(start = 12.dp, end = 4.dp, top = 9.dp, bottom = 9.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.weight(1f),
contentAlignment =
if (isInputRtl) {
Alignment.CenterEnd
} else {
Alignment.CenterStart
},
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.chat_send_desc),
)
CompositionLocalProvider(
LocalLayoutDirection provides inputLayoutDirection,
) {
if (inputFieldValue.text.isEmpty()) {
Text(
text = placeholderText,
style = MaterialTheme.typography.bodyMedium,
textAlign =
if (isInputRtl) {
TextAlign.Right
} else {
TextAlign.Left
},
color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.6f,
),
modifier = Modifier.fillMaxWidth(),
)
}
innerTextField()
}
}
// Send button INSIDE the field. Shown whenever a send is
// possible — text typed OR an attachment pending (issue
// #956): the old text-only gate hid the button entirely
// for attachment-only sends.
AnimatedContent(
targetState = canSend,
transitionSpec = {
(scaleIn(initialScale = 0.8f) + fadeIn())
.togetherWith(scaleOut(targetScale = 0.8f) + fadeOut())
},
label = "send_toggle",
) { showSend ->
if (showSend) {
IconButton(
onClick = onSend,
enabled = canSend,
colors = IconButtonDefaults.filledTonalIconButtonColors(),
modifier =
Modifier
.size(36.dp)
.testTag("send_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription =
stringResource(
R.string.chat_send_desc,
),
)
}
}
}
}
}
}
}
},
)
},
)
}
}
// ── BOTTOM ROW: Toolbar ──
@@ -87,20 +87,25 @@ fun SudoPromptDialog(
fun SecretPromptDialog(
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
envVar: String? = null,
prompt: String? = null,
) {
var secret by remember { mutableStateOf("") }
val titleText = envVar?.takeIf { it.isNotBlank() } ?: stringResource(R.string.chat_secret_title)
val bodyText = prompt?.takeIf { it.isNotBlank() } ?: stringResource(R.string.chat_secret_body)
val labelText = envVar?.takeIf { it.isNotBlank() } ?: stringResource(R.string.chat_secret_value)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.chat_secret_title)) },
title = { Text(titleText) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = stringResource(R.string.chat_secret_body))
Text(text = bodyText)
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = secret,
onValueChange = { secret = it },
label = { Text(stringResource(R.string.chat_secret_value)) },
label = { Text(labelText) },
modifier = Modifier.fillMaxWidth().testTag("secret_value_input"),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
@@ -133,6 +133,8 @@ fun ChatLifecycleEffects(
SecretPromptDialog(
onConfirm = viewModel::respondToSecret,
onDismiss = viewModel::dismissSecret,
envVar = prompt.envVar,
prompt = prompt.prompt,
)
}
}
@@ -42,6 +42,8 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -85,6 +87,7 @@ import com.m57.hermescontrol.theme.CodeTerminalBg
import com.m57.hermescontrol.theme.CodeTerminalBorder
import com.m57.hermescontrol.theme.CodeTerminalMuted
import com.m57.hermescontrol.theme.CodeTerminalText
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.SubagentIndicator
import kotlinx.coroutines.delay
@@ -429,19 +432,27 @@ private fun copyToClipboard(
// ── ClarifyBubble ─────────────────────────────────────────────────────────
/**
* Centered dashed-border bubble showing a clarify question with
* selectable option chips and a dismiss button.
* Centered dashed-border bubble showing a clarify question (or batch of questions) with
* selectable option chips, multi-select toggles, open-ended input, and a dismiss button.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ClarifyBubble(
text: String,
options: List<String>,
onOptionSelected: (String) -> Unit,
clarifyRequest: ClarifyUi,
onRespondSingle: (String) -> Unit,
onRespondBatch: (Map<String, String>) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var typedText by remember { mutableStateOf("") }
val questions = clarifyRequest.resolvedQuestions
val isBatch = questions.size > 1
var selectedChoicesByQid by remember(clarifyRequest) {
mutableStateOf<Map<String, Set<String>>>(emptyMap())
}
var customTextByQid by remember(clarifyRequest) {
mutableStateOf<Map<String, String>>(emptyMap())
}
Surface(
modifier =
@@ -458,57 +469,170 @@ fun ClarifyBubble(
),
) {
Column(
modifier = Modifier.padding(12.dp),
modifier = Modifier.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (options.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
options.forEach { option ->
SuggestionChip(
onClick = { onOptionSelected(option) },
label = { Text(option) },
colors =
SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
labelColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
)
questions.forEachIndexed { index, q ->
if (index > 0) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 12.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
if (isBatch) {
Text(
text = "${index + 1}. ${q.question}",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
} else {
Text(
text = q.question,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
}
if (q.multiSelect) {
Spacer(Modifier.height(2.dp))
Text(
text = "Select all that apply",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(),
)
}
if (q.choices.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
q.choices.forEach { choice ->
val selectedSet = selectedChoicesByQid[q.qid] ?: emptySet()
val isSelected = selectedSet.contains(choice)
FilterChip(
selected = isSelected,
onClick = {
if (q.multiSelect) {
val updated = if (isSelected) selectedSet - choice else selectedSet + choice
selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated)
} else {
if (!isBatch && customTextByQid[q.qid].isNullOrBlank()) {
// Fast 1-tap respond for lone single-select question when no custom text is entered
onRespondSingle(choice)
} else {
val updated = if (isSelected) emptySet() else setOf(choice)
selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated)
}
}
},
label = { Text(choice) },
leadingIcon =
if (isSelected) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
}
} else {
null
},
)
}
}
}
Spacer(Modifier.height(8.dp))
val typed = customTextByQid[q.qid].orEmpty()
OutlinedTextField(
value = typed,
onValueChange = { newText ->
customTextByQid = customTextByQid + (q.qid to newText)
},
label = {
Text(
if (q.choices.isEmpty()) {
stringResource(R.string.message_your_response)
} else {
"Other (optional)"
},
)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = typedText,
onValueChange = { typedText = it },
label = { Text(stringResource(R.string.message_your_response)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
) {
val hasAnyInput =
questions.any { q ->
(selectedChoicesByQid[q.qid]?.isNotEmpty() == true) ||
(!customTextByQid[q.qid].isNullOrBlank())
}
FilledTonalButton(
onClick = {
if (typedText.isNotBlank()) {
onOptionSelected(typedText)
typedText = ""
if (isBatch) {
val answers =
questions.associate { q ->
val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty()
val custom = customTextByQid[q.qid]?.trim().orEmpty()
val finalAns =
when {
selectedList.isNotEmpty() && custom.isNotEmpty() -> {
(selectedList + custom).joinToString(", ")
}
selectedList.isNotEmpty() -> {
selectedList.joinToString(", ")
}
else -> {
custom
}
}
q.qid to finalAns
}
onRespondBatch(answers)
} else {
val q = questions.first()
val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty()
val custom = customTextByQid[q.qid]?.trim().orEmpty()
val finalAns =
when {
selectedList.isNotEmpty() && custom.isNotEmpty() -> {
(selectedList + custom).joinToString(", ")
}
selectedList.isNotEmpty() -> {
selectedList.joinToString(", ")
}
else -> {
custom
}
}
if (finalAns.isNotBlank()) {
onRespondSingle(finalAns)
}
}
},
enabled = typedText.isNotBlank(),
enabled = hasAnyInput,
) {
Text("Send")
Text(if (isBatch) "Submit All" else "Send")
}
TextButton(onClick = onDismiss) {
Text("Dismiss")
@@ -518,6 +642,28 @@ fun ClarifyBubble(
}
}
/**
* Backward-compatible overload for legacy callers and unit tests.
*/
@Composable
fun ClarifyBubble(
text: String,
options: List<String>,
onOptionSelected: (String) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
ClarifyBubble(
clarifyRequest = ClarifyUi(text = text, options = options),
onRespondSingle = onOptionSelected,
onRespondBatch = { answers ->
answers.values.firstOrNull()?.let(onOptionSelected)
},
onDismiss = onDismiss,
modifier = modifier,
)
}
// ── SubagentCard ──────────────────────────────────────────────────────────
/**
@@ -140,20 +140,36 @@ internal fun FullBleedAgentMessage(
}
if (!message.isStreaming) {
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
}
copied = true
},
modifier = Modifier.size(28.dp).testTag("fullbleed_copy"),
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = stringResource(R.string.content_desc_copy),
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
}
copied = true
},
modifier = Modifier.size(28.dp).testTag("fullbleed_copy"),
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = stringResource(R.string.content_desc_copy),
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.width(4.dp))
val finishTime = message.finishTimestamp ?: message.timestamp
Text(
text =
com.m57.hermescontrol.ui.chat.formatTimestamp(
finishTime,
DateFormat.is24HourFormat(LocalContext.current),
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.testTag("fullbleed_finish_time"),
)
}
}
@@ -67,6 +67,7 @@ fun FullBleedChatList(
viewModel: ChatViewModel,
clarifyRequest: ClarifyUi? = null,
onRespondClarify: ((String) -> Unit)? = null,
onRespondClarifyBatch: ((Map<String, String>) -> Unit)? = null,
onDismissClarify: (() -> Unit)? = null,
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {},
savingAttachmentPath: String? = null,
@@ -299,9 +300,9 @@ fun FullBleedChatList(
if (clarifyRequest != null) {
item(key = "clarify_bubble") {
ClarifyBubble(
text = clarifyRequest.text,
options = clarifyRequest.options,
onOptionSelected = { option -> onRespondClarify?.invoke(option) },
clarifyRequest = clarifyRequest,
onRespondSingle = { option -> onRespondClarify?.invoke(option) },
onRespondBatch = { answers -> onRespondClarifyBatch?.invoke(answers) },
onDismiss = { onDismissClarify?.invoke() },
)
}
@@ -29,7 +29,7 @@ data class SettingsUiState(
val token: String = "",
val autoReconnect: Boolean = true,
val themePreference: ThemePreference = ThemePreference.SYSTEM,
val useDynamicColors: Boolean = true,
val useDynamicColors: Boolean = false,
val themePreset: ThemePreset = ThemePreset.DEFAULT,
val isTesting: Boolean = false,
val testResult: String? = null,
@@ -0,0 +1,153 @@
package com.m57.hermescontrol.util
import androidx.compose.ui.unit.LayoutDirection
/**
* Bidirectional (BiDi) text utilities for mixed Arabic/English content,
* dynamic layout direction resolution, and Unicode directional isolates.
*/
object BidiUtils {
const val LRI = "\u2066" // Left-to-Right Isolate
const val RLI = "\u2067" // Right-to-Left Isolate
const val FSI = "\u2068" // First-Strong Isolate
const val PDI = "\u2069" // Pop Directional Isolate
const val LRM = "\u200E" // Left-to-Right Mark
const val RLM = "\u200F" // Right-to-Left Mark
enum class BidiDirection {
LTR,
RTL,
NEUTRAL,
}
/**
* Determines whether the given text should be rendered as RTL.
*
* 1. If the first strong directional character is RTL (Arabic/Hebrew), returns true.
* 2. If the first strong directional character is LTR (e.g. an Arabic sentence starting with
* an English term like "Android هو نظام..."), returns true if RTL characters outnumber LTR characters.
* 3. If there are no strong characters (empty, numbers, emojis, symbols only), returns false.
*/
fun isRtlText(text: CharSequence): Boolean {
var firstStrongRtl: Boolean? = null
var rtlCount = 0
var ltrCount = 0
var i = 0
val len = text.length
while (i < len) {
val cp = Character.codePointAt(text, i)
when (Character.getDirectionality(cp)) {
Character.DIRECTIONALITY_RIGHT_TO_LEFT,
Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC,
-> {
if (firstStrongRtl == null) firstStrongRtl = true
rtlCount++
}
Character.DIRECTIONALITY_LEFT_TO_RIGHT -> {
if (firstStrongRtl == null) firstStrongRtl = false
ltrCount++
}
}
i += Character.charCount(cp)
}
return when {
firstStrongRtl == true -> true
firstStrongRtl == false -> rtlCount > ltrCount
else -> false
}
}
/**
* Detects the dominant [BidiDirection] of [text].
* Returns [BidiDirection.NEUTRAL] when no strong directional characters exist.
*/
fun detectTextDirection(text: CharSequence): BidiDirection {
var hasStrong = false
var firstStrongRtl: Boolean? = null
var rtlCount = 0
var ltrCount = 0
var i = 0
val len = text.length
while (i < len) {
val cp = Character.codePointAt(text, i)
when (Character.getDirectionality(cp)) {
Character.DIRECTIONALITY_RIGHT_TO_LEFT,
Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC,
-> {
hasStrong = true
if (firstStrongRtl == null) firstStrongRtl = true
rtlCount++
}
Character.DIRECTIONALITY_LEFT_TO_RIGHT -> {
hasStrong = true
if (firstStrongRtl == null) firstStrongRtl = false
ltrCount++
}
}
i += Character.charCount(cp)
}
if (!hasStrong) return BidiDirection.NEUTRAL
val isRtl =
when {
firstStrongRtl == true -> true
firstStrongRtl == false -> rtlCount > ltrCount
else -> false
}
return if (isRtl) BidiDirection.RTL else BidiDirection.LTR
}
/**
* Resolves the Compose [LayoutDirection] for [text].
* If [text] is neutral/empty, falls back to [fallback].
*/
fun resolveLayoutDirection(
text: CharSequence,
fallback: LayoutDirection = LayoutDirection.Ltr,
): LayoutDirection =
when (detectTextDirection(text)) {
BidiDirection.RTL -> LayoutDirection.Rtl
BidiDirection.LTR -> LayoutDirection.Ltr
BidiDirection.NEUTRAL -> fallback
}
/**
* Returns true if [text] contains strong LTR characters (Latin letters, digits)
* and no strong RTL characters (Arabic, Hebrew).
*/
fun isLtrSnippet(text: CharSequence): Boolean {
var hasLtr = false
var i = 0
val len = text.length
while (i < len) {
val cp = Character.codePointAt(text, i)
when (Character.getDirectionality(cp)) {
Character.DIRECTIONALITY_RIGHT_TO_LEFT,
Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC,
-> return false
Character.DIRECTIONALITY_LEFT_TO_RIGHT -> hasLtr = true
}
i += Character.charCount(cp)
}
return hasLtr
}
/**
* Wraps [text] in a Left-to-Right Isolate (LRI ... PDI) if [isLtrSnippet] is true.
* Used to prevent inline English terms and code from bleeding into surrounding RTL runs.
*/
fun wrapLtrIsolate(text: String): String = if (isLtrSnippet(text)) "$LRI$text$PDI" else text
/**
* Appends a Right-to-Left Mark (RLM) to [text] if it is RTL, ensuring trailing
* punctuation and neutral emojis anchor to the visual end of the RTL sentence.
*/
fun anchorTrailingRtl(text: String): String = if (isRtlText(text) && !text.endsWith(RLM)) "$text$RLM" else text
}
+10 -1
View File
@@ -1078,7 +1078,8 @@
<string name="moa_title">مزيج الوكلاء (Mixture of Agents)</string>
<string name="model_aux_tasks_label">المهام المساعدة</string>
<string name="model_aux_tasks_title">المهام المساعدة</string>
<string name="model_expensive_title">نموذج عالي التكلفة</string>
<string name="model_expensive_title">نموذج مرتفع التكلفة</string>
<string name="model_confirm_continue">متابعة</string>
<string name="model_main_label">النموذج الرئيسي</string>
<string name="model_moa_label">مزيج الوكلاء (Mixture of Agents)</string>
<string name="model_new_sessions">ينطبق على الجلسات الجديدة</string>
@@ -1167,7 +1168,11 @@
<string name="group_chat_empty_title">لا توجد رسائل بعد</string>
<string name="group_chat_empty_subtitle">أشر إلى بوت أو استخدم @all لبدء التعاون.</string>
<string name="group_chat_thinking">%1$s يفكّر الآن…</string>
<string name="group_chat_bot_passed">تخطى %1$s دوره</string>
<string name="group_chat_typing">جارٍ الكتابة…</string>
<string name="group_chat_tool_running">جارٍ التشغيل…</string>
<string name="group_chat_tool_no_output">لا يوجد مخرجات</string>
<string name="group_chat_tool_no_output_success">رمز الخروج 0 (لا يوجد مخرجات)</string>
<string name="group_chat_members_count">%1$d أعضاء</string>
<string name="group_chat_mention_all">@all</string>
<string name="group_chat_capped_limit">المحادثة متوقفة مؤقتًا (تم الوصول إلى الحد الأقصى لعدد الردود) — أرسل ردًا للمتابعة</string>
@@ -1181,6 +1186,10 @@
<string name="group_chat_settings_system_prompt">تعليمات / موجه الغرفة</string>
<string name="group_chat_settings_system_prompt_hint">قواعد مخصصة، أو أسلوب، أو سياق لهذه المجموعة (اختياري)…</string>
<string name="group_chat_settings_system_prompt_desc">إرشادات مخصصة لجميع البوتات في هذه الغرفة</string>
<string name="group_chat_settings_high_limits_warning_title">تحذير من الحدود المرتفعة</string>
<string name="group_chat_settings_high_limits_warning_desc">قد يؤدي تعيين حدود مرتفعة للرسائل (%1$d) أو التمريرات (%2$d) إلى استهلاك كبير للرموز (Tokens) وحدوث حلقات نقاش طويلة ومستمرة بين البوتات.</string>
<string name="group_chat_settings_high_limits_confirm">حفظ على أي حال</string>
<string name="group_chat_settings_high_limits_adjust">تعديل الحدود</string>
<string name="group_chat_action_settings">الإعدادات</string>
<string name="group_chat_stopped_by_user">تم إيقاف النقاش</string>
<string name="group_chat_action_stop">إيقاف التوليد</string>
+5
View File
@@ -387,6 +387,7 @@
<string name="model_no_models">보고된 모델이 없거나 인증이 필요합니다.</string>
<string name="content_desc_active_model">활성 모델</string>
<string name="model_section_pinned">고정된 모델</string>
<string name="model_confirm_continue">계속</string>
<string name="content_desc_unpin_model">모델 고정 해제</string>
<string name="content_desc_pinned_section_toggle">고정 섹션 표시 전환</string>
@@ -863,6 +864,10 @@
<string name="group_chat_settings_system_prompt">방 지침 / 프롬프트</string>
<string name="group_chat_settings_system_prompt_hint">이 그룹을 위한 사용자 지정 규칙, 어조 또는 컨텍스트 (선택사항)…</string>
<string name="group_chat_settings_system_prompt_desc">이 방의 모든 봇에 적용되는 사용자 지정 가이드라인</string>
<string name="group_chat_settings_high_limits_warning_title">높은 제한 경고</string>
<string name="group_chat_settings_high_limits_warning_desc">메시지 수(%1$d) 또는 전달 횟수(%2$d)를 높게 설정하면 API 토큰이 대량 소비되고 봇 간의 긴 토론 루프가 발생할 수 있습니다.</string>
<string name="group_chat_settings_high_limits_confirm">그래도 저장</string>
<string name="group_chat_settings_high_limits_adjust">제한 조정</string>
<string name="group_chat_action_settings">설정</string>
<string name="subagent_agent_plan">에이전트 계획</string>
<string name="subagent_live_transcript">실시간 기록</string>
+5
View File
@@ -1069,6 +1069,7 @@
<string name="moa_title">多 Agent 混合</string>
<string name="model_aux_tasks_label">辅助任务</string>
<string name="model_aux_tasks_title">辅助任务</string>
<string name="model_confirm_continue">继续</string>
<string name="model_expensive_title">高成本模型</string>
<string name="model_main_label">主模型</string>
<string name="model_moa_label">多 Agent 混合</string>
@@ -1164,5 +1165,9 @@
<string name="group_chat_settings_system_prompt">房间指令 / 提示词</string>
<string name="group_chat_settings_system_prompt_hint">此群聊的自定义规则、语气或上下文(可选)…</string>
<string name="group_chat_settings_system_prompt_desc">适用于此房间内所有 Bot 的自定义准则</string>
<string name="group_chat_settings_high_limits_warning_title">高上限警告</string>
<string name="group_chat_settings_high_limits_warning_desc">设置较高的消息数 (%1$d) 或交接次数 (%2$d) 可能会消耗大量 API 令牌,并导致机器人之间产生长时间的连续讨论循环。</string>
<string name="group_chat_settings_high_limits_confirm">仍要保存</string>
<string name="group_chat_settings_high_limits_adjust">调整限制</string>
<string name="group_chat_action_settings">设置</string>
</resources>
+10
View File
@@ -145,6 +145,7 @@
<string name="notif_waiting_replies">Waiting for Hermes replies</string>
<string name="notif_new_message">New message</string>
<string name="notif_clarification_needed">Hermes needs a clarification</string>
<string name="notif_input_needed">Hermes needs your input</string>
<string name="notif_reply_placeholder">Type your reply\u2026</string>
<string name="notif_channel_service_name">Hermes Background Service</string>
<string name="notif_channel_service_desc">Keeps connection alive in the background</string>
@@ -1198,6 +1199,7 @@
<string name="moa_title">Mixture of Agents</string>
<string name="model_aux_tasks_label">Auxiliary tasks</string>
<string name="model_aux_tasks_title">Auxiliary Tasks</string>
<string name="model_confirm_continue">Continue</string>
<string name="model_expensive_title">Expensive Model</string>
<string name="model_main_label">Main model</string>
<string name="model_moa_label">Mixture of Agents</string>
@@ -1281,7 +1283,11 @@
<string name="group_chat_empty_title">No Messages Yet</string>
<string name="group_chat_empty_subtitle">Mention a bot or use @all to kick off collaboration.</string>
<string name="group_chat_thinking">%1$s is thinking…</string>
<string name="group_chat_bot_passed">%1$s passed</string>
<string name="group_chat_typing">Typing…</string>
<string name="group_chat_tool_running">Running…</string>
<string name="group_chat_tool_no_output">No output</string>
<string name="group_chat_tool_no_output_success">Exit code 0 (no output)</string>
<string name="group_chat_members_count">%1$d members</string>
<string name="group_chat_mention_all">@all</string>
<string name="group_chat_capped_limit">Discussion paused (turn limit reached) — reply to continue</string>
@@ -1295,6 +1301,10 @@
<string name="group_chat_settings_system_prompt">Room Instructions / Prompt</string>
<string name="group_chat_settings_system_prompt_hint">Custom rules, tone, or context for this group (optional)…</string>
<string name="group_chat_settings_system_prompt_desc">Custom guidelines for all bots in this room</string>
<string name="group_chat_settings_high_limits_warning_title">High Limits Warning</string>
<string name="group_chat_settings_high_limits_warning_desc">Setting high message (%1$d) or handoff (%2$d) limits can consume significant API tokens and lead to long continuous bot discussion loops.</string>
<string name="group_chat_settings_high_limits_confirm">Save Anyway</string>
<string name="group_chat_settings_high_limits_adjust">Adjust Limits</string>
<string name="group_chat_action_settings">Settings</string>
<string name="group_chat_stopped_by_user">Discussion stopped</string>
<string name="group_chat_action_stop">Stop generation</string>
@@ -291,6 +291,223 @@ class EventParserTest {
assertEquals(listOf("staging", "production"), clarifyEvent.options)
}
@Test
fun testParseClarifyRequest_withBatchQuestions_parsesSuccessfully() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "clarify.request",
"payload" to
mapOf(
"request_id" to "req-batch-1",
"questions" to
listOf(
mapOf(
"qid" to "q0",
"question" to "Which database?",
"choices" to listOf("Postgres", "SQLite"),
"multi_select" to false,
),
),
),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.ClarifyRequest)
val clarifyEvent = event as WsEvent.ClarifyRequest
assertEquals("Which database?", clarifyEvent.text)
assertEquals(listOf("Postgres", "SQLite"), clarifyEvent.options)
assertEquals("req-batch-1", clarifyEvent.clarifyId)
assertEquals("q0", clarifyEvent.questionId)
}
@Test
fun testParseClarifyRequest_withMultiSelectAndMultipleQuestions() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "clarify.request",
"payload" to
mapOf(
"request_id" to "req-batch-2",
"questions" to
listOf(
mapOf(
"qid" to "q0",
"question" to "Pick languages:",
"choices" to listOf("Python", "Go", "Rust"),
"multi_select" to true,
),
mapOf(
"qid" to "q1",
"question" to "Any comments?",
"choices" to emptyList<String>(),
"multi_select" to false,
),
),
),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.ClarifyRequest)
val clarifyEvent = event as WsEvent.ClarifyRequest
assertEquals("req-batch-2", clarifyEvent.clarifyId)
assertEquals(2, clarifyEvent.questions.size)
val q0 = clarifyEvent.questions[0]
assertEquals("q0", q0.qid)
assertEquals("Pick languages:", q0.question)
assertEquals(listOf("Python", "Go", "Rust"), q0.choices)
assertTrue(q0.multiSelect)
val q1 = clarifyEvent.questions[1]
assertEquals("q1", q1.qid)
assertEquals("Any comments?", q1.question)
assertTrue(q1.choices.isEmpty())
assertFalse(q1.multiSelect)
}
@Test
fun testParseClarifyExpire_parsesSuccessfully() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "clarify.expire",
"payload" to mapOf("request_id" to "req-expire-1"),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.ClarifyExpire)
val expireEvent = event as WsEvent.ClarifyExpire
assertEquals("req-expire-1", expireEvent.clarifyId)
}
// ── Approval requests (full support) ───────────────────────────────
@Test
fun testParseApprovalRequest_withChoicesAndRequestId() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "approval.request",
"payload" to
mapOf(
"command" to "rm -rf /tmp/x",
"description" to "dangerous command",
"request_id" to "req-1",
"choices" to listOf("once", "session", "always", "deny"),
"allow_permanent" to true,
"pattern_keys" to listOf("shell:rm"),
),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.ApprovalRequest)
val approval = event as WsEvent.ApprovalRequest
assertEquals("rm -rf /tmp/x", approval.command)
assertEquals("req-1", approval.requestId)
assertEquals(listOf("once", "session", "always", "deny"), approval.choices)
assertEquals(true, approval.allowPermanent)
assertEquals(listOf("shell:rm"), approval.patternKeys)
}
@Test
fun testParseApprovalRequest_smartDeniedDefaultsToOnceDeny() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "approval.request",
"payload" to
mapOf(
"command" to "rm -rf /",
"smart_denied" to true,
),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.ApprovalRequest)
val approval = event as WsEvent.ApprovalRequest
assertEquals(listOf("once", "deny"), approval.choices)
assertEquals(true, approval.smartDenied)
}
@Test
fun testParseApprovalRequest_legacyDefaultsToFullChoices() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "approval.request",
"payload" to mapOf("command" to "ls"),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.ApprovalRequest)
val approval = event as WsEvent.ApprovalRequest
assertEquals(listOf("once", "session", "always", "deny"), approval.choices)
}
@Test
fun testParseApprovalRequest_allowPermanentFalseStillParses() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "approval.request",
"payload" to
mapOf(
"command" to "rm",
"choices" to listOf("once", "session", "always", "deny"),
"allow_permanent" to false,
),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.ApprovalRequest)
val approval = event as WsEvent.ApprovalRequest
assertEquals(false, approval.allowPermanent)
}
// ── TEST-07: Untested subtypes ─────────────────────────────────────
@Test
@@ -746,4 +963,71 @@ class EventParserTest {
assertEquals("1", todoUpdated.todos[1].parent)
assertTrue(todoUpdated.todos[1].isSubtask)
}
@Test
fun testParseSudoRequest_returnsSudoRequest() {
val params =
mapOf(
"type" to "sudo.request",
"session_id" to "sess-sudo-1",
"payload" to mapOf("request_id" to "sudo-1"),
)
val event = EventParser.parseParams(params)
assertTrue(event is WsEvent.SudoRequest)
val sudo = event as WsEvent.SudoRequest
assertEquals("sudo-1", sudo.requestId)
assertEquals("sess-sudo-1", sudo.sessionId)
}
@Test
fun testParseSudoExpire_returnsSudoExpire() {
val params =
mapOf(
"type" to "sudo.expire",
"session_id" to "sess-sudo-1",
"payload" to mapOf("request_id" to "sudo-1"),
)
val event = EventParser.parseParams(params)
assertTrue(event is WsEvent.SudoExpire)
val expire = event as WsEvent.SudoExpire
assertEquals("sudo-1", expire.requestId)
assertEquals("sess-sudo-1", expire.sessionId)
}
@Test
fun testParseSecretRequest_withEnvVarAndPrompt() {
val params =
mapOf(
"type" to "secret.request",
"session_id" to "sess-secret-1",
"payload" to
mapOf(
"request_id" to "secret-1",
"env_var" to "GITHUB_TOKEN",
"prompt" to "Enter your GitHub token to continue",
),
)
val event = EventParser.parseParams(params)
assertTrue(event is WsEvent.SecretRequest)
val secret = event as WsEvent.SecretRequest
assertEquals("secret-1", secret.requestId)
assertEquals("sess-secret-1", secret.sessionId)
assertEquals("GITHUB_TOKEN", secret.envVar)
assertEquals("Enter your GitHub token to continue", secret.prompt)
}
@Test
fun testParseSecretExpire_returnsSecretExpire() {
val params =
mapOf(
"type" to "secret.expire",
"session_id" to "sess-secret-1",
"payload" to mapOf("request_id" to "secret-1"),
)
val event = EventParser.parseParams(params)
assertTrue(event is WsEvent.SecretExpire)
val expire = event as WsEvent.SecretExpire
assertEquals("secret-1", expire.requestId)
assertEquals("sess-secret-1", expire.sessionId)
}
}
@@ -68,4 +68,56 @@ class GroupChatMentionsTest {
assertEquals(1, responders.size)
assertEquals("coder", responders.first().name)
}
@Test
fun groupChatMentionAutocomplete_endToEndIntegration() {
// 1. User types "@cod"
val input =
androidx.compose.ui.text.input
.TextFieldValue(
"hey @cod",
androidx.compose.ui.text
.TextRange(8),
)
val query =
com.m57.hermescontrol.ui.chat.ChatInputPolicy
.extractMentionQuery(input.text, input.selection.end)
assertEquals("cod", query)
// 2. Candidate filtering:
// Query "@c" matches both "coder" (starts with c) and "Scout Bot" (contains c)
val broadMatches =
members.filter {
it.name.startsWith("c", ignoreCase = true) || it.effectiveTitle.contains("c", ignoreCase = true)
}
assertEquals(2, broadMatches.size)
// Narrower query "@cod" matches only "coder"
val matchingBots =
members.filter {
it.name.startsWith(query!!, ignoreCase = true) || it.effectiveTitle.contains(query, ignoreCase = true)
}
assertEquals(1, matchingBots.size)
assertEquals("coder", matchingBots.first().name)
// 3. User taps chip for coder
val completed =
com.m57.hermescontrol.ui.chat.ChatInputPolicy
.applyMention(input, matchingBots.first().name)
assertEquals("hey @coder ", completed.text)
assertEquals(11, completed.selection.end)
// 4. Mention query is now null after space
val nextQuery =
com.m57.hermescontrol.ui.chat.ChatInputPolicy.extractMentionQuery(
completed.text,
completed.selection.end,
)
org.junit.Assert.assertNull(nextQuery)
// 5. Group chat mentions resolver picks up the mentioned bot
val responders = GroupChatMentions.resolveResponders(completed.text, members)
assertEquals(1, responders.size)
assertEquals("coder", responders.first().name)
}
}
@@ -266,9 +266,17 @@ class GroupChatViewModelTest {
testScheduler.runCurrent()
val finalState = viewModel.uiState.value
// Only the user message should remain; (pass) is filtered out
assertEquals(1, finalState.messages.size)
// User message plus the visual pass indicator
assertEquals(2, finalState.messages.size)
assertTrue(finalState.messages.first().isUser)
val passMsg = finalState.messages.last()
assertTrue(passMsg.isSystem)
assertTrue(passMsg.isPass)
assertEquals("Scout Bot", passMsg.senderDisplayName)
assertEquals("Scout Bot passed", passMsg.text)
// LLM prompt history only considers non-system messages
assertEquals(1, finalState.messages.filter { !it.isSystem }.size)
}
@Test
@@ -420,6 +428,33 @@ class GroupChatViewModelTest {
assertEquals(15, viewModel.uiState.value.maxBotMessages)
assertEquals(4, viewModel.uiState.value.maxContinuationPasses)
assertEquals("Custom instructions for this group", viewModel.uiState.value.systemPrompt)
// Extended limits within new range
viewModel.updateGroupLimits(
maxMessages = 45,
maxPasses = 18,
)
testScheduler.runCurrent()
assertEquals(45, viewModel.uiState.value.maxBotMessages)
assertEquals(18, viewModel.uiState.value.maxContinuationPasses)
// Exceeding ceiling clamps to MAX_ALLOWED_BOT_MESSAGES (50) and MAX_ALLOWED_CONTINUATION_PASSES (20)
viewModel.updateGroupLimits(
maxMessages = 999,
maxPasses = 999,
)
testScheduler.runCurrent()
assertEquals(MAX_ALLOWED_BOT_MESSAGES, viewModel.uiState.value.maxBotMessages)
assertEquals(MAX_ALLOWED_CONTINUATION_PASSES, viewModel.uiState.value.maxContinuationPasses)
// Under floor clamps to 1 and 0
viewModel.updateGroupLimits(
maxMessages = -10,
maxPasses = -5,
)
testScheduler.runCurrent()
assertEquals(1, viewModel.uiState.value.maxBotMessages)
assertEquals(0, viewModel.uiState.value.maxContinuationPasses)
}
@Test
@@ -473,13 +508,19 @@ class GroupChatViewModelTest {
val toolCall = msgWithTool.toolCalls.first()
assertEquals("terminal", toolCall.name)
assertEquals("date", toolCall.summary)
assertEquals("date", toolCall.command)
assertTrue(toolCall.isRunning)
// Emit ToolComplete
eventsFlow.emit(
WsEvent.ToolComplete(
name = "terminal",
data = mapOf("tool_id" to "tool-123"),
data =
mapOf(
"tool_id" to "tool-123",
"output" to "Sat Aug 29 03:30:00 UTC 2026",
"exit_code" to 0,
),
sessionId = "session-scout-1",
),
)
@@ -488,7 +529,11 @@ class GroupChatViewModelTest {
val msgAfterToolDone =
viewModel.uiState.value.messages
.last()
assertFalse(msgAfterToolDone.toolCalls.first().isRunning)
val completedTool = msgAfterToolDone.toolCalls.first()
assertFalse(completedTool.isRunning)
assertEquals("Sat Aug 29 03:30:00 UTC 2026", completedTool.output)
assertEquals(0, completedTool.exitCode)
assertFalse(completedTool.isError)
// Complete message
eventsFlow.emit(
@@ -793,6 +793,148 @@ class ChatViewModelTest {
assertEquals(sessionId, call.third)
}
@Test
fun testModelSwitch_confirmRequired_triggersConfirmationDialogAndReSends() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
var lastReqId = ""
val capturedParams = mutableListOf<Map<String, Any>>()
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers {
val params = arg<Map<String, Any>>(1)
capturedParams.add(params)
val id = "req-confirm-${capturedParams.size}"
lastReqId = id
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
// User sends model switch command that triggers privacy/expensive guard
viewModel.sendMessage("/model muse-spark-1.3-contributor-free --provider opencode-free --session")
advanceUntilIdle()
assertEquals(1, capturedParams.size)
assertNull(capturedParams[0]["confirm_expensive_model"])
// Backend returns confirm_required = true
mockEventsFlow.emit(
WsEvent.RpcResult(
id = lastReqId,
result =
mapOf(
"key" to "model",
"value" to "muse-spark-1.3-contributor-free",
"confirm_required" to true,
"confirm_message" to "Meta training warning",
),
),
)
advanceUntilIdle()
// UI state now shows confirmation dialog message
assertEquals("Meta training warning", viewModel.uiState.value.modelSwitchConfirmMessage)
// User confirms
viewModel.confirmModelSwitchExpensive()
advanceUntilIdle()
assertNull(viewModel.uiState.value.modelSwitchConfirmMessage)
assertEquals(2, capturedParams.size)
assertEquals(true, capturedParams[1]["confirm_expensive_model"])
assertEquals(
"muse-spark-1.3-contributor-free --provider opencode-free --session",
capturedParams[1]["value"],
)
assertEquals(sessionId, capturedParams[1]["session_id"])
}
@Test
fun testModelSwitch_dismissConfirmation_revertsModel() =
runTest {
val (viewModel, _) = createViewModelWithSession()
var lastReqId = ""
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers {
val id = "req-cfg-dismiss"
lastReqId = id
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
// Set an initial model
viewModel.sendSlashModel("openai", "gpt-4o")
advanceUntilIdle()
assertEquals("openai/gpt-4o", viewModel.uiState.value.currentSessionModel)
// Switch to expensive model
viewModel.sendSlashModel("opencode-free", "muse-spark-1.3-contributor-free")
advanceUntilIdle()
assertEquals("opencode-free/muse-spark-1.3-contributor-free", viewModel.uiState.value.currentSessionModel)
// Backend requires confirmation
mockEventsFlow.emit(
WsEvent.RpcResult(
id = lastReqId,
result =
mapOf(
"key" to "model",
"value" to "muse-spark-1.3-contributor-free",
"confirm_required" to true,
"confirm_message" to "Meta training warning",
),
),
)
advanceUntilIdle()
assertEquals("Meta training warning", viewModel.uiState.value.modelSwitchConfirmMessage)
// User dismisses/cancels
viewModel.dismissModelSwitchConfirm()
advanceUntilIdle()
assertNull(viewModel.uiState.value.modelSwitchConfirmMessage)
// Reverted back to previous model
assertEquals("openai/gpt-4o", viewModel.uiState.value.currentSessionModel)
}
@Test
fun testUndoCommand_dispatchesAndPrefillsComposer() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
every {
HermesWsClient.request(
WsMethods.COMMAND_DISPATCH,
mapOf("name" to "undo", "arg" to "2", "session_id" to sessionId),
any(),
)
} returns
CompletableDeferred(
mapOf(
"type" to "prefill",
"message" to "undone user message",
"notice" to "↶ Undid 2 turns",
),
)
viewModel.sendMessage("/undo 2")
advanceUntilIdle()
// Prefill text is staged in UI state
assertEquals("undone user message", viewModel.uiState.value.pendingPrefillText)
// Notice is displayed as system message with rewind target feedback
assertTrue(
viewModel.uiState.value.messages
.any { it.content.contains("↶ Undid 2 turns") && it.content.contains("undone user message") },
)
// Verify slash usage was recorded
assertEquals(1, viewModel.uiState.value.slashUsageCounts["/undo"])
// Consume prefill text
viewModel.consumePendingPrefill()
advanceUntilIdle()
assertNull(viewModel.uiState.value.pendingPrefillText)
}
// ── Connection / init tests ──────────────────────────────────────────────
@Test
@@ -1217,6 +1359,130 @@ class ChatViewModelTest {
}
}
@Test
fun testClarifyRequestWithQuestionIdAndRespond() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
mockEventsFlow.emit(
WsEvent.ClarifyRequest(
text = "Pick one:",
options = listOf("Option 1"),
clarifyId = "clarify-batch-1",
sessionId = sessionId,
questionId = "q0",
),
)
advanceUntilIdle()
assertEquals(
"Pick one:",
viewModel.uiState.value.clarifyRequest
?.text,
)
assertEquals(
"q0",
viewModel.uiState.value.clarifyRequest
?.questionId,
)
viewModel.respondToClarify("Option 1")
advanceUntilIdle()
assertNull(viewModel.uiState.value.clarifyRequest)
verify {
HermesWsClient.send(
method = WsMethods.CLARIFY_RESPOND,
params =
mapOf(
"session_id" to sessionId,
"response" to "Option 1",
"answer" to "Option 1",
"clarify_id" to "clarify-batch-1",
"request_id" to "clarify-batch-1",
"question_id" to "q0",
),
onSent = any(),
)
}
}
@Test
fun testClarifyBatch_respondsToAllQuestions() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
mockEventsFlow.emit(
WsEvent.ClarifyRequest(
text = "1. Pick A\n\n2. Pick B",
options = listOf("A1", "A2"),
clarifyId = "clarify-batch-multi",
sessionId = sessionId,
questionId = "q0",
questions =
listOf(
WsEvent.ClarifyQuestion(
qid = "q0",
question = "Pick A",
choices = listOf("A1", "A2"),
multiSelect = false,
),
WsEvent.ClarifyQuestion(
qid = "q1",
question = "Pick B",
choices = listOf("B1", "B2"),
multiSelect = true,
),
),
),
)
advanceUntilIdle()
val clarify = viewModel.uiState.value.clarifyRequest
assertNotNull(clarify)
assertEquals(2, clarify?.resolvedQuestions?.size)
viewModel.respondToClarifyBatch(
mapOf(
"q0" to "A1",
"q1" to "B1, B2",
),
)
advanceUntilIdle()
assertNull(viewModel.uiState.value.clarifyRequest)
verify {
HermesWsClient.send(
method = WsMethods.CLARIFY_RESPOND,
params =
mapOf(
"session_id" to sessionId,
"response" to "A1",
"answer" to "A1",
"question_id" to "q0",
"clarify_id" to "clarify-batch-multi",
"request_id" to "clarify-batch-multi",
),
onSent = any(),
)
}
verify {
HermesWsClient.send(
method = WsMethods.CLARIFY_RESPOND,
params =
mapOf(
"session_id" to sessionId,
"response" to "B1, B2",
"answer" to "B1, B2",
"question_id" to "q1",
"clarify_id" to "clarify-batch-multi",
"request_id" to "clarify-batch-multi",
),
onSent = any(),
)
}
}
@Test
fun testClarifyRequestCustomResponse() =
runTest {
@@ -1316,6 +1582,53 @@ class ChatViewModelTest {
}
}
@Test
fun testClarifyDismissWithQuestionIdInformsAgent() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
mockEventsFlow.emit(
WsEvent.ClarifyRequest(
"Please choose:",
listOf("Yes", "No"),
"clarify-789",
sessionId,
questionId = "q0",
),
)
advanceUntilIdle()
assertEquals(
"clarify-789",
viewModel.uiState.value.clarifyRequest
?.clarifyId,
)
assertEquals(
"q0",
viewModel.uiState.value.clarifyRequest
?.questionId,
)
viewModel.dismissClarify()
advanceUntilIdle()
assertNull(viewModel.uiState.value.clarifyRequest)
verify {
HermesWsClient.send(
method = WsMethods.CLARIFY_RESPOND,
params =
mapOf(
"session_id" to sessionId,
"response" to "The user cancelled — no answer provided.",
"answer" to "The user cancelled — no answer provided.",
"clarify_id" to "clarify-789",
"request_id" to "clarify-789",
"question_id" to "q0",
),
onSent = any(),
)
}
}
// ── Attachments ──────────────────────────────────────────────────────────
/** Add [count] dummy attachments so a test starts with a populated list. */
@@ -2722,6 +3035,236 @@ class ChatViewModelTest {
assertNull(msgAfter!!.approvalInfo)
}
@Test
fun testApprovalRequest_storesRequestIdAndChoices() =
runTest {
val viewModel = createViewModel()
advanceUntilIdle()
mockEventsFlow.emit(
WsEvent.ApprovalRequest(
command = "rm -rf /tmp/x",
description = "dangerous command",
patternKeys = listOf("shell:rm"),
sessionId = null,
requestId = "req-1",
choices = listOf("once", "session", "always", "deny"),
allowPermanent = true,
smartDenied = false,
),
)
advanceUntilIdle()
val msg =
viewModel.uiState.value.messages
.first { it.content.contains("Approval Required") }
assertEquals("req-1", msg.approvalInfo?.requestId)
assertEquals(
listOf("once", "session", "always", "deny"),
msg.approvalInfo?.choices,
)
assertEquals(true, msg.approvalInfo?.allowPermanent)
}
@Test
fun testRespondToApproval_mapsApproveToOnceAndSendsRequestId() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
advanceUntilIdle()
mockEventsFlow.emit(
WsEvent.ApprovalRequest(
command = "rm",
description = "Dangerous",
patternKeys = null,
sessionId = null,
requestId = "req-42",
choices = listOf("once", "deny"),
),
)
advanceUntilIdle()
viewModel.respondToApproval("approve")
advanceUntilIdle()
verify {
HermesWsClient.send(
WsMethods.APPROVAL_RESPOND,
withArg { params ->
assertEquals(sessionId, params["session_id"])
assertEquals("once", params["choice"])
assertEquals("req-42", params["request_id"])
},
any(),
)
}
}
@Test
fun testRespondToApproval_sessionChoice() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
advanceUntilIdle()
mockEventsFlow.emit(
WsEvent.ApprovalRequest(
command = "rm",
description = "Dangerous",
patternKeys = null,
sessionId = null,
requestId = "req-7",
choices = listOf("once", "session", "always", "deny"),
),
)
advanceUntilIdle()
viewModel.respondToApproval("session")
advanceUntilIdle()
verify {
HermesWsClient.send(
WsMethods.APPROVAL_RESPOND,
withArg { params ->
assertEquals("session", params["choice"])
assertEquals("req-7", params["request_id"])
},
any(),
)
}
}
@Test
fun testRespondToApproval_omitsRequestIdWhenAbsent() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
advanceUntilIdle()
mockEventsFlow.emit(
WsEvent.ApprovalRequest(
command = "ls",
description = "Safe",
patternKeys = null,
sessionId = null,
),
)
advanceUntilIdle()
viewModel.respondToApproval("deny")
advanceUntilIdle()
verify {
HermesWsClient.send(
WsMethods.APPROVAL_RESPOND,
withArg { params ->
assertEquals("deny", params["choice"])
assertFalse(params.containsKey("request_id"))
},
any(),
)
}
}
@Test
fun testRespondToApproval_triggersPendingReplay() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
advanceUntilIdle()
mockEventsFlow.emit(
WsEvent.ApprovalRequest(
command = "rm",
description = "Dangerous",
patternKeys = null,
sessionId = null,
requestId = "req-9",
choices = listOf("once", "deny"),
),
)
advanceUntilIdle()
viewModel.respondToApproval("once")
advanceUntilIdle()
verify {
HermesWsClient.send(
WsMethods.APPROVAL_PENDING,
withArg { params ->
assertEquals(sessionId, params["session_id"])
},
any(),
)
}
}
@Test
fun testApprovalFlow_prefersRuntimeSessionIdOverStorageId() =
runTest {
val viewModel = createViewModel()
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
mockEventsFlow.emit(
WsEvent.RpcResult(
"req-id-3",
mapOf(
"session_id" to "runtime-sid-999",
"stored_session_id" to "storage-uuid-111",
),
),
)
advanceUntilIdle()
assertEquals("storage-uuid-111", viewModel.uiState.value.currentSessionId)
mockEventsFlow.emit(
WsEvent.ApprovalRequest(
command = "rm -rf /data",
description = "Dangerous operation",
patternKeys = null,
sessionId = null,
requestId = "req-runtime-test",
choices = listOf("once", "deny"),
),
)
advanceUntilIdle()
verify {
HermesWsClient.send(
WsMethods.APPROVAL_RECEIVED,
withArg { params ->
assertEquals("runtime-sid-999", params["session_id"])
assertEquals("req-runtime-test", params["request_id"])
},
any(),
)
}
viewModel.respondToApproval("once")
advanceUntilIdle()
verify {
HermesWsClient.send(
WsMethods.APPROVAL_RESPOND,
withArg { params ->
assertEquals("runtime-sid-999", params["session_id"])
assertEquals("once", params["choice"])
assertEquals("req-runtime-test", params["request_id"])
},
any(),
)
HermesWsClient.send(
WsMethods.APPROVAL_PENDING,
withArg { params ->
assertEquals("runtime-sid-999", params["session_id"])
},
any(),
)
}
}
// ── Sudo / secret prompt flow (issue #524) ───────────────────────────
@Test
@@ -2846,6 +3389,130 @@ class ChatViewModelTest {
assertNull(viewModel.uiState.value.secretPrompt)
}
@Test
fun testSudoExpire_clearsMatchingPrompt() =
runTest {
val (viewModel, _) = createViewModelWithSession()
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.SudoRequest(requestId = "sudo-1", sessionId = null))
advanceUntilIdle()
assertNotNull(viewModel.uiState.value.sudoPrompt)
mockEventsFlow.emit(WsEvent.SudoExpire(requestId = "sudo-1", sessionId = null))
advanceUntilIdle()
assertNull(viewModel.uiState.value.sudoPrompt)
}
@Test
fun testSudoExpire_ignoresNonMatchingRequestId() =
runTest {
val (viewModel, _) = createViewModelWithSession()
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.SudoRequest(requestId = "sudo-2", sessionId = null))
advanceUntilIdle()
assertNotNull(viewModel.uiState.value.sudoPrompt)
mockEventsFlow.emit(WsEvent.SudoExpire(requestId = "sudo-stale", sessionId = null))
advanceUntilIdle()
assertNotNull(viewModel.uiState.value.sudoPrompt)
}
@Test
fun testSecretExpire_clearsMatchingPrompt() =
runTest {
val (viewModel, _) = createViewModelWithSession()
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.SecretRequest(requestId = "secret-1", sessionId = null))
advanceUntilIdle()
assertNotNull(viewModel.uiState.value.secretPrompt)
mockEventsFlow.emit(WsEvent.SecretExpire(requestId = "secret-1", sessionId = null))
advanceUntilIdle()
assertNull(viewModel.uiState.value.secretPrompt)
}
@Test
fun testSecretRequest_setsEnvVarAndPrompt() =
runTest {
val (viewModel, _) = createViewModelWithSession()
advanceUntilIdle()
mockEventsFlow.emit(
WsEvent.SecretRequest(
requestId = "secret-1",
sessionId = null,
envVar = "GITHUB_TOKEN",
prompt = "Enter your GitHub token",
),
)
advanceUntilIdle()
val prompt = viewModel.uiState.value.secretPrompt
assertNotNull(prompt)
assertEquals("GITHUB_TOKEN", prompt?.envVar)
assertEquals("Enter your GitHub token", prompt?.prompt)
}
@Test
fun testDismissSudo_sendsEmptyPassword() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.SudoRequest(requestId = "sudo-1", sessionId = null))
advanceUntilIdle()
assertNotNull(viewModel.uiState.value.sudoPrompt)
viewModel.dismissSudo()
advanceUntilIdle()
assertNull(viewModel.uiState.value.sudoPrompt)
verify {
HermesWsClient.send(
WsMethods.SUDO_RESPOND,
withArg { params ->
assertEquals(sessionId, params["session_id"])
assertEquals("", params["password"])
assertEquals("sudo-1", params["request_id"])
},
any(),
)
}
}
@Test
fun testDismissSecret_sendsEmptyValue() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.SecretRequest(requestId = "secret-1", sessionId = null))
advanceUntilIdle()
assertNotNull(viewModel.uiState.value.secretPrompt)
viewModel.dismissSecret()
advanceUntilIdle()
assertNull(viewModel.uiState.value.secretPrompt)
verify {
HermesWsClient.send(
WsMethods.SECRET_RESPOND,
withArg { params ->
assertEquals(sessionId, params["session_id"])
assertEquals("", params["value"])
assertEquals("secret-1", params["request_id"])
},
any(),
)
}
}
// ── Settings ─────────────────────────────────────────────────────────────
@Test
@@ -26,6 +26,25 @@ class ChatWsEventReducerTest {
assertEquals(null, result.state.clarifyRequest)
}
@Test
fun testClarifyExpire_clearsMatchingClarifyRequest() {
val state =
ChatUiState(
currentSessionId = "session-1",
clarifyRequest = ClarifyUi("Expiring question", emptyList(), "clarify-expire-1"),
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = WsEvent.ClarifyExpire(clarifyId = "clarify-expire-1", sessionId = "session-1"),
currentSessionId = "session-1",
)
assertEquals(null, result.state.clarifyRequest)
}
@Test
fun testToolProgress_updatesProgressPreviewForMatchingRunningTool() {
val initialMessage =
@@ -11,6 +11,7 @@ import com.m57.hermescontrol.theme.StatusRedContainer
import com.m57.hermescontrol.theme.StatusYellow
import com.m57.hermescontrol.theme.StatusYellowContainer
import com.m57.hermescontrol.theme.searchHighlightColors
import com.m57.hermescontrol.util.BidiUtils
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -538,4 +539,36 @@ class MarkdownTextFeatureTest {
val codeBlock = blocks.single() as MdBlock.Code
assertTrue(codeBlock.code.contains("```python"))
}
// 25. ARABIC BIDI MIXED TEXT (issue #1044)
@Test
fun testArabicMixedWithInlineCode_wrapsWithLtrIsolate() {
val input = "تم اختبار كود `val x = 1` بنجاح"
val parsed = parseInline(input, Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
val expected = "تم اختبار كود ${BidiUtils.LRI}val x = 1${BidiUtils.PDI} بنجاح"
assertEquals(expected, parsed.toString())
}
@Test
fun testArabicMixedWithBoldEnglish_wrapsWithLtrIsolate() {
val input = "هذا النص يحتوي على **Android** داخل فقرة"
val parsed = parseInline(input, Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
val expected = "هذا النص يحتوي على ${BidiUtils.LRI}Android${BidiUtils.PDI} داخل فقرة"
assertEquals(expected, parsed.toString())
}
@Test
fun testEnglishWithInlineCode_doesNotWrapWithIsolate() {
val input = "Run `val x = 1` here"
val parsed = parseInline(input, Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertEquals("Run val x = 1 here", parsed.toString())
}
@Test
fun testArabicStartingWithEnglishWord_wrapsEnglishInLtrIsolate() {
val input = "Okay سكرت كلشي وصار كامل عربي متل ما بدك يا بوبو"
val parsed = parseInline(input, Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
val expected = "${BidiUtils.LRI}Okay${BidiUtils.PDI} سكرت كلشي وصار كامل عربي متل ما بدك يا بوبو"
assertEquals(expected, parsed.toString())
}
}
@@ -82,6 +82,14 @@ class SlashCommandDispatcherTest {
assertEquals(SlashResult.SideQuestion(""), dispatcher.dispatch("/btw "))
}
@Test
fun `undo routes to Undo`() {
assertEquals(SlashResult.Undo(""), dispatcher.dispatch("/undo"))
assertEquals(SlashResult.Undo("2"), dispatcher.dispatch("/undo 2"))
assertEquals(SlashResult.Undo("3"), dispatcher.dispatch("/UNDO 3"))
assertEquals(SlashResult.Undo(""), dispatcher.dispatch("/Undo"))
}
@Test
fun `NEW uppercase still routes to NewSession`() {
// Dispatcher lower-cases before matching, so case must not matter.
@@ -27,6 +27,10 @@ class FakeChatMessageDao : ChatMessageDao {
messageList.forEach { messages[it.id] = it }
}
override suspend fun deleteMessagesForSession(sessionId: String) {
messages.values.removeAll { it.sessionId == sessionId }
}
/** Direct access for test setup — bypasses the suspend modifier. */
fun addMessageDirect(message: ChatMessageEntity) {
messages[message.id] = message
@@ -0,0 +1,89 @@
package com.m57.hermescontrol.util
import androidx.compose.ui.unit.LayoutDirection
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BidiUtilsTest {
@Test
fun testIsRtlText_pureArabic() {
assertTrue(BidiUtils.isRtlText("مرحبا بك في تطبيق هيرمس"))
}
@Test
fun testIsRtlText_pureEnglish() {
assertFalse(BidiUtils.isRtlText("Hello from Hermes Control"))
}
@Test
fun testIsRtlText_mixedArabicWithEnglishWords() {
val text = "هذا النص يحتوي على Android و Compose داخل فقرة عربية"
assertTrue(BidiUtils.isRtlText(text))
}
@Test
fun testIsRtlText_arabicStartingWithEnglishTerm() {
val text = "Android هو نظام تشغيل رائع ومميز جدا"
assertTrue(BidiUtils.isRtlText(text))
}
@Test
fun testIsRtlText_englishSentenceWithArabicWord() {
val text = "The Arabic word for welcome is مرحبا in most dialects"
assertFalse(BidiUtils.isRtlText(text))
}
@Test
fun testIsRtlText_neutralOnly() {
assertFalse(BidiUtils.isRtlText(""))
assertFalse(BidiUtils.isRtlText(" "))
assertFalse(BidiUtils.isRtlText("12345"))
assertFalse(BidiUtils.isRtlText("🚀✨🔥"))
assertFalse(BidiUtils.isRtlText("/status"))
}
@Test
fun testIsRtlText_neutralPrefixWithArabic() {
assertTrue(BidiUtils.isRtlText("🚀 مرحبا يا صديقي"))
assertTrue(BidiUtils.isRtlText("- [x] مهمة جديدة مع Compose"))
}
@Test
fun testDetectTextDirection() {
assertEquals(BidiUtils.BidiDirection.RTL, BidiUtils.detectTextDirection("أهلا"))
assertEquals(BidiUtils.BidiDirection.LTR, BidiUtils.detectTextDirection("Hello"))
assertEquals(BidiUtils.BidiDirection.NEUTRAL, BidiUtils.detectTextDirection("123 🚀"))
}
@Test
fun testResolveLayoutDirection() {
assertEquals(LayoutDirection.Rtl, BidiUtils.resolveLayoutDirection("صباح الخير"))
assertEquals(LayoutDirection.Ltr, BidiUtils.resolveLayoutDirection("Good morning"))
assertEquals(LayoutDirection.Ltr, BidiUtils.resolveLayoutDirection("123", fallback = LayoutDirection.Ltr))
assertEquals(LayoutDirection.Rtl, BidiUtils.resolveLayoutDirection("123", fallback = LayoutDirection.Rtl))
}
@Test
fun testWrapLtrIsolate() {
assertEquals("${BidiUtils.LRI}Android${BidiUtils.PDI}", BidiUtils.wrapLtrIsolate("Android"))
assertEquals("${BidiUtils.LRI}val x = 1${BidiUtils.PDI}", BidiUtils.wrapLtrIsolate("val x = 1"))
// RTL snippet should not be wrapped
assertEquals("مرحبا", BidiUtils.wrapLtrIsolate("مرحبا"))
}
@Test
fun testAnchorTrailingRtl() {
val rtlWithEmoji = "مرحبا بكم 🚀"
assertEquals("مرحبا بكم 🚀${BidiUtils.RLM}", BidiUtils.anchorTrailingRtl(rtlWithEmoji))
// Does not append duplicate RLM
val alreadyAnchored = "مرحبا بكم 🚀${BidiUtils.RLM}"
assertEquals(alreadyAnchored, BidiUtils.anchorTrailingRtl(alreadyAnchored))
// LTR text remains unmodified
val ltrWithEmoji = "Welcome to Hermes 🚀"
assertEquals(ltrWithEmoji, BidiUtils.anchorTrailingRtl(ltrWithEmoji))
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ retrofit = "3.0.0"
securityCrypto = "1.0.0"
mockk = "1.14.11"
turbine = "1.2.1"
coil = "3.6.0"
coil = "3.6.1"
room = "3.0.2"
sqlcipher = "4.18.0"
datastore = "1.1.2"