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 ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
+29
-13
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user