Merge dev into main for v1.27.0

Promote verified dev to stable main for release v1.27.0.
This commit is contained in:
M57 authored and GitHub committed 2026-09-13 21:23:31 +03:00
commit 9b271c8393
206 files changed
+29780 -10330

No files matched your search

+6
View File
@@ -34,6 +34,12 @@
---
## Support the project
If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi](https://ko-fi.com/m_5_7).
---
## Screenshots
<p align="center">
@@ -0,0 +1,110 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "3deb0f3f202de9316222a02a0b5ae80e",
"entities": [
{
"tableName": "chat_messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `session_id` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `reasoning_text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `tool_name` TEXT, `tool_call_id` TEXT NOT NULL, `tool_status` TEXT, `is_streaming` INTEGER NOT NULL, `display_kind` TEXT, `token_count` INTEGER, `tps` REAL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionId",
"columnName": "session_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "reasoningText",
"columnName": "reasoning_text",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "toolName",
"columnName": "tool_name",
"affinity": "TEXT"
},
{
"fieldPath": "toolCallId",
"columnName": "tool_call_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toolStatus",
"columnName": "tool_status",
"affinity": "TEXT"
},
{
"fieldPath": "isStreaming",
"columnName": "is_streaming",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayKind",
"columnName": "display_kind",
"affinity": "TEXT"
},
{
"fieldPath": "tokenCount",
"columnName": "token_count",
"affinity": "INTEGER"
},
{
"fieldPath": "tps",
"columnName": "tps",
"affinity": "REAL"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_chat_messages_session_id_timestamp",
"unique": false,
"columnNames": [
"session_id",
"timestamp"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` ON `${TABLE_NAME}` (`session_id`, `timestamp`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3deb0f3f202de9316222a02a0b5ae80e')"
]
}
}
@@ -0,0 +1,413 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorStatus
import com.m57.hermescontrol.ui.chat.ChatConnectorsUiState
import com.m57.hermescontrol.ui.chat.ConnectorActionState
import com.m57.hermescontrol.ui.chat.ConnectorUiItem
import com.m57.hermescontrol.ui.chat.ConnectorsLoadPhase
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class SessionIntegrationsSheetTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testLoadingState_displaysLoadingIndicator() {
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Loading,
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_loading").assertIsDisplayed()
}
@Test
fun testConnectedItem_displaysConnectedStatusAndReconnectAction() {
var clickedSlug: String? = null
var clickedReconnect: Boolean? = null
val item =
ConnectorUiItem(
slug = "github",
name = "GitHub",
isConnected = true,
isEnabled = true,
status = ConnectorStatus.CONNECTED,
connectionStatus = "connected",
)
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
items = listOf(item),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { slug, reconnect ->
clickedSlug = slug
clickedReconnect = reconnect
},
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_item_github").assertIsDisplayed()
composeTestRule.onNodeWithText("GitHub").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_action_github").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_action_github").performClick()
assertEquals("github", clickedSlug)
assertEquals(true, clickedReconnect)
}
@Test
fun testDisconnectedItem_displaysConnectActionAndTriggersCallback() {
var clickedSlug: String? = null
var clickedReconnect: Boolean? = null
val item =
ConnectorUiItem(
slug = "slack",
name = "Slack",
isConnected = false,
isEnabled = true,
status = ConnectorStatus.DISCONNECTED,
connectionStatus = "disconnected",
)
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
items = listOf(item),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { slug, reconnect ->
clickedSlug = slug
clickedReconnect = reconnect
},
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_item_slack").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_action_slack").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_action_slack").performClick()
assertEquals("slack", clickedSlug)
assertEquals(false, clickedReconnect)
}
@Test
fun testConnectingState_displaysSpinnerAndDisablesActions() {
val item =
ConnectorUiItem(
slug = "google",
name = "Google Drive",
isConnected = false,
isEnabled = true,
status = ConnectorStatus.DISCONNECTED,
connectionStatus = "disconnected",
)
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
items = listOf(item),
actionState = ConnectorActionState.Connecting(slug = "google", isReconnect = false),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_spinner_google").assertIsDisplayed()
}
@Test
fun testAwaitingAuthorizationState_displaysBannerAndRefreshStatus() {
var refreshed = false
val item =
ConnectorUiItem(
slug = "jira",
name = "Jira",
isConnected = false,
isEnabled = true,
status = ConnectorStatus.INITIATED,
connectionStatus = "initiated",
)
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
items = listOf(item),
actionState = ConnectorActionState.AwaitingAuthorization(slug = "jira"),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = { refreshed = true },
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_awaiting_banner").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_refresh_status_button").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_refresh_status_button").performClick()
assertTrue(refreshed)
}
@Test
fun testUnavailablePhase_displaysErrorMessage() {
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Unavailable("Connectors unavailable"),
items = emptyList(),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
}
@Test
fun testErrorPhase_displaysRetryButtonAndTriggersRefresh() {
var retryTriggered = false
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Error(ConnectorError.NetworkError(), canRetry = true),
items = emptyList(),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = { retryTriggered = true },
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_retry_button").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_retry_button").performClick()
assertTrue(retryTriggered)
}
@Test
fun testEmptyState_displaysNoIntegrationsMessage() {
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
items = emptyList(),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_empty").assertIsDisplayed()
}
@Test
fun testNoSessionState_displaysNoActiveSession() {
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Initial,
items = emptyList(),
),
sessionId = null,
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_no_session_state").assertIsDisplayed()
}
@Test
fun testNoStorageButRuntimeSession_usesAuthoritativeRuntimeSessionId() {
val item =
ConnectorUiItem(
slug = "linear",
name = "Linear",
isConnected = false,
isEnabled = true,
status = ConnectorStatus.DISCONNECTED,
connectionStatus = "disconnected",
)
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
sessionId = "sess-runtime-999",
items = listOf(item),
),
sessionId = null,
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_item_linear").assertIsDisplayed()
}
@Test
fun testUnsupportedBackend_displaysErrorMessage() {
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.UnsupportedBackend("Older server does not support connectors"),
items = emptyList(),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
}
@Test
fun testUnsupportedRuntime_displaysErrorMessage() {
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.UnsupportedRuntime("Runtime incompatible"),
items = emptyList(),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
}
@Test
fun testNotOwner_displaysErrorMessage() {
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.NotOwner("Session owned by another user"),
items = emptyList(),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
}
@Test
fun testExpiredAndRevokedItems_displayStatusAndAction() {
val expiredItem =
ConnectorUiItem(
slug = "github",
name = "GitHub",
isConnected = false,
isEnabled = true,
status = ConnectorStatus.EXPIRED,
connectionStatus = "expired",
)
val revokedItem =
ConnectorUiItem(
slug = "jira",
name = "Jira",
isConnected = false,
isEnabled = true,
status = ConnectorStatus.REVOKED,
connectionStatus = "revoked",
)
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
items = listOf(expiredItem, revokedItem),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_item_github").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_action_github").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_item_jira").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_action_jira").assertIsDisplayed()
}
}
@@ -0,0 +1,121 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.ui.chat.SubagentIndicator
import com.m57.hermescontrol.ui.chat.SubagentTranscriptUiState
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class SubagentInspectionSheetTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testSubagentInspectionSheet_displaysSubagentAndTogglesTranscript() {
var toggledSubagentId = ""
val indicator =
SubagentIndicator(
type = "subagent.progress",
subagentId = "sub-1089",
goal = "Analyze server logs",
status = "running",
model = "claude-sonnet",
durationSeconds = 12.4,
)
composeTestRule.setContent {
SubagentInspectionSheet(
indicators = listOf(indicator),
inspectingSubagentId = null,
subagentTranscript = null,
onToggleTranscript = { toggledSubagentId = it },
onDismiss = {},
)
}
composeTestRule.onNodeWithTag("subagent_inspection_sheet").assertIsDisplayed()
composeTestRule.onNodeWithText("Analyze server logs").assertIsDisplayed()
composeTestRule.onNodeWithTag("subagent_transcript_toggle_sub-1089").assertIsDisplayed()
composeTestRule.onNodeWithTag("subagent_transcript_toggle_sub-1089").performClick()
assertEquals("sub-1089", toggledSubagentId)
}
@Test
fun testSubagentInspectionSheet_displaysTranscriptContentAndTruncation() {
val indicator =
SubagentIndicator(
type = "subagent.progress",
subagentId = "sub-1089",
goal = "Analyze server logs",
status = "running",
)
val transcript =
SubagentTranscriptUiState(
subagentId = "sub-1089",
text = "Fetched 100 log lines successfully.",
isTruncated = true,
isLoading = false,
)
composeTestRule.setContent {
SubagentInspectionSheet(
indicators = listOf(indicator),
inspectingSubagentId = "sub-1089",
subagentTranscript = transcript,
onToggleTranscript = {},
onDismiss = {},
)
}
composeTestRule.onNodeWithTag("subagent_transcript_container").assertIsDisplayed()
composeTestRule.onNodeWithTag("subagent_transcript_text").assertIsDisplayed()
composeTestRule.onNodeWithText("Fetched 100 log lines successfully.").assertIsDisplayed()
composeTestRule.onNodeWithText("Rolling 16KB tail (older output truncated)").assertIsDisplayed()
}
@Test
fun testSubagentInspectionSheet_displaysErrorAndTriggersRetry() {
var retried = false
val indicator =
SubagentIndicator(
type = "subagent.progress",
subagentId = "sub-1089",
goal = "Analyze server logs",
status = "running",
)
val transcript =
SubagentTranscriptUiState(
subagentId = "sub-1089",
text = "",
error = "Connection timeout",
isLoading = false,
)
composeTestRule.setContent {
SubagentInspectionSheet(
indicators = listOf(indicator),
inspectingSubagentId = "sub-1089",
subagentTranscript = transcript,
onToggleTranscript = {},
onRetryTranscript = { retried = true },
onDismiss = {},
)
}
composeTestRule.onNodeWithText("Connection timeout").assertIsDisplayed()
composeTestRule.onNodeWithTag("subagent_transcript_retry").assertIsDisplayed()
composeTestRule.onNodeWithTag("subagent_transcript_retry").performClick()
assertEquals(true, retried)
}
}
@@ -0,0 +1,120 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class VaultPromptCardsTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testVaultUnlockCard_interaction() {
var confirmedPassword = ""
var dismissed = false
composeTestRule.setContent {
VaultUnlockCard(
prompt =
VaultUnlockPromptUi(
requestId = "req-1",
sessionId = "sess-1",
backend = "onepassword",
displayName = "1Password",
),
onConfirm = { confirmedPassword = it },
onDismiss = { dismissed = true },
)
}
composeTestRule.onNodeWithTag("vault_unlock_card").assertIsDisplayed()
composeTestRule.onNodeWithText("Unlock 1Password").assertIsDisplayed()
composeTestRule.onNodeWithTag("vault_unlock_password_input").performTextInput("mypass123")
composeTestRule.onNodeWithTag("vault_unlock_confirm").performClick()
assertEquals("mypass123", confirmedPassword)
composeTestRule.onNodeWithTag("vault_unlock_dismiss").performClick()
assertEquals(true, dismissed)
}
@Test
fun testVaultSaveLoginCard_interaction() {
var savedIdentifier = ""
var savedPassword = ""
var dismissed = false
composeTestRule.setContent {
VaultSaveLoginCard(
prompt =
VaultSaveLoginPromptUi(
requestId = "req-save",
sessionId = "sess-1",
origin = "https://github.com/login",
site = "GitHub",
),
onConfirm = { id, pass ->
savedIdentifier = id
savedPassword = pass
},
onDismiss = { dismissed = true },
)
}
composeTestRule.onNodeWithTag("vault_save_login_card").assertIsDisplayed()
composeTestRule.onNodeWithTag("vault_save_login_identifier_input").performTextInput("testuser@example.com")
composeTestRule.onNodeWithTag("vault_save_login_password_input").performTextInput("supersecret")
composeTestRule.onNodeWithTag("vault_save_login_confirm").performClick()
assertEquals("testuser@example.com", savedIdentifier)
assertEquals("supersecret", savedPassword)
composeTestRule.onNodeWithTag("vault_save_login_dismiss").performClick()
assertEquals(true, dismissed)
}
@Test
fun testVaultCodeCard_interaction() {
var submittedCode = ""
var dismissed = false
composeTestRule.setContent {
VaultCodeCard(
prompt =
VaultCodePromptUi(
requestId = "req-code",
sessionId = "sess-1",
site = "GitHub",
hint = "SMS verification to ***1234",
),
onConfirm = { submittedCode = it },
onDismiss = { dismissed = true },
)
}
composeTestRule.onNodeWithTag("vault_code_card").assertIsDisplayed()
composeTestRule.onNodeWithText("SMS verification to ***1234").assertIsDisplayed()
composeTestRule.onNodeWithTag("vault_code_input").performTextInput("654321")
composeTestRule.onNodeWithTag("vault_code_confirm").performClick()
assertEquals("654321", submittedCode)
composeTestRule.onNodeWithTag("vault_code_dismiss").performClick()
assertEquals(true, dismissed)
}
}
@@ -0,0 +1,180 @@
package com.m57.hermescontrol.ui.sessions
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionLiveStatus
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.ui.sessions.components.SearchResultCard
import com.m57.hermescontrol.ui.sessions.components.SessionCard
import com.m57.hermescontrol.ui.sessions.components.SessionLiveStatusIndicator
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class SessionLiveStatusIndicatorTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun workingStatus_displaysRunningIndicator() {
composeTestRule.setContent {
HermesControlTheme {
SessionLiveStatusIndicator(
liveStatus = SessionLiveStatus.WORKING,
sessionId = "sess-working",
)
}
}
composeTestRule.onNodeWithTag("session_live_status_sess-working").assertIsDisplayed()
composeTestRule.onNodeWithText("Running").assertIsDisplayed()
}
@Test
fun waitingStatus_displaysNeedsInputIndicator() {
composeTestRule.setContent {
HermesControlTheme {
SessionLiveStatusIndicator(
liveStatus = SessionLiveStatus.WAITING,
sessionId = "sess-waiting",
)
}
}
composeTestRule.onNodeWithTag("session_live_status_sess-waiting").assertIsDisplayed()
composeTestRule.onNodeWithText("Needs input").assertIsDisplayed()
}
@Test
fun sessionCard_withLiveStatus_displaysIndicator() {
val session =
SessionInfo(
id = "sess-card-working",
title = "Working session",
message_count = 5,
)
composeTestRule.setContent {
HermesControlTheme {
SessionCard(
session = session,
displayTitle = "Working session",
branchStem = null,
isFork = false,
forkDepth = 0,
query = "",
isSelecting = false,
isSelected = false,
isDeleting = false,
isPinned = false,
isHidden = false,
liveStatus = SessionLiveStatus.WORKING,
highlightBackground = Color.Transparent,
highlightForeground = Color.Transparent,
onCardClick = {},
onToggleSelection = {},
onSelect = {},
onRename = {},
onTogglePin = {},
onToggleHide = {},
onDelete = {},
)
}
}
composeTestRule.onNodeWithTag("session_card_sess-card-working").assertIsDisplayed()
composeTestRule
.onNodeWithTag(
"session_live_status_sess-card-working",
useUnmergedTree = true,
).assertIsDisplayed()
composeTestRule.onNodeWithText("Running", useUnmergedTree = true).assertIsDisplayed()
}
@Test
fun sessionCard_withoutLiveStatus_hidesIndicator() {
val session =
SessionInfo(
id = "sess-card-idle",
title = "Idle session",
message_count = 2,
)
composeTestRule.setContent {
HermesControlTheme {
SessionCard(
session = session,
displayTitle = "Idle session",
branchStem = null,
isFork = false,
forkDepth = 0,
query = "",
isSelecting = false,
isSelected = false,
isDeleting = false,
isPinned = false,
isHidden = false,
liveStatus = null,
highlightBackground = Color.Transparent,
highlightForeground = Color.Transparent,
onCardClick = {},
onToggleSelection = {},
onSelect = {},
onRename = {},
onTogglePin = {},
onToggleHide = {},
onDelete = {},
)
}
}
composeTestRule.onNodeWithTag("session_card_sess-card-idle").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_live_status_sess-card-idle").assertDoesNotExist()
}
@Test
fun searchResultCard_withLiveStatus_displaysIndicator() {
val session =
SessionInfo(
id = "sess-search-waiting",
preview = "Searching something",
source = "web",
)
composeTestRule.setContent {
HermesControlTheme {
SearchResultCard(
session = session,
query = "Searching",
isSelecting = false,
isSelected = false,
isDeleting = false,
liveStatus = SessionLiveStatus.WAITING,
highlightBackground = Color.Transparent,
highlightForeground = Color.Transparent,
onCardClick = {},
onToggleSelection = {},
onSelect = {},
onRename = {},
onDelete = {},
)
}
}
composeTestRule.onNodeWithTag("session_card_sess-search-waiting").assertIsDisplayed()
composeTestRule
.onNodeWithTag(
"session_live_status_sess-search-waiting",
useUnmergedTree = true,
).assertIsDisplayed()
composeTestRule.onNodeWithText("Needs input", useUnmergedTree = true).assertIsDisplayed()
}
}
@@ -62,6 +62,7 @@ import com.m57.hermescontrol.ui.settings.SettingsBehaviorPage
import com.m57.hermescontrol.ui.settings.SettingsChatPage
import com.m57.hermescontrol.ui.settings.SettingsConnectionPage
import com.m57.hermescontrol.ui.settings.SettingsLanguagePage
import com.m57.hermescontrol.ui.settings.SettingsVaultPage
import com.m57.hermescontrol.ui.settings.SettingsViewModel
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
import kotlinx.coroutines.launch
@@ -137,6 +138,11 @@ private fun appEntryProvider(
viewModel = viewModel { SettingsViewModel() },
)
}
entry<SettingsVault> {
SettingsVaultPage(
onBack = { NavigationController.goBack() },
)
}
entry<SettingsAbout> {
SettingsAboutPage(
onBack = { NavigationController.goBack() },
@@ -87,4 +87,6 @@ import kotlinx.serialization.Serializable
@Serializable data object SettingsBehavior : NavKey
@Serializable data object SettingsVault : NavKey
@Serializable data object SettingsAbout : NavKey
@@ -12,6 +12,8 @@ data class ServerStoreState(
val port: Int = 9119,
val baseUrl: String? = null,
val autoReconnect: Boolean = true,
val restoreLastSession: Boolean = false,
val lastOpenedSessionIds: Map<String, String> = emptyMap(),
val themePreference: ThemePreference = ThemePreference.SYSTEM,
val useDynamicColors: Boolean = false,
val themePreset: ThemePreset = ThemePreset.DEFAULT,
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.local
import android.content.Context
import android.content.SharedPreferences
import androidx.annotation.VisibleForTesting
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import com.m57.hermescontrol.data.config.ConnectionProfile
@@ -500,6 +501,11 @@ object AuthManager {
_baseUrlFlow.value = ""
}
@VisibleForTesting
fun setBaseUrlForTest(baseUrl: String) {
_baseUrlFlow.value = baseUrl
}
fun getToken(): String? {
if (tokenInitialized) return cachedToken
synchronized(this) {
@@ -610,6 +616,47 @@ object AuthManager {
serverStore.update { it.copy(autoReconnect = enabled) }
}
// ── Restore last session on startup (issue #1102) ─────────────────────
private fun currentSessionScopeKey(): String {
val connId = getSelectedProfileId()?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
val serverProfile = activeProfileId.value?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
return "$connId:$serverProfile"
}
fun isRestoreLastSession(): Boolean = serverStore.getLatestState().restoreLastSession
fun setRestoreLastSession(enabled: Boolean) {
serverStore.update { it.copy(restoreLastSession = enabled) }
}
fun getLastOpenedSessionId(): String? = serverStore.getLatestState().lastOpenedSessionIds[currentSessionScopeKey()]
fun setLastOpenedSessionId(sessionId: String?) {
val key = currentSessionScopeKey()
serverStore.update { state ->
val updated =
if (sessionId != null) {
state.lastOpenedSessionIds + (key to sessionId)
} else {
state.lastOpenedSessionIds - key
}
state.copy(lastOpenedSessionIds = updated)
}
}
fun clearLastOpenedSessionId() {
setLastOpenedSessionId(null)
}
fun clearLastOpenedSessionIdsForConnection(connectionProfileId: String) {
serverStore.update { state ->
val prefix = "$connectionProfileId:"
val updated = state.lastOpenedSessionIds.filterKeys { !it.startsWith(prefix) }
state.copy(lastOpenedSessionIds = updated)
}
}
// ── Theme preference ──────────────────────────────────────────────────
fun getThemePreference(): ThemePreference = serverStore.getLatestState().themePreference
@@ -42,4 +42,8 @@ data class ChatMessageEntity(
val isStreaming: Boolean = false,
@ColumnInfo(name = "display_kind")
val displayKind: String? = null,
@ColumnInfo(name = "token_count")
val tokenCount: Int? = null,
@ColumnInfo(name = "tps")
val tps: Double? = null,
)
@@ -35,6 +35,8 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
else -> null
},
displayKind = displayKind,
tokenCount = tokenCount,
tps = tps,
)
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
@@ -50,4 +52,6 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
toolStatus = toolStatus?.name,
isStreaming = isStreaming,
displayKind = displayKind,
tokenCount = tokenCount,
tps = tps,
)
@@ -12,7 +12,7 @@ import java.io.File
@Database(
entities = [ChatMessageEntity::class],
version = 6,
version = 7,
exportSchema = true,
)
abstract class HermesDatabase : RoomDatabase() {
@@ -67,6 +67,18 @@ abstract class HermesDatabase : RoomDatabase() {
}
}
val MIGRATION_6_7: Migration =
object : Migration(6, 7) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"ALTER TABLE `chat_messages` ADD COLUMN `token_count` INTEGER",
)
connection.execSQL(
"ALTER TABLE `chat_messages` ADD COLUMN `tps` REAL",
)
}
}
fun get(context: Context): HermesDatabase =
instance ?: synchronized(this) {
// SQLCipher can't open plaintext SQLite databases — if an old
@@ -97,6 +109,7 @@ abstract class HermesDatabase : RoomDatabase() {
MIGRATION_3_4,
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
).fallbackToDestructiveMigration(false)
.build()
.also { instance = it }
@@ -0,0 +1,233 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.util.ConnectorUrlValidator
/**
* High-level connection status for a connector item.
* Deliberate precedence: if [connected] is true, status resolves to [CONNECTED]
* regardless of expired/revoked/stale statuses reported by backend.
*/
enum class ConnectorStatus {
CONNECTED,
INITIATED,
EXPIRED,
REVOKED,
FAILED,
DISCONNECTED,
UNKNOWN,
;
val isConnected: Boolean get() = this == CONNECTED
companion object {
fun from(
connected: Boolean,
rawStatus: String?,
): ConnectorStatus = from(connected = connected as Boolean?, rawStatus = rawStatus)
fun from(
connected: Boolean?,
rawStatus: String?,
): ConnectorStatus {
if (connected == true) return CONNECTED
if (connected == false) {
return when (rawStatus?.lowercase()?.trim()) {
"expired" -> EXPIRED
"revoked" -> REVOKED
"initiated" -> INITIATED
"failed", "error" -> FAILED
"disconnected" -> DISCONNECTED
null, "", "active", "connected", "authorized" -> DISCONNECTED
else -> UNKNOWN
}
}
// Missing boolean: "authorized", "active", or "connected" yields effective CONNECTED
return when (rawStatus?.lowercase()?.trim()) {
"authorized", "active", "connected" -> CONNECTED
"expired" -> EXPIRED
"revoked" -> REVOKED
"initiated" -> INITIATED
"failed", "error" -> FAILED
"disconnected" -> DISCONNECTED
null, "" -> DISCONNECTED
else -> UNKNOWN
}
}
}
}
/**
* Status resulting from a connect/reconnect initiation.
*/
enum class ConnectorConnectStatus {
ACTIVE,
INITIATED,
FAILED,
UNKNOWN,
;
val isActive: Boolean get() = this == ACTIVE
companion object {
fun from(rawStatus: String?): ConnectorConnectStatus =
when (rawStatus?.lowercase()?.trim()) {
"active", "connected" -> ACTIVE
"initiated" -> INITIATED
"failed", "error" -> FAILED
null, "" -> UNKNOWN
else -> UNKNOWN
}
}
}
/**
* Individual connector item returned by `connectors.list`.
*/
data class ConnectorItem(
val connector: String,
val name: String = connector,
val connected: Boolean = false,
val enabled: Boolean = true,
val connectionStatus: String? = null,
val status: ConnectorStatus = ConnectorStatus.from(connected, connectionStatus),
val connectUrl: String? = null,
) {
val isConnected: Boolean get() = connected || status.isConnected
/** Safe validated HTTPS URL, or null if invalid or absent. */
val safeConnectUrl: String?
get() = if (ConnectorUrlValidator.isValidHttpsUrl(connectUrl)) connectUrl else null
override fun toString(): String {
val redactedUrl = if (connectUrl != null) "[REDACTED]" else "null"
return "ConnectorItem(" +
"connector=$connector, " +
"name=$name, " +
"connected=$connected, " +
"enabled=$enabled, " +
"status=$status, " +
"connectUrl=$redactedUrl" +
")"
}
}
/**
* Result of a connect/reconnect action for an individual connector.
* Instructions from arbitrary backends are intentionally not exposed for UI display.
*/
data class ConnectorConnectItem(
val connector: String,
val status: ConnectorConnectStatus,
val connectUrl: String? = null,
) {
val isConnectUrlValid: Boolean
get() = ConnectorUrlValidator.isValidHttpsUrl(connectUrl)
val safeConnectUrl: String?
get() = if (isConnectUrlValid) connectUrl else null
override fun toString(): String {
val redactedUrl = if (connectUrl != null) "[REDACTED]" else "null"
return "ConnectorConnectItem(" +
"connector=$connector, " +
"status=$status, " +
"connectUrl=$redactedUrl, " +
"isConnectUrlValid=$isConnectUrlValid" +
")"
}
}
/**
* Summary counts returned by `connectors.connect`.
*/
data class ConnectorConnectSummary(
val total: Int = 0,
val active: Int = 0,
val initiated: Int = 0,
val failed: Int = 0,
)
/**
* Typed safe connector errors mapped from RPC codes and reasons.
* Raw exceptions or auth tokens are never exposed.
*/
sealed interface ConnectorError {
val message: String
data class InvalidParams(
override val message: String = "Invalid connector parameters.",
) : ConnectorError
data class NotOwner(
override val message: String = "Session is not owned by this transport.",
) : ConnectorError
data class Unavailable(
override val message: String = "Connectors are not available in this session.",
) : ConnectorError
data class UnsupportedRuntime(
override val message: String = "Connectors must be managed on the session's compute host.",
) : ConnectorError
data class RequestFailed(
override val message: String = "Connector request failed. Please try again.",
) : ConnectorError
data class InvalidResponse(
override val message: String = "Connector service returned an invalid response.",
) : ConnectorError
data class UnsupportedBackend(
override val message: String = "Connector service is not supported by this backend.",
) : ConnectorError
data class MalformedEnvelope(
override val message: String = "Malformed response envelope received from gateway.",
) : ConnectorError
data class NetworkError(
override val message: String = "Failed to communicate with gateway.",
) : ConnectorError
data class Other(
val code: Int = 0,
override val message: String = "Connector operation failed.",
) : ConnectorError
}
/**
* Result envelope for `connectors.list`.
*/
sealed interface ConnectorListResult {
data class Success(
val available: Boolean,
val connectors: List<ConnectorItem>,
) : ConnectorListResult
data class Error(
val error: ConnectorError,
) : ConnectorListResult
fun getOrNull(): List<ConnectorItem>? = (this as? Success)?.connectors
fun errorOrNull(): ConnectorError? = (this as? Error)?.error
}
/**
* Result envelope for `connectors.connect`.
*/
sealed interface ConnectorConnectResult {
data class Success(
val results: List<ConnectorConnectItem>,
val summary: ConnectorConnectSummary,
) : ConnectorConnectResult
data class Error(
val error: ConnectorError,
) : ConnectorConnectResult
fun getOrNull(): List<ConnectorConnectItem>? = (this as? Success)?.results
fun errorOrNull(): ConnectorError? = (this as? Error)?.error
}
@@ -1,5 +1,11 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.data.ws.toAny
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
/**
* Live context-window occupancy from the `session.context_breakdown` WS RPC —
* the same RPC the Hermes desktop app's status-bar meter uses.
@@ -30,20 +36,47 @@ data class ContextBreakdownResponse(
* Parse the decoded JSON-RPC result of `session.context_breakdown`.
*
* The WS event parser decodes JSON numbers as [Double] (`JsonRpcModels.toAny`),
* so every count is read through [Number]. Returns null when the payload is
* so every count is read through [Number]. Also supports raw [JsonObject] from
* [HermesWsClient.request]. Returns null when the payload is
* not a map (error/malformed response) — callers treat that as unknown and
* keep the last known meter values.
*/
fun parseContextBreakdown(result: Any?): ContextBreakdownResponse? {
val map = result as? Map<*, *> ?: return null
val rawMap =
when (result) {
is JsonElement -> result.toAny() as? Map<*, *>
is Map<*, *> -> result
else -> null
} ?: return null
fun num(key: String): Long? = (map[key] as? Number)?.toLong()
fun num(key: String): Long? =
when (val value = rawMap[key]) {
is Number -> value.toLong()
is JsonPrimitive -> value.longOrNull ?: value.intOrNull?.toLong()
is String -> value.toLongOrNull()
else -> null
}
fun intNum(key: String): Int? =
when (val value = rawMap[key]) {
is Number -> value.toInt()
is JsonPrimitive -> value.intOrNull
is String -> value.toIntOrNull()
else -> null
}
fun str(key: String): String? =
when (val value = rawMap[key]) {
is String -> value
is JsonPrimitive -> value.content
else -> value?.toString()
}
return ContextBreakdownResponse(
contextUsed = num("context_used"),
contextMax = num("context_max"),
contextPercent = (map["context_percent"] as? Number)?.toInt(),
contextPercent = intNum("context_percent"),
estimatedTotal = num("estimated_total"),
model = map["model"] as? String,
model = str("model"),
)
}
@@ -80,9 +80,63 @@ data class PluginsHubResponse(
@Serializable
data class AgentPluginInstallBody(
val identifier: String,
val identifier: String = "",
val force: Boolean = false,
val enable: Boolean = true,
@SerialName("catalog_name") val catalogName: String? = null,
val ref: String? = null,
)
@Serializable
data class PluginCatalogCapabilities(
@SerialName("provides_tools") val providesTools: List<String> = emptyList(),
@SerialName("provides_hooks") val providesHooks: List<String> = emptyList(),
@SerialName("provides_middleware") val providesMiddleware: List<String> = emptyList(),
@SerialName("requires_env") val requiresEnv: List<String> = emptyList(),
)
@Serializable
data class PluginCatalogRemovedEntry(
val name: String? = null,
val repo: String? = null,
val reason: String? = null,
val date: String? = null,
)
@Serializable
data class PluginCatalogEntry(
val name: String,
@SerialName("catalog_name") val catalogName: String? = null,
val repo: String? = null,
val sha: String? = null,
@SerialName("sha_short") val shaShort: String? = null,
val description: String? = null,
val maintainer: String? = null,
val tier: String? = null,
val category: String? = null,
@SerialName("requires_hermes") val requiresHermes: String? = null,
val subdir: String? = null,
@SerialName("docs_url") val docsUrl: String? = null,
val platforms: List<String> = emptyList(),
val capabilities: PluginCatalogCapabilities? = null,
@SerialName("capability_summary") val capabilitySummary: String? = null,
val installed: Boolean = false,
@SerialName("installed_sha") val installedSha: String? = null,
@SerialName("update_available") val updateAvailable: Boolean = false,
@SerialName("runtime_status") val runtimeStatus: String? = null,
) {
val displayName: String
get() = catalogName?.takeIf { it.isNotBlank() } ?: name
val isOfficial: Boolean
get() = tier?.equals("official", ignoreCase = true) == true
}
@Serializable
data class PluginCatalogResponse(
val entries: List<PluginCatalogEntry> = emptyList(),
val removed: List<PluginCatalogRemovedEntry> = emptyList(),
@SerialName("generated_at") val generatedAt: String? = null,
)
@Serializable
@@ -0,0 +1,116 @@
package com.m57.hermescontrol.data.model
private fun recency(s: SessionInfo): Double = s.last_active ?: s.started_at ?: 0.0
/**
* Port of desktop session-branch-tree.ts#flattenSessionsWithBranches.
*
* - Aliases _lineage_root_id so forks find compression-projected parents.
* - Sorts sibling branches by own recency; sorts roots by groupRecency
* (max over subtree) so activity on any branch lifts the whole family.
* - preserveOrder keeps caller-chosen root order (pinned section).
* - Cycle-guarded; trailing sweep never drops an input row.
*/
fun flattenSessionsWithBranches(
sessions: List<SessionInfo>,
preserveOrder: Boolean = false,
): List<SessionTreeItem> {
if (sessions.size < 2) {
return sessions.map { it.toTreeItem(depth = 0, siblingIndex = 0, siblingCount = 1) }
}
val byVisibleId = HashMap<String, SessionInfo>(sessions.size * 2)
for (s in sessions) {
byVisibleId[s.id] = s
s.lineageRootId
?.trim()
?.takeIf(String::isNotEmpty)
?.let { byVisibleId[it] = s }
}
val childrenByParent = HashMap<String, MutableList<SessionInfo>>()
val nestedIds = HashSet<String>()
for (s in sessions) {
val parentId = s.parent_session_id?.trim()?.takeIf(String::isNotEmpty) ?: continue
val parent = byVisibleId[parentId] ?: continue
if (parent.id == s.id) continue
nestedIds += s.id
childrenByParent.getOrPut(parent.id) { mutableListOf() } += s
}
childrenByParent.values.forEach { it.sortByDescending(::recency) }
// Group sorts by its freshest member; memo pre-seed doubles as cycle guard.
val groupRecencyMemo = HashMap<String, Double>()
fun groupRecency(s: SessionInfo): Double {
groupRecencyMemo[s.id]?.let { return it }
groupRecencyMemo[s.id] = recency(s)
val max =
childrenByParent[s.id]
.orEmpty()
.fold(recency(s)) { acc, child -> maxOf(acc, groupRecency(child)) }
groupRecencyMemo[s.id] = max
return max
}
val out = mutableListOf<SessionTreeItem>()
val seen = HashSet<String>()
fun emit(
s: SessionInfo,
depth: Int,
siblingIndex: Int,
siblingCount: Int,
) {
if (!seen.add(s.id)) return
out += s.toTreeItem(depth, siblingIndex, siblingCount)
val kids = childrenByParent[s.id].orEmpty()
kids.forEachIndexed { i, child -> emit(child, depth + 1, i, kids.size) }
}
val roots = sessions.withIndex().filter { it.value.id !in nestedIds }
val ordered =
if (preserveOrder) {
roots
} else {
roots.sortedWith(
compareByDescending<IndexedValue<SessionInfo>> { groupRecency(it.value) }
.thenBy { it.index },
)
}
ordered.forEachIndexed { i, r -> emit(r.value, 0, i, ordered.size) }
sessions.filter { it.id !in seen }.forEach { emit(it, 0, 0, 1) } // trailing sweep
return out
}
private fun SessionInfo.toTreeItem(
depth: Int,
siblingIndex: Int,
siblingCount: Int,
): SessionTreeItem {
// G4 fix: fork identity from the field itself, never from load-set membership.
val isFork = !parent_session_id.isNullOrBlank()
val title =
title?.takeIf(String::isNotBlank)
?: display_name?.takeIf(String::isNotBlank)
?: if (isFork && depth > 0) {
"branch ${siblingIndex + 1}"
} else {
preview?.takeIf(String::isNotBlank)?.take(80) ?: "Untitled"
}
return SessionTreeItem(
session = this,
depth = depth,
branchStem =
if (depth == 0) {
null
} else if (siblingIndex == siblingCount - 1) {
"└─"
} else {
"├─"
},
isFork = isFork,
forkDepth = if (depth > 0) depth else 0,
displayTitle = title,
)
}
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.model
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
@@ -78,6 +79,14 @@ data class SessionInfo(
val display_name: String? = null,
val model: String? = null,
val terminal_backend: String? = null,
// Epoch seconds of the latest message (backend-computed, present on all
// list rows). Recency source of truth; started_at is the fallback.
val last_active: Double? = null,
// Original root id when this row is a compression-projected tip. Branch
// children reference the ROOT id in parent_session_id, so tree building
// must alias this id to this row (desktop byVisibleId parity).
@SerialName("_lineage_root_id")
val lineageRootId: String? = null,
// Durable "keep" flag: pinned sessions are exempt from the auto-archive
// sweep and are surfaced first in the history list (sorted client-side).
// Uses LenientNullableBooleanSerializer to tolerate both JSON booleans and raw SQLite ints (issue #966).
@@ -0,0 +1,41 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* High-level live status for a session in History.
*/
enum class SessionLiveStatus {
WORKING,
WAITING,
}
/**
* RPC response from `session.active_list`.
*/
@Serializable
data class ActiveSessionsResponse(
val sessions: List<ActiveSessionItem> = emptyList(),
)
/**
* Individual item in `session.active_list`.
*/
@Serializable
data class ActiveSessionItem(
val id: String? = null,
@SerialName("session_key")
val sessionKey: String? = null,
val status: String? = null,
@SerialName("last_active")
val lastActive: Double? = null,
)
/**
* Parsed authoritative snapshot of live sessions.
*/
data class LiveSessionSnapshot(
val statusByStoredId: Map<String, SessionLiveStatus> = emptyMap(),
val storedIdByRuntimeId: Map<String, String> = emptyMap(),
)
@@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
@Serializable
@@ -50,10 +51,18 @@ data class SessionMessage(
val display_kind: String? = null,
/** Display-only metadata for the marker (e.g. delegation result counts). */
val display_metadata: JsonElement? = null,
/** Token count recorded by the backend. */
val token_count: JsonElement? = null,
) {
val timestampText: String?
get() = (timestamp as? JsonPrimitive)?.content
val tokenCount: Int?
get() {
val prim = token_count as? JsonPrimitive ?: return null
return prim.intOrNull ?: prim.content.toIntOrNull()
}
val timestampEpochMs: Long?
get() {
val prim = timestamp as? JsonPrimitive ?: return null
@@ -0,0 +1,52 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Item returned in the [SubagentListResponse] from `subagent.list` (issue #1089).
*/
@Serializable
data class SubagentListItem(
@SerialName("subagent_id") val subagentId: String = "",
val goal: String? = null,
val status: String? = null,
val model: String? = null,
@SerialName("elapsed_seconds") val elapsedSeconds: Double? = null,
@SerialName("started_at") val startedAt: Double? = null,
@SerialName("parent_id") val parentId: String? = null,
val depth: Int? = null,
@SerialName("delegation_id") val delegationId: String? = null,
@SerialName("tool_count") val toolCount: Int? = null,
@SerialName("last_tool") val lastTool: String? = null,
@SerialName("accepting_steer") val acceptingSteer: Boolean? = null,
)
/**
* Response returned from `subagent.list` (issue #1089).
*/
@Serializable
data class SubagentListResponse(
val subagents: List<SubagentListItem> = emptyList(),
val delegations: List<JsonElement> = emptyList(),
)
/**
* Response returned from `subagent.tail` (issue #1089).
*
* Backend returns either `text` (desktop/TUI gateway) or `tail` (contract).
* [content] returns the authoritative transcript text from whichever field is present.
*/
@Serializable
data class SubagentTailResponse(
@SerialName("subagent_id") val subagentId: String = "",
val tail: String? = null,
val text: String? = null,
val available: Boolean? = null,
val truncated: Boolean? = null,
@SerialName("bytes_read") val bytesRead: Long? = null,
) {
/** Returns authoritative transcript text across backend schema variants. */
fun content(): String = text ?: tail ?: ""
}
@@ -1,5 +1,12 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.data.ws.toAny
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
/**
* Minimal view of the `session.usage` WS RPC result or `session.usage` push event —
* only the fields the context widget consumes today. The backend sends many more keys
@@ -20,6 +27,8 @@ data class UsageSnapshotResponse(
val contextMax: Long? = null,
/** Cumulative session total tokens. */
val totalTokens: Long? = null,
/** Rolling tokens-per-second generation speed. */
val avgTps: Double? = null,
)
/**
@@ -27,16 +36,47 @@ data class UsageSnapshotResponse(
*
* Handles both direct usage dict (from RPC response) and nested `{ "usage": { ... } }`
* (from push event payload). The WS event parser decodes JSON numbers as [Double]
* (`JsonRpcModels.toAny`), so every count is read through [Number].
* (`JsonRpcModels.toAny`), so every count is read through [Number]. Also supports raw [JsonObject].
* Returns null when the payload is not a map (error/malformed response) — callers keep the last known value.
*/
fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? {
val map = result as? Map<*, *> ?: return null
val usageMap = (map["usage"] as? Map<*, *>) ?: map
val rawMap =
when (result) {
is JsonElement -> result.toAny() as? Map<*, *>
is Map<*, *> -> result
else -> null
} ?: return null
val usageMap = (rawMap["usage"] as? Map<*, *>) ?: rawMap
fun num(key: String): Long? =
when (val value = usageMap[key]) {
is Number -> value.toLong()
is JsonPrimitive -> value.longOrNull ?: value.intOrNull?.toLong()
is String -> value.toLongOrNull()
else -> null
}
fun intNum(key: String): Int? =
when (val value = usageMap[key]) {
is Number -> value.toInt()
is JsonPrimitive -> value.intOrNull
is String -> value.toIntOrNull()
else -> null
}
fun doubleNum(key: String): Double? =
when (val value = usageMap[key]) {
is Number -> value.toDouble()
is JsonPrimitive -> value.doubleOrNull ?: value.longOrNull?.toDouble()
is String -> value.toDoubleOrNull()
else -> null
}
return UsageSnapshotResponse(
compressions = (usageMap["compressions"] as? Number)?.toInt(),
contextUsed = (usageMap["context_used"] as? Number)?.toLong(),
contextMax = (usageMap["context_max"] as? Number)?.toLong(),
totalTokens = (usageMap["total"] as? Number)?.toLong(),
compressions = intNum("compressions"),
contextUsed = num("context_used"),
contextMax = num("context_max"),
totalTokens = num("total"),
avgTps = doubleNum("avg_tps"),
)
}
@@ -0,0 +1,61 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models for the Credential Vault RPC surface (issue #1090).
*
* Backend: `tui_gateway/methods_vault.py`.
* Sources represent local encrypted vault or external managers (1Password, Bitwarden).
*/
@Serializable
data class VaultSource(
val name: String,
@SerialName("display_name") val displayName: String = name,
val enabled: Boolean = false,
@SerialName("needs_unlock") val needsUnlock: Boolean = false,
val unlocked: Boolean = false,
val installed: Boolean = false,
)
@Serializable
data class VaultSourcesResponse(
val sources: List<VaultSource> = emptyList(),
)
@Serializable
data class VaultItem(
val id: String,
val backend: String = "local",
val kind: String = "login",
val label: String = "",
val origin: String? = null,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("identifier_type") val identifierType: String? = null,
val identifier: String? = null,
@SerialName("has_otp") val hasOtp: Boolean = false,
)
@Serializable
data class VaultListResponse(
val items: List<VaultItem> = emptyList(),
)
@Serializable
data class VaultSourceSetResponse(
val name: String? = null,
val enabled: Boolean? = null,
)
@Serializable
data class VaultUnlockResponse(
val name: String? = null,
val unlocked: Boolean? = null,
)
@Serializable
data class VaultLockResponse(
val locked: Boolean? = null,
)
@@ -0,0 +1,122 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Handles basic credential login and WS ticket minting for in-chat reconnect/re-authentication.
*
* Extracted from [com.m57.hermescontrol.ui.chat.ChatViewModel] to isolate raw OkHttp network calls.
*/
class ChatReloginAuthenticator(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
) {
suspend fun relogin(
username: String,
password: String,
onSuccess: () -> Unit,
onFailure: (String) -> Unit,
) {
withContext(ioDispatcher) {
val endpoint = AuthManager.endpointForBuild()
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
val jsonBody =
JSONObject()
.put("provider", "basic")
.put("username", username)
.put("password", password)
.put("next", "")
.toString()
try {
val loginClient =
OkHttpProvider.probe
.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
val loginReq =
Request
.Builder()
.url(endpoint.resolve("auth/password-login").toString())
.header("Content-Type", "application/json")
.post(jsonBody.toRequestBody(jsonMediaType))
.build()
loginClient.newCall(loginReq).execute().use { loginResp ->
if (!loginResp.isSuccessful) {
val msg =
when (loginResp.code) {
401 -> "Invalid username or password (401)"
403 -> "Forbidden (403)"
else -> "HTTP error code: ${loginResp.code}"
}
withContext(mainDispatcher) {
onFailure(msg)
}
return@withContext
}
}
val ticketClient =
OkHttpProvider.base
.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
val ticketReq =
Request
.Builder()
.url(endpoint.resolve("api/auth/ws-ticket").toString())
.post("{}".toRequestBody(jsonMediaType))
.build()
ticketClient.newCall(ticketReq).execute().use { ticketResp ->
if (!ticketResp.isSuccessful) {
withContext(mainDispatcher) {
onFailure("Failed to mint WS ticket: HTTP ${ticketResp.code}")
}
return@withContext
}
val body = ticketResp.body.string()
val ticket = JSONObject(body).optString("ticket").takeIf { it.isNotBlank() }
if (ticket.isNullOrBlank()) {
withContext(mainDispatcher) {
onFailure("Invalid ticket returned from server")
}
return@withContext
}
AuthManager.setWsAuthParam("ticket")
AuthManager.setToken(ticket)
withContext(mainDispatcher) {
onSuccess()
}
}
} catch (e: IOException) {
withContext(mainDispatcher) {
onFailure("Connection failed: ${e.message}")
}
} catch (e: JSONException) {
withContext(mainDispatcher) {
onFailure("Connection failed: ${e.message}")
}
}
}
}
}
@@ -80,6 +80,7 @@ import com.m57.hermescontrol.data.model.OAuthSubmitResponse
import com.m57.hermescontrol.data.model.PairingApproveRequest
import com.m57.hermescontrol.data.model.PairingResponse
import com.m57.hermescontrol.data.model.PairingRevokeRequest
import com.m57.hermescontrol.data.model.PluginCatalogResponse
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
import com.m57.hermescontrol.data.model.PluginsHubResponse
import com.m57.hermescontrol.data.model.PortalResponse
@@ -95,6 +96,7 @@ import com.m57.hermescontrol.data.model.RenameProfileRequest
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
import com.m57.hermescontrol.data.model.ScanStatus
import com.m57.hermescontrol.data.model.SessionDetailResponse
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.model.SessionMessagesResponse
import com.m57.hermescontrol.data.model.SessionRenameRequest
@@ -267,6 +269,12 @@ interface HermesApiService {
@Query("profile") profile: String? = null,
): Response<SessionDetailResponse>
@GET("api/sessions/{id}")
suspend fun getSessionInfo(
@Path("id", encoded = true) sessionId: String,
@Query("profile") profile: String? = null,
): Response<SessionInfo>
@GET("api/model/info")
suspend fun getModelInfo(): Response<ModelInfoResponse>
@@ -618,6 +626,9 @@ interface HermesApiService {
@GET("api/dashboard/plugins/hub")
suspend fun getPlugins(): Response<PluginsHubResponse>
@GET("api/dashboard/plugins/catalog")
suspend fun getPluginCatalog(): Response<PluginCatalogResponse>
@POST("api/dashboard/agent-plugins/install")
suspend fun installPlugin(
@Body body: AgentPluginInstallBody,
@@ -0,0 +1,389 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ConnectorConnectItem
import com.m57.hermescontrol.data.model.ConnectorConnectResult
import com.m57.hermescontrol.data.model.ConnectorConnectStatus
import com.m57.hermescontrol.data.model.ConnectorConnectSummary
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorItem
import com.m57.hermescontrol.data.model.ConnectorListResult
import com.m57.hermescontrol.data.model.ConnectorStatus
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.intOrNull
/**
* Dedicated parser for connector payloads with strict envelope validation,
* resilient type coercion, legacy alias normalization, and safe error mapping.
*/
object ConnectorParser {
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
}
/**
* Parse raw result from `connectors.list` RPC.
* Fails with [ConnectorError.MalformedEnvelope] if the required envelope structure is missing.
*/
fun parseListResult(raw: Any?): ConnectorListResult {
val root =
toJsonElement(raw) as? JsonObject
?: return ConnectorListResult.Error(
ConnectorError.MalformedEnvelope("Response envelope must be a JSON object."),
)
// Required envelope check: "connectors" must exist and be an array.
val connectorsArray =
root["connectors"] as? JsonArray
?: return ConnectorListResult.Error(
ConnectorError.MalformedEnvelope("Envelope missing required 'connectors' array."),
)
// Optional 'available' flag (defaults to true if omitted, unless explicitly false)
val available = parseBoolean(root["available"], default = true)
val items = mutableListOf<ConnectorItem>()
val seenSlugs = mutableSetOf<String>()
for (element in connectorsArray) {
val itemObj = element as? JsonObject ?: continue
val item = parseConnectorItem(itemObj) ?: continue
if (seenSlugs.add(item.connector)) {
items.add(item)
}
}
// Entirely invalid nonempty connectors array must fail rather than looking silently valid empty.
if (connectorsArray.isNotEmpty() && items.isEmpty()) {
return ConnectorListResult.Error(
ConnectorError.MalformedEnvelope("Connectors list contains only invalid items."),
)
}
return ConnectorListResult.Success(
available = available,
connectors = items,
)
}
/**
* Parse raw result from `connectors.connect` RPC.
* Fails with [ConnectorError.MalformedEnvelope] or [ConnectorError.InvalidResponse] if invalid.
*/
fun parseConnectResult(raw: Any?): ConnectorConnectResult {
val rawElement = toJsonElement(raw)
// Tolerate purposeful legacy bare array form: [ { "connector": "linear", ... } ]
if (rawElement is JsonArray) {
val results = mutableListOf<ConnectorConnectItem>()
val seenSlugs = mutableSetOf<String>()
for (element in rawElement) {
val itemObj = element as? JsonObject ?: continue
val item = parseConnectItem(itemObj) ?: continue
if (seenSlugs.add(item.connector)) {
results.add(item)
}
}
if (results.isNotEmpty()) {
val summary =
ConnectorConnectSummary(
total = results.size,
active = results.count { it.status == ConnectorConnectStatus.ACTIVE },
initiated = results.count { it.status == ConnectorConnectStatus.INITIATED },
failed = results.count { it.status == ConnectorConnectStatus.FAILED },
)
return ConnectorConnectResult.Success(
results = results,
summary = summary,
)
}
return ConnectorConnectResult.Error(
ConnectorError.InvalidResponse("Connect response contains no valid results."),
)
}
val root =
rawElement as? JsonObject
?: return ConnectorConnectResult.Error(
ConnectorError.MalformedEnvelope("Connect response must be a JSON object."),
)
// Required envelope check: "results" must exist and be an array.
val resultsArray =
root["results"] as? JsonArray
?: return ConnectorConnectResult.Error(
ConnectorError.MalformedEnvelope("Connect envelope missing required 'results' array."),
)
val results = mutableListOf<ConnectorConnectItem>()
val seenSlugs = mutableSetOf<String>()
for (element in resultsArray) {
val itemObj = element as? JsonObject ?: continue
val item = parseConnectItem(itemObj) ?: continue
if (seenSlugs.add(item.connector)) {
results.add(item)
}
}
// Connect must return at least nonempty valid results
if (results.isEmpty()) {
return ConnectorConnectResult.Error(
ConnectorError.InvalidResponse("Connect response contains no valid results."),
)
}
// Current summary object is required in standard envelope
val summaryObj =
root["summary"] as? JsonObject
?: return ConnectorConnectResult.Error(
ConnectorError.MalformedEnvelope("Connect envelope missing required 'summary' object."),
)
val summary = parseConnectSummary(summaryObj)
return ConnectorConnectResult.Success(
results = results,
summary = summary,
)
}
private fun parseConnectorItem(obj: JsonObject): ConnectorItem? {
// Strict string identifier with backend slug validation; legacy aliases supported
val slug =
parseIdentifier(obj["connector"])
?: parseIdentifier(obj["slug"])
?: parseIdentifier(obj["id"])
?: return null // Skip items without valid connector identifier
// Display name: primary "name", legacy aliases "displayName", "title", fallback to slug
val name =
parseString(obj["name"])
?: parseString(obj["displayName"])
?: parseString(obj["title"])
?: slug
val rawConnected = parseBooleanOrNull(obj["connected"])
val enabled = parseBoolean(obj["enabled"], default = true)
// Connection status string: primary "connectionStatus", legacy aliases "status", "connection_status"
val rawStatus =
parseString(obj["connectionStatus"])
?: parseString(obj["status"])
?: parseString(obj["connection_status"])
// Compute typed status respecting connected precedence:
// When connected boolean is missing (legacy), status "authorized"/"connected"/"active" yields CONNECTED.
// Explicit connected=false is authoritative and not overridden by status active.
val status = ConnectorStatus.from(connected = rawConnected, rawStatus = rawStatus)
val effectiveConnected = rawConnected ?: status.isConnected
// Connect URL (transient): primary "connect_url", legacy aliases "connectUrl", "auth_url", "authUrl"
val connectUrl =
parseString(obj["connect_url"])
?: parseString(obj["connectUrl"])
?: parseString(obj["auth_url"])
?: parseString(obj["authUrl"])
return ConnectorItem(
connector = slug,
name = name,
connected = effectiveConnected,
enabled = enabled,
connectionStatus = rawStatus,
status = status,
connectUrl = connectUrl,
)
}
private fun parseConnectItem(obj: JsonObject): ConnectorConnectItem? {
val slug =
parseIdentifier(obj["connector"])
?: parseIdentifier(obj["slug"])
?: parseIdentifier(obj["id"])
?: return null
val rawStatus =
parseString(obj["status"])
?: parseString(obj["connectionStatus"])
?: parseString(obj["connection_status"])
val status = ConnectorConnectStatus.from(rawStatus)
val connectUrl =
parseString(obj["connect_url"])
?: parseString(obj["connectUrl"])
?: parseString(obj["auth_url"])
?: parseString(obj["authUrl"])
return ConnectorConnectItem(
connector = slug,
status = status,
connectUrl = connectUrl,
)
}
private fun parseConnectSummary(obj: JsonObject?): ConnectorConnectSummary {
if (obj == null) return ConnectorConnectSummary()
return ConnectorConnectSummary(
total = parseInt(obj["total"], default = 0),
active = parseInt(obj["active"], default = 0),
initiated = parseInt(obj["initiated"], default = 0),
failed = parseInt(obj["failed"], default = 0),
)
}
/**
* Map RPC error codes and reasons into typed [ConnectorError]s.
* Raw server strings and sensitive parameters are never forwarded or exposed in error messages.
*/
fun mapRpcError(
code: Int,
message: String? = null,
data: JsonElement? = null,
): ConnectorError {
val reason =
when (data) {
is JsonObject -> (data["reason"] as? JsonPrimitive)?.content
is JsonPrimitive -> data.content
else -> null
}?.uppercase()?.trim()
return when {
code == 4000 || reason == "INVALID_PARAMS" -> {
ConnectorError.InvalidParams()
}
code == 4001 || reason == "NOT_OWNER" -> {
ConnectorError.NotOwner()
}
code == 4031 || reason == "CONNECTORS_UNAVAILABLE" -> {
ConnectorError.Unavailable()
}
code == 5033 || reason == "UNSUPPORTED_RUNTIME" -> {
ConnectorError.UnsupportedRuntime()
}
code == 5034 && reason == "INVALID_CONNECTOR_RESPONSE" -> {
ConnectorError.InvalidResponse()
}
code == 5034 -> {
ConnectorError.RequestFailed()
}
code == -32601 -> {
ConnectorError.UnsupportedBackend()
}
else -> {
ConnectorError.Other(code = code)
}
}
}
internal fun parseStrictString(element: JsonElement?): String? {
if (element == null || element is JsonNull) return null
if (element is JsonPrimitive && element.isString) {
return element.content
}
return null
}
internal fun parseIdentifier(element: JsonElement?): String? {
val str = parseStrictString(element)?.trim() ?: return null
return if (ConnectorRepository.isValidSlug(str)) str else null
}
internal fun parseString(element: JsonElement?): String? {
if (element == null || element is JsonNull) return null
if (element is JsonPrimitive) {
if (!element.isString && element.content.isBlank()) return null
return element.content
}
return null
}
internal fun parseBooleanOrNull(element: JsonElement?): Boolean? {
if (element == null || element is JsonNull) return null
if (element is JsonPrimitive) {
element.booleanOrNull?.let { return it }
val content = element.content.trim()
if (content.equals("true", ignoreCase = true) || content == "1") return true
if (content.equals("false", ignoreCase = true) || content == "0") return false
}
return null
}
internal fun parseBoolean(
element: JsonElement?,
default: Boolean,
): Boolean = parseBooleanOrNull(element) ?: default
internal fun parseInt(
element: JsonElement?,
default: Int,
): Int {
if (element == null || element is JsonNull) return default
if (element is JsonPrimitive) {
element.intOrNull?.let { return it }
element.content.toIntOrNull()?.let { return it }
}
return default
}
fun toJsonElement(value: Any?): JsonElement =
when (value) {
null -> {
JsonNull
}
is JsonElement -> {
value
}
is Map<*, *> -> {
JsonObject(
@Suppress("UNCHECKED_CAST")
(value as Map<String, Any?>).mapValues { (_, v) -> toJsonElement(v) },
)
}
is List<*> -> {
JsonArray(value.map { toJsonElement(it) })
}
is String -> {
val trimmed = value.trim()
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
) {
try {
json.parseToJsonElement(value)
} catch (_: Exception) {
JsonPrimitive(value)
}
} else {
JsonPrimitive(value)
}
}
is Boolean -> {
JsonPrimitive(value)
}
is Number -> {
JsonPrimitive(value)
}
else -> {
JsonPrimitive(value.toString())
}
}
}
@@ -0,0 +1,126 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ConnectorConnectResult
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorListResult
import kotlinx.coroutines.CancellationException
/**
* Repository interface for connector operations: `connectors.list` and `connectors.connect` (issue #1091).
* Public injectable interface enabling isolated testing in ViewModels and controllers.
*/
interface ConnectorRepository {
/**
* Request connector catalog and authorization status for [sessionId].
*/
suspend fun listConnectors(sessionId: String): ConnectorListResult
/**
* Initiate authorization flow for [connectors] slugs in [sessionId].
* If [reconnect] is true, restarts authorization flow even if already connected.
*/
suspend fun connect(
sessionId: String,
connectors: List<String>,
reconnect: Boolean = false,
): ConnectorConnectResult
companion object : ConnectorRepository by HermesConnectorRepository() {
private val SLUG_REGEX = Regex("^[a-z0-9][a-z0-9_-]*$")
fun isValidSlug(slug: String?): Boolean {
if (slug.isNullOrBlank()) return false
return SLUG_REGEX.matches(slug)
}
}
}
/**
* Default implementation of [ConnectorRepository] backed by [HermesWsClient].
*/
class HermesConnectorRepository(
private val rpcRequest: suspend (method: String, params: Map<String, Any>) -> Any? = { method, params ->
val deferred = HermesWsClient.request(method, params)
try {
deferred.await()
} catch (e: CancellationException) {
deferred.cancel(e)
throw e
} finally {
if (!deferred.isCompleted) {
deferred.cancel()
}
}
},
) : ConnectorRepository {
override suspend fun listConnectors(sessionId: String): ConnectorListResult {
if (sessionId.isBlank()) {
return ConnectorListResult.Error(
ConnectorError.InvalidParams("session_id required"),
)
}
return try {
val params = mapOf("session_id" to sessionId)
val result = rpcRequest(WsMethods.CONNECTORS_LIST, params)
ConnectorParser.parseListResult(result)
} catch (e: CancellationException) {
throw e
} catch (e: HermesWsClient.HermesRpcException) {
ConnectorListResult.Error(
ConnectorParser.mapRpcError(code = e.code, message = e.message ?: "", data = e.data),
)
} catch (_: Exception) {
ConnectorListResult.Error(
ConnectorError.NetworkError("Failed to communicate with gateway."),
)
}
}
override suspend fun connect(
sessionId: String,
connectors: List<String>,
reconnect: Boolean,
): ConnectorConnectResult {
if (sessionId.isBlank()) {
return ConnectorConnectResult.Error(
ConnectorError.InvalidParams("session_id required"),
)
}
if (connectors.isEmpty()) {
return ConnectorConnectResult.Error(
ConnectorError.InvalidParams("connectors must be nonempty slugs; reconnect must be boolean"),
)
}
for (slug in connectors) {
if (!ConnectorRepository.isValidSlug(slug)) {
return ConnectorConnectResult.Error(
ConnectorError.InvalidParams("connectors must be nonempty slugs; reconnect must be boolean"),
)
}
}
return try {
val params =
mapOf(
"session_id" to sessionId,
"connectors" to connectors,
"reconnect" to reconnect,
)
val result = rpcRequest(WsMethods.CONNECTORS_CONNECT, params)
ConnectorParser.parseConnectResult(result)
} catch (e: CancellationException) {
throw e
} catch (e: HermesWsClient.HermesRpcException) {
ConnectorConnectResult.Error(
ConnectorParser.mapRpcError(code = e.code, message = e.message ?: "", data = e.data),
)
} catch (_: Exception) {
ConnectorConnectResult.Error(
ConnectorError.NetworkError("Failed to communicate with gateway."),
)
}
}
}
@@ -87,7 +87,7 @@ object EventParser {
"message.complete" -> {
val text = payload?.get("text") as? String ?: ""
val reasoning = payload?.get("reasoning") as? String
WsEvent.MessageComplete(text, sessionId, reasoning)
WsEvent.MessageComplete(text, sessionId, reasoning, rawPayload = payload)
}
"message.done" -> {
@@ -341,6 +341,43 @@ object EventParser {
WsEvent.SecretExpire(requestId, sessionId)
}
// ── Credential Vault prompts (issue #1090) ──────────────────
"vault.unlock.request" -> {
val requestId = payload?.get("request_id") as? String
val backend = payload?.get("backend") as? String
val displayName = payload?.get("display_name") as? String
WsEvent.VaultUnlockRequest(requestId, sessionId, backend, displayName)
}
"vault.unlock.expire" -> {
val requestId = payload?.get("request_id") as? String
WsEvent.VaultUnlockExpire(requestId, sessionId)
}
"vault.save_login.request" -> {
val requestId = payload?.get("request_id") as? String
val origin = payload?.get("origin") as? String
val site = payload?.get("site") as? String
WsEvent.VaultSaveLoginRequest(requestId, sessionId, origin, site)
}
"vault.save_login.expire" -> {
val requestId = payload?.get("request_id") as? String
WsEvent.VaultSaveLoginExpire(requestId, sessionId)
}
"vault.code.request" -> {
val requestId = payload?.get("request_id") as? String
val site = payload?.get("site") as? String
val hint = payload?.get("hint") as? String
WsEvent.VaultCodeRequest(requestId, sessionId, site, hint)
}
"vault.code.expire" -> {
val requestId = payload?.get("request_id") as? String
WsEvent.VaultCodeExpire(requestId, sessionId)
}
else -> {
Log.w(TAG, "Unknown event type: $eventType")
WsEvent.Unknown(rawJson)
@@ -9,10 +9,12 @@ import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.currentCoroutineContext
@@ -25,9 +27,12 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
@@ -62,34 +67,48 @@ enum class ConnectionStatus {
object HermesWsClient {
private const val TAG = "HermesWsClient"
// ── Backoff settings ─────────────────────────────────────────────────
// ── Monotonic time helper (immune to NTP and wall-clock jumps) ─────────
private fun monotonicTimeMs(): Long = System.nanoTime() / 1_000_000L
// ── Backoff settings (desktop parity: reconnect-backoff.ts full-jitter) ─
private var initialBackoffMs = 1_000L
private const val BACKOFF_BASE_MS = 300L
private const val BACKOFF_CAP_MS = 15_000L
private const val MAX_BACKOFF_MS = 30_000L
private const val BACKOFF_MULTIPLIER = 2.0
private var testBackoffOverrideMs: Long? = null
/**
* Test hook: the initial reconnect backoff, overridable so reconnect
* tests are deterministic instead of racing real time (CI runners
* routinely exceed fixed latch windows). Production keeps 1s.
* routinely exceed fixed latch windows). Production keeps full-jitter backoff.
*/
@VisibleForTesting
internal fun setReconnectBackoffForTest(initialMillis: Long) {
initialBackoffMs = initialMillis
currentBackoff = initialMillis
testBackoffOverrideMs = initialMillis
}
private fun nextBackoffDelayMs(): Long {
val override = testBackoffOverrideMs
if (override != null) return override
val ceiling = currentBackoff
if (ceiling <= 0L) return 0L
return (Math.random() * ceiling).toLong().coerceAtLeast(BACKOFF_BASE_MS).coerceAtMost(ceiling)
}
private const val MAX_OUTBOUND_MESSAGE_BYTES = 16 * 1024 * 1024
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 60_000L
/**
* No inbound frame for this long means the link is dead even though the
* TCP session may still look open (silent NAT/VPN drop). OkHttp pings
* every 30s (OkHttpProvider.websocket) and any inbound frame refreshes
* [lastPongTimestamp], so a healthy link never exceeds ~30s of silence.
* 90s tolerates exactly one missed ping cycle before acting.
* Liveness thresholds aligned with reference clients (desktop/web):
* Ping every 15s using gateway.ping; 45s inbound silence deadline.
*/
private const val STALE_THRESHOLD_MS = 90_000L
private const val HEARTBEAT_INTERVAL_MS = 15_000L
private const val STALE_THRESHOLD_MS = 45_000L
private const val LIVENESS_PROBE_TIMEOUT_MS = 5_000L
// ── Internal state (all access through synchronized / atomic) ────────
@@ -150,7 +169,7 @@ object HermesWsClient {
private set
val isHealthy: Boolean
get() = isConnected && (System.currentTimeMillis() - lastPongTimestamp < 60_000L)
get() = isConnected && (monotonicTimeMs() - lastPongTimestamp < STALE_THRESHOLD_MS)
// ── Observable latency tracking (issue #1017) ─────────────────────────
private val _lastLatencyMs = MutableStateFlow<Long?>(null)
@@ -162,14 +181,14 @@ object HermesWsClient {
private fun startHealthTracking() {
healthJob?.cancel()
lastPongTimestamp = System.currentTimeMillis()
lastPongTimestamp = monotonicTimeMs()
healthJob =
wsScope.launch {
while (connected.get()) {
delay(30_000L)
delay(HEARTBEAT_INTERVAL_MS)
if (connected.get()) {
try {
ping(timeoutMs = 10_000L)
ping(timeoutMs = LIVENESS_PROBE_TIMEOUT_MS)
} catch (e: Exception) {
Log.w(TAG, "Health check ping failed: ${e.message}")
}
@@ -181,14 +200,15 @@ object HermesWsClient {
/**
* Ultra-lightweight JSON-RPC liveness check and RTT measurement (issue #1017).
* Bypasses agent queues on the backend; measures round-trip time in milliseconds.
* Uses [WsMethods.GATEWAY_PING] which is answered inline in the WebSocket read
* loop (ws.py:350) and never queues behind worker dispatch pools.
* Updates [lastLatencyMs] and [lastPongTimestamp].
*/
suspend fun ping(timeoutMs: Long = 5_000L): Long {
val start = System.currentTimeMillis()
request(WsMethods.PING, emptyMap(), timeoutMs = timeoutMs).await()
val latency = (System.currentTimeMillis() - start).coerceAtLeast(0L)
lastPongTimestamp = System.currentTimeMillis()
suspend fun ping(timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS): Long {
val start = monotonicTimeMs()
request(WsMethods.GATEWAY_PING, emptyMap(), timeoutMs = timeoutMs).await()
val latency = (monotonicTimeMs() - start).coerceAtLeast(0L)
lastPongTimestamp = monotonicTimeMs()
_lastLatencyMs.value = latency
return latency
}
@@ -207,7 +227,7 @@ object HermesWsClient {
*/
private fun runHealthCheckPass(): Boolean {
if (!connected.get()) return false
val staleMs = System.currentTimeMillis() - lastPongTimestamp
val staleMs = monotonicTimeMs() - lastPongTimestamp
if (staleMs <= STALE_THRESHOLD_MS) return true
Log.w(TAG, "WebSocket stale (${staleMs / 1000}s without frames) — cancelling to trigger reconnect")
synchronized(outboundLock) {
@@ -220,11 +240,11 @@ object HermesWsClient {
/**
* Test hook: backdate liveness past the staleness threshold and force one
* synchronous watchdog pass, so reconnect tests are deterministic instead
* of racing the real 30s/90s cadence.
* of racing the real 15s/45s cadence.
*/
@VisibleForTesting
internal fun forceHealthCheckForTest(staleMillis: Long) {
lastPongTimestamp = System.currentTimeMillis() - staleMillis
lastPongTimestamp = monotonicTimeMs() - staleMillis
runHealthCheckPass()
}
@@ -238,7 +258,7 @@ object HermesWsClient {
private val parsedEvents =
MutableSharedFlow<WsEvent>(
extraBufferCapacity = 512,
extraBufferCapacity = 2048,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
@@ -346,9 +366,34 @@ object HermesWsClient {
@VisibleForTesting
val isConnected: Boolean get() = connected.get()
private var foregroundProbeJob: Job? = null
fun setAppForeground(foreground: Boolean) {
appInForeground.set(foreground)
if (!foreground) disconnectIfIdleInBackground()
if (!foreground) {
disconnectIfIdleInBackground()
return
}
probeLivenessOnWake()
}
private fun probeLivenessOnWake(deferredOnce: Boolean = false) {
if (!connected.get()) return
foregroundProbeJob?.cancel()
foregroundProbeJob =
wsScope.launch {
val alive = runCatching { ping(LIVENESS_PROBE_TIMEOUT_MS) }.isSuccess
if (alive) return@launch
if (pendingReply && !deferredOnce) {
delay(3_000L)
probeLivenessOnWake(deferredOnce = true)
return@launch
}
Log.w(TAG, "Foreground liveness probe failed — cancelling socket")
synchronized(outboundLock) {
if (connected.get()) webSocket?.cancel()
}
}
}
fun acquireExternalActivityConnectionLease() {
@@ -629,6 +674,8 @@ object HermesWsClient {
reconnectJob = null
outboundDrainJob?.cancel()
outboundDrainJob = null
foregroundProbeJob?.cancel()
foregroundProbeJob = null
stopHealthTracking()
webSocket?.close(1000, "Client closed")
webSocket = null
@@ -658,6 +705,8 @@ object HermesWsClient {
/** Thrown when an awaited [request] is neither answered nor rejected within [REQUEST_TIMEOUT_MS], or is rejected by a disconnect. */
class HermesRpcException(
message: String,
val code: Int = 0,
val data: JsonElement? = null,
) : Exception(message)
/**
@@ -697,6 +746,15 @@ object HermesWsClient {
send(method, params) { reqId ->
pendingCalls[reqId] = PendingCall(method, deferred)
}
deferred.invokeOnCompletion { cause ->
if (cause is CancellationException) {
pendingCalls.remove(id)?.let { call ->
call.timeoutJob?.cancel()
removeQueuedMessage(id)
disconnectIfIdleInBackground()
}
}
}
// Arm the per-request timeout (fires if the server never answers).
pendingCalls[id]?.timeoutJob =
wsScope.launch {
@@ -716,7 +774,13 @@ object HermesWsClient {
removeQueuedMessage(id)
call.timeoutJob?.cancel()
if (error != null) {
call.deferred.completeExceptionally(HermesRpcException(error.message))
call.deferred.completeExceptionally(
HermesRpcException(
message = error.message,
code = error.code,
data = error.data,
),
)
} else {
call.deferred.complete(result)
}
@@ -764,7 +828,9 @@ object HermesWsClient {
params = decoratedParams.mapValues { it.value.toJsonElement() },
)
val json = OkHttpProvider.json.encodeToString(request)
if (BuildConfig.DEBUG) Log.d(TAG, "→ $json")
if (BuildConfig.DEBUG) {
Log.d(TAG, formatSafeOutgoingFrameLog(id, method, request.params.keys, json.length, queued = false))
}
var reconnect = false
synchronized(outboundLock) {
if (method == WsMethods.PROMPT_SUBMIT) {
@@ -890,21 +956,23 @@ object HermesWsClient {
// ── Sequence tracking & replay on reconnect (issue #1016) ─────────────
private fun triggerReplay() {
// Arm the hold SYNCHRONOUSLY (still under onOpen's outboundLock) so a
// live frame racing the replay coroutine is parked instead of advancing
// lastSeenSeq past events the replay would then skip-drop (G2).
if (lastSeenSeq.isEmpty() || replayInFlight) return
replayJob?.cancel()
replayJob =
wsScope.launch {
fetchReplay()
}
}
private suspend fun fetchReplay() {
if (replayInFlight || lastSeenSeq.isEmpty()) return
replayInFlight = true
val sessionsToReplay = lastSeenSeq.keys().toList()
for (sid in sessionsToReplay) {
replayHold[sid] = Collections.synchronizedList(mutableListOf())
}
replayJob?.cancel()
replayJob =
wsScope.launch {
fetchReplay(sessionsToReplay)
}
}
private suspend fun fetchReplay(sessionsToReplay: List<String> = lastSeenSeq.keys().toList()) {
try {
for (sid in sessionsToReplay) {
val lastSeen = lastSeenSeq[sid] ?: continue
@@ -926,13 +994,21 @@ object HermesWsClient {
}
if (resultMap != null) {
val epoch = resultMap["epoch"] as? String
if (!epoch.isNullOrEmpty()) {
if (replayEpoch != null && replayEpoch != epoch) {
replayEpoch = epoch
lastSeenSeq.clear()
continue
}
replayEpoch = epoch
val epochChanged =
!epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch
val truncated = resultMap["truncated"] == true
val latestSeq = (resultMap["latest_seq"] as? Number)?.toInt()
if (epochChanged) replayEpoch = epoch
if (epochChanged || truncated) {
// Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted).
// Partial replay would silently hole the transcript — fast-forward watermark
// and request full transcript resync.
if (epochChanged) lastSeenSeq.clear()
if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq
parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid))
continue
}
val eventsList = (resultMap["events"] as? List<*>)?.filterIsInstance<Map<String, Any?>>()
if (eventsList != null) {
@@ -962,25 +1038,28 @@ object HermesWsClient {
}
} catch (e: Exception) {
Log.w(TAG, "Replay failed for session $sid: ${e.message}")
parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid))
}
}
} finally {
for (sid in sessionsToReplay) {
val held = replayHold.remove(sid)
if (held != null) {
synchronized(held) {
for ((heldSeq, heldEvent) in held) {
if (heldSeq != null) {
val prev = lastSeenSeq[sid] ?: 0
if (heldSeq <= prev) continue
lastSeenSeq[sid] = heldSeq
withContext(NonCancellable) {
for (sid in sessionsToReplay) {
val held = replayHold.remove(sid)
if (held != null) {
synchronized(held) {
for ((heldSeq, heldEvent) in held) {
if (heldSeq != null) {
val prev = lastSeenSeq[sid] ?: 0
if (heldSeq <= prev) continue
lastSeenSeq[sid] = heldSeq
}
parsedEvents.tryEmit(heldEvent)
}
parsedEvents.tryEmit(heldEvent)
}
}
}
replayInFlight = false
}
replayInFlight = false
}
}
@@ -1065,7 +1144,7 @@ object HermesWsClient {
_connectionStatus.value = ConnectionStatus.NO_NETWORK
return
}
val reconnectDelay = currentBackoff
val reconnectDelay = nextBackoffDelayMs()
currentBackoff =
(currentBackoff * BACKOFF_MULTIPLIER)
.toLong()
@@ -1092,6 +1171,14 @@ object HermesWsClient {
}
}
private fun isTerminalAuthClose(
code: Int,
reason: String,
): Boolean =
code == 4401 || code == 4403 ||
reason.contains("unauthorized", ignoreCase = true) ||
reason.startsWith("auth:", ignoreCase = true)
// ── Listener ─────────────────────────────────────────────────────────
private class WsListenerImpl(
@@ -1126,7 +1213,7 @@ object HermesWsClient {
markQueuedMessageSent(msg)
continue
}
if (BuildConfig.DEBUG) Log.d(TAG, "→ (queued) $msg")
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeQueuedFrameLog(msg))
if (!webSocket.send(msg)) {
recoverRejectedSocket(webSocket)
break
@@ -1144,8 +1231,8 @@ object HermesWsClient {
text: String,
) {
if (!isCurrent() || HermesWsClient.webSocket !== webSocket) return
if (BuildConfig.DEBUG) Log.d(TAG, "← $text")
lastPongTimestamp = System.currentTimeMillis()
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeIncomingFrameLog(text))
lastPongTimestamp = monotonicTimeMs()
// Resolve any in-flight `request()` awaiting this RPC result/error
// (issue #526) before fanning the parsed event out to collectors.
val event =
@@ -1173,6 +1260,7 @@ object HermesWsClient {
if (!sid.isNullOrBlank() && seq != null) {
val prev = lastSeenSeq[sid] ?: 0
if (seq <= prev) {
// Frame is already at or behind watermark — drop duplicate
return
}
val parsed = EventParser.parse(rpc, text)
@@ -1203,7 +1291,7 @@ object HermesWsClient {
parsed
}
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e)
Log.e(TAG, "Failed to parse message: ${e.javaClass.simpleName}")
WsEvent.Unknown(text)
}
when (event) {
@@ -1219,17 +1307,17 @@ object HermesWsClient {
pendingPromptSubmits.isEmpty()
) {
pendingReply = false
disconnectIfIdleInBackground()
}
}
removeQueuedMessage(event.id)
resolvePending(event.id, null, event.error)
disconnectIfIdleInBackground()
}
else -> {}
}
// tryEmit on a DROP_OLDEST flow only returns false when the
// buffer is full AND no subscriber is draining; with extraBuffer=512
// buffer is full AND no subscriber is draining; with extraBuffer=2048
// and always-on init collectors this is unreachable in practice.
parsedEvents.tryEmit(event)
}
@@ -1247,10 +1335,7 @@ object HermesWsClient {
closingSocket = webSocket
// Do NOT log [reason] — it may carry server-side context.
Log.d(TAG, "WebSocket closing: $code")
if (code == 4001 || code == 4401 ||
reason.contains("unauthorized", ignoreCase = true) ||
reason.startsWith("auth:", ignoreCase = true)
) {
if (isTerminalAuthClose(code, reason)) {
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
}
webSocket.close(code, reason)
@@ -1275,10 +1360,7 @@ object HermesWsClient {
connected.set(false)
ActiveSessionHolder.clear()
stopHealthTracking()
if (code == 4001 || code == 4401 ||
reason.contains("unauthorized", ignoreCase = true) ||
reason.startsWith("auth:", ignoreCase = true)
) {
if (isTerminalAuthClose(code, reason)) {
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
} else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) {
_connectionStatus.value = ConnectionStatus.RECONNECTING
@@ -1307,9 +1389,11 @@ object HermesWsClient {
ActiveSessionHolder.clear()
stopHealthTracking()
val code = response?.code ?: 0
if (code == 401 || t.message?.contains(
"401",
) == true || t.message?.contains("unauthorized", ignoreCase = true) == true
if (code == 401 || code == 4401 || code == 4403 ||
t.message?.contains("401") == true ||
t.message?.contains("4401") == true ||
t.message?.contains("4403") == true ||
t.message?.contains("unauthorized", ignoreCase = true) == true
) {
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
} else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) {
@@ -1319,4 +1403,60 @@ object HermesWsClient {
}
}
}
@VisibleForTesting
internal fun setConnectionStatusForTest(status: ConnectionStatus) {
_connectionStatus.value = status
}
@VisibleForTesting
internal fun formatSafeOutgoingFrameLog(
id: String,
method: String,
paramsKeys: Collection<String>?,
byteLength: Int,
queued: Boolean = false,
): String {
val prefix = if (queued) "→ (queued)" else "→"
val keys = paramsKeys?.joinToString(",", prefix = "[", postfix = "]") ?: "[]"
return "$prefix id=$id method=$method paramsKeys=$keys bytes=$byteLength"
}
@VisibleForTesting
internal fun formatSafeQueuedFrameLog(msg: String): String =
try {
val req = OkHttpProvider.json.decodeFromString<JsonRpcRequest>(msg)
formatSafeOutgoingFrameLog(req.id, req.method, req.params.keys, msg.length, queued = true)
} catch (_: Throwable) {
"→ (queued) frame bytes=${msg.length}"
}
@VisibleForTesting
internal fun formatSafeIncomingFrameLog(text: String): String =
try {
val element = OkHttpProvider.json.parseToJsonElement(text)
if (element is JsonObject) {
val id = (element["id"] as? JsonPrimitive)?.content
val method = (element["method"] as? JsonPrimitive)?.content
val hasResult = element.containsKey("result")
val resultKeys =
(element["result"] as? JsonObject)?.keys?.joinToString(
",",
prefix = "[",
postfix = "]",
)
val errorObj = element["error"] as? JsonObject
val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.content
val sb = StringBuilder("← ")
if (id != null) sb.append("id=").append(id).append(" ")
if (method != null) sb.append("method=").append(method).append(" ")
if (hasResult) sb.append("result=").append(resultKeys ?: "present").append(" ")
if (errorCode != null) sb.append("errorCode=").append(errorCode).append(" ")
sb.append("bytes=").append(text.length).toString()
} else {
"← non-object frame bytes=${text.length}"
}
} catch (_: Throwable) {
"← frame bytes=${text.length}"
}
}
@@ -0,0 +1,169 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ActiveSessionsResponse
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
import com.m57.hermescontrol.data.model.SessionLiveStatus
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.serializer
/**
* Interface for polling and observing live session statuses.
*/
interface SessionLiveStatusSource {
suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot?
val events: Flow<WsEvent>
val connectionStatus: StateFlow<ConnectionStatus>
}
/**
* Decodes untyped RPC responses from `session.active_list` into a [LiveSessionSnapshot].
*/
object SessionLiveStatusDecoder {
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
}
fun decodeSnapshot(rawResult: Any?): LiveSessionSnapshot? {
if (rawResult == null) return null
val response =
try {
decodeResponse(rawResult)
} catch (_: Exception) {
return null
} ?: return null
val statusByStoredId = mutableMapOf<String, SessionLiveStatus>()
val storedIdByRuntimeId = mutableMapOf<String, String>()
for (item in response.sessions) {
val runtimeId = item.id?.trim()
val storedId = item.sessionKey?.trim()
if (runtimeId.isNullOrEmpty() || storedId.isNullOrEmpty()) {
continue
}
storedIdByRuntimeId[runtimeId] = storedId
when (item.status?.trim()?.lowercase()) {
"working" -> {
statusByStoredId[storedId] = SessionLiveStatus.WORKING
}
"waiting" -> {
statusByStoredId[storedId] = SessionLiveStatus.WAITING
}
else -> {
// idle, starting, unknown -> no live indicator
}
}
}
return LiveSessionSnapshot(
statusByStoredId = statusByStoredId,
storedIdByRuntimeId = storedIdByRuntimeId,
)
}
private fun decodeResponse(result: Any): ActiveSessionsResponse? {
val element: JsonElement =
when (result) {
is JsonElement -> {
result
}
is Map<*, *> -> {
anyToJsonElement(result)
}
is List<*> -> {
JsonArray(result.map { anyToJsonElement(it) })
}
else -> {
val str = result.toString()
try {
json.parseToJsonElement(str)
} catch (_: Exception) {
return null
}
}
}
if (element !is JsonObject || !element.containsKey("sessions")) {
return null
}
return json.decodeFromJsonElement(serializer<ActiveSessionsResponse>(), element)
}
@Suppress("UNCHECKED_CAST")
internal fun anyToJsonElement(value: Any?): JsonElement =
when (value) {
null -> {
JsonNull
}
is JsonElement -> {
value
}
is Map<*, *> -> {
JsonObject(
(value as Map<String, Any?>).mapValues { (_, v) -> anyToJsonElement(v) },
)
}
is List<*> -> {
JsonArray(value.map { anyToJsonElement(it) })
}
is String -> {
JsonPrimitive(value)
}
is Boolean -> {
JsonPrimitive(value)
}
is Number -> {
JsonPrimitive(value)
}
else -> {
JsonPrimitive(value.toString())
}
}
}
/**
* Production implementation of [SessionLiveStatusSource] backed by [HermesWsClient].
*/
class HermesSessionLiveStatusSource(
private val rpcRequest: suspend (method: String, params: Map<String, Any>) -> Any? = { method, params ->
HermesWsClient.request(method, params).await()
},
eventsProvider: () -> Flow<WsEvent> = { HermesWsClient.events },
connectionStatusProvider: () -> StateFlow<ConnectionStatus> = { HermesWsClient.connectionStatus },
) : SessionLiveStatusSource {
override val events: Flow<WsEvent> by lazy { eventsProvider() }
override val connectionStatus: StateFlow<ConnectionStatus> by lazy { connectionStatusProvider() }
override suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? =
try {
val raw = rpcRequest(WsMethods.SESSION_ACTIVE_LIST, emptyMap())
SessionLiveStatusDecoder.decodeSnapshot(raw)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
}
@@ -0,0 +1,138 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.model.SubagentListResponse
import com.m57.hermescontrol.data.model.SubagentTailResponse
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.serializer
/**
* WebSocket RPC repository for subagent operations: `subagent.list` and `subagent.tail` (issue #1089).
*/
object SubagentRepository {
private const val TAG = "SubagentRepository"
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
}
/**
* Request the current active subagent roster for a given [sessionId].
*
* Returns null if RPC fails or is unsupported by server.
*/
suspend fun listSubagents(sessionId: String): SubagentListResponse? {
if (sessionId.isBlank()) return null
return try {
val result =
HermesWsClient
.request(
WsMethods.SUBAGENT_LIST,
mapOf("session_id" to sessionId),
).await()
decode<SubagentListResponse>(result)
} catch (e: Exception) {
Log.w(TAG, "subagent.list request failed for session $sessionId: ${e.message}")
null
}
}
/**
* Request a rolling live transcript tail for [subagentId].
*
* Supplies [sessionId] when available so gateway authority checks pass cleanly.
* Throws or returns null if unavailable.
*/
suspend fun tailSubagent(
sessionId: String,
subagentId: String,
maxBytes: Int = 16384,
): SubagentTailResponse? {
if (subagentId.isBlank()) return null
val params =
mutableMapOf<String, Any>(
"subagent_id" to subagentId,
"max_bytes" to maxBytes,
)
if (sessionId.isNotBlank()) {
params["session_id"] = sessionId
}
val result = HermesWsClient.request(WsMethods.SUBAGENT_TAIL, params).await()
return decode<SubagentTailResponse>(result)
}
@Suppress("UNCHECKED_CAST")
internal inline fun <reified T> decode(result: Any?): T? {
if (result == null) return null
val element: JsonElement =
when (result) {
is JsonElement -> {
result
}
is Map<*, *> -> {
anyToJsonElement(result)
}
is List<*> -> {
JsonArray(result.map { anyToJsonElement(it) })
}
else -> {
val str = result.toString()
try {
json.parseToJsonElement(str)
} catch (_: Exception) {
JsonPrimitive(str)
}
}
}
return json.decodeFromJsonElement(serializer<T>(), element)
}
@Suppress("UNCHECKED_CAST")
internal fun anyToJsonElement(value: Any?): JsonElement =
when (value) {
null -> {
JsonNull
}
is JsonElement -> {
value
}
is Map<*, *> -> {
JsonObject(
(value as Map<String, Any?>).mapValues { (_, v) -> anyToJsonElement(v) },
)
}
is List<*> -> {
JsonArray(value.map { anyToJsonElement(it) })
}
is String -> {
JsonPrimitive(value)
}
is Boolean -> {
JsonPrimitive(value)
}
is Number -> {
JsonPrimitive(value)
}
else -> {
JsonPrimitive(value.toString())
}
}
}
@@ -0,0 +1,91 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.VaultItem
import com.m57.hermescontrol.data.model.VaultListResponse
import com.m57.hermescontrol.data.model.VaultLockResponse
import com.m57.hermescontrol.data.model.VaultSource
import com.m57.hermescontrol.data.model.VaultSourceSetResponse
import com.m57.hermescontrol.data.model.VaultSourcesResponse
import com.m57.hermescontrol.data.model.VaultUnlockResponse
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.serializer
/**
* Repository for Credential Vault RPC operations over WebSocket (issue #1090).
*/
object VaultRepository {
private val json get() = OkHttpProvider.json
@Suppress("UNCHECKED_CAST")
private inline fun <reified T> decode(result: Any?): T? {
if (result == null) return null
val element: JsonElement =
when (result) {
is JsonElement -> result
is Map<*, *> -> anyToJsonElement(result)
is List<*> -> JsonArray(result.map { anyToJsonElement(it) })
else -> JsonPrimitive(result.toString())
}
return json.decodeFromJsonElement(serializer<T>(), element)
}
@Suppress("UNCHECKED_CAST")
private fun anyToJsonElement(value: Any?): JsonElement =
when (value) {
null -> JsonNull
is JsonElement -> value
is Map<*, *> -> JsonObject((value as Map<String, Any?>).mapValues { (_, v) -> anyToJsonElement(v) })
is List<*> -> JsonArray(value.map { anyToJsonElement(it) })
is String -> JsonPrimitive(value)
is Boolean -> JsonPrimitive(value)
is Number -> JsonPrimitive(value)
else -> JsonPrimitive(value.toString())
}
suspend fun getSources(): List<VaultSource> {
val result = HermesWsClient.request(WsMethods.VAULT_SOURCES).await()
return decode<VaultSourcesResponse>(result)?.sources ?: emptyList()
}
suspend fun setSourceEnabled(
name: String,
enabled: Boolean,
): Boolean {
val result =
HermesWsClient
.request(
WsMethods.VAULT_SOURCE_SET,
mapOf("name" to name, "enabled" to enabled),
).await()
return decode<VaultSourceSetResponse>(result)?.enabled == enabled
}
suspend fun unlockSource(
name: String,
password: String,
): Boolean {
val result =
HermesWsClient
.request(
WsMethods.VAULT_UNLOCK,
mapOf("name" to name, "password" to password),
).await()
return decode<VaultUnlockResponse>(result)?.unlocked == true
}
suspend fun lockSource(name: String? = null): Boolean {
val params = if (name != null) mapOf("name" to name) else emptyMap<String, Any>()
val result = HermesWsClient.request(WsMethods.VAULT_LOCK, params).await()
return decode<VaultLockResponse>(result)?.locked == true
}
suspend fun listItems(): List<VaultItem> {
val result = HermesWsClient.request(WsMethods.VAULT_LIST).await()
return decode<VaultListResponse>(result)?.items ?: emptyList()
}
}
@@ -61,6 +61,8 @@ sealed class WsEvent {
val reasoning: String? = null,
/** Stored session id captured before background disconnect clears the active mapping. */
val storedSessionId: String? = null,
/** Full raw payload map (including usage/avg_tps) emitted with message.complete. */
val rawPayload: Map<String, Any?>? = null,
) : WsEvent()
data class MessageDone(
@@ -301,6 +303,44 @@ sealed class WsEvent {
val sessionId: String?,
) : WsEvent()
// ── Credential Vault prompts (issue #1090) ──────────────────────────
data class VaultUnlockRequest(
val requestId: String?,
val sessionId: String? = null,
val backend: String? = null,
val displayName: String? = null,
) : WsEvent()
data class VaultUnlockExpire(
val requestId: String?,
val sessionId: String? = null,
) : WsEvent()
data class VaultSaveLoginRequest(
val requestId: String?,
val sessionId: String? = null,
val origin: String? = null,
val site: String? = null,
) : WsEvent()
data class VaultSaveLoginExpire(
val requestId: String?,
val sessionId: String? = null,
) : WsEvent()
data class VaultCodeRequest(
val requestId: String?,
val sessionId: String? = null,
val site: String? = null,
val hint: String? = null,
) : WsEvent()
data class VaultCodeExpire(
val requestId: String?,
val sessionId: String? = null,
) : WsEvent()
// ── Gateway-level errors ───────────────────────────────────────────
/**
@@ -355,6 +395,13 @@ sealed class WsEvent {
val kind: String = "",
) : WsEvent()
// ── Replay resync (internal) ──────────────────────────────────────────
/** Internal: replay could not cover the reconnect gap (truncated or epoch change) — UI must refetch history. */
data class TranscriptResyncRequired(
val sessionId: String,
) : WsEvent()
// ── Fallback ─────────────────────────────────────────────────────────
data class Unknown(
@@ -43,11 +43,24 @@ object WsMethods {
const val SUDO_RESPOND = "sudo.respond"
const val SECRET_RESPOND = "secret.respond"
// ── Credential Vault (issue #1090) ────────────────────────────────────
const val VAULT_UNLOCK_RESPOND = "vault.unlock.respond"
const val VAULT_SAVE_LOGIN_RESPOND = "vault.save_login.respond"
const val VAULT_CODE_RESPOND = "vault.code.respond"
const val VAULT_LIST = "vault.list"
const val VAULT_SOURCES = "vault.sources"
const val VAULT_SOURCE_SET = "vault.source.set"
const val VAULT_UNLOCK = "vault.unlock"
const val VAULT_LOCK = "vault.lock"
// ── Heartbeat & Latency (issue #1017) ──────────────────────────────────
/** Ultra-lightweight liveness check and round-trip latency measurement. */
const val PING = "ping"
/** Inline WebSocket read-loop ping, bypasses server thread pool dispatch. */
const val GATEWAY_PING = "gateway.ping"
// ── Commands catalog ──────────────────────────────────────────────────
const val COMMANDS_CATALOG = "commands.catalog"
const val COMMAND_DISPATCH = "command.dispatch"
@@ -70,6 +83,22 @@ object WsMethods {
/** Kill a single background process (scoped to the active session). */
const val PROCESS_KILL = "process.kill"
// ── Subagents (issue #1089) ───────────────────────────────────────────
/** List active subagents and delegations for a session. */
const val SUBAGENT_LIST = "subagent.list"
/** Rolling tail snapshot of a subagent's live execution transcript. */
const val SUBAGENT_TAIL = "subagent.tail"
// ── Connectors (issue #1091) ───────────────────────────────────────────
/** List available third-party connectors and authorization state for a session. */
const val CONNECTORS_LIST = "connectors.list"
/** Initiate or restart authorization flow for one or more connectors. */
const val CONNECTORS_CONNECT = "connectors.connect"
// ── Billing / subscription (issue #628) ─────────────────────────────
// Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e).
// `credits.view` was REMOVED upstream; these replace it. `usage.bars`
@@ -140,6 +140,13 @@ class ChatNotificationService : Service() {
showReplyNotification(preview.take(100), null)
}
is WsEvent.VaultUnlockRequest,
is WsEvent.VaultSaveLoginRequest,
is WsEvent.VaultCodeRequest,
-> {
showReplyNotification(getString(R.string.notif_input_needed), null)
}
else -> {}
}
}
@@ -34,6 +34,8 @@ data class GroupChatMessage(
val isSystem: Boolean = false,
val isPass: Boolean = false,
val toolCalls: List<GroupChatToolCall> = emptyList(),
val tokenCount: Int? = null,
val tps: Double? = null,
)
const val STOPPED_SYSTEM_TEXT = "Discussion stopped"
File diff suppressed because it is too large. Load diff
@@ -33,6 +33,7 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
data class GroupChatUiState(
val groupName: String = "",
@@ -74,22 +75,22 @@ class GroupChatViewModel(
private var activeEpoch: String = ""
// Map of bot.name -> seen count of non-system messages
private val memberWatermarks = mutableMapOf<String, Int>()
private val memberWatermarks = ConcurrentHashMap<String, Int>()
// Map of bot.name -> active session info in this group
private val memberSessions = mutableMapOf<String, MemberSession>()
private val memberSessions = ConcurrentHashMap<String, MemberSession>()
// In-flight turn completion deferreds keyed by session_id
private val inFlightTurns = mutableMapOf<String, CompletableDeferred<String>>()
private val inFlightTurns = ConcurrentHashMap<String, CompletableDeferred<String>>()
// Map of session_id -> active streaming message ID
private val activeStreamMsgId = mutableMapOf<String, String>()
private val activeStreamMsgId = ConcurrentHashMap<String, String>()
// Map of session_id -> bot profile
private val sessionToBot = mutableMapOf<String, ProfileInfo>()
private val sessionToBot = ConcurrentHashMap<String, ProfileInfo>()
// Map of session_id -> last activity epoch ms (streaming token, tool call, thinking)
private val sessionLastActivity = mutableMapOf<String, Long>()
private val sessionLastActivity = ConcurrentHashMap<String, Long>()
init {
if (groupName.isNotBlank()) {
@@ -0,0 +1,296 @@
package com.m57.hermescontrol.ui.bots.group.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
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.ImeAction
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.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
import com.m57.hermescontrol.ui.common.BotAvatar
import com.m57.hermescontrol.util.BidiUtils
@Composable
fun GroupChatComposer(
inputFieldValue: TextFieldValue,
onInputValueChange: (TextFieldValue) -> Unit,
groupBots: List<ProfileInfo>,
groupName: String,
activeSpeaker: String?,
isFocused: Boolean,
onFocusChanged: (Boolean) -> Unit,
onSend: () -> Unit,
onStop: () -> Unit,
modifier: Modifier = Modifier,
) {
val mentionQuery =
remember(inputFieldValue.text, inputFieldValue.selection) {
ChatInputPolicy.extractMentionQuery(
inputFieldValue.text,
inputFieldValue.selection.end,
)
}
val showAllOption =
remember(mentionQuery, groupBots) {
if (mentionQuery == null || groupBots.isEmpty()) {
false
} else {
"all".startsWith(mentionQuery, ignoreCase = true) ||
"everyone".startsWith(mentionQuery, ignoreCase = true)
}
}
val filteredBots =
remember(mentionQuery, groupBots) {
if (mentionQuery == null) {
emptyList()
} else {
groupBots.filter { bot ->
bot.name.startsWith(mentionQuery, ignoreCase = true) ||
bot.effectiveTitle.contains(mentionQuery, ignoreCase = true)
}
}
}
AnimatedVisibility(
visible = mentionQuery != null && (showAllOption || filteredBots.isNotEmpty()),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
LazyRow(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp)
.testTag("group_chat_mention_row"),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (showAllOption) {
item(key = "mention_all") {
SuggestionChip(
onClick = {
onInputValueChange(ChatInputPolicy.applyMention(inputFieldValue, "all"))
},
label = {
Text(
text = "@all",
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
},
icon = {
Icon(
imageVector = Icons.Filled.Groups,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
},
shape = RoundedCornerShape(8.dp),
modifier = Modifier.testTag("mention_chip_all"),
)
}
}
items(filteredBots, key = { it.name }) { bot ->
SuggestionChip(
onClick = {
onInputValueChange(ChatInputPolicy.applyMention(inputFieldValue, bot.name))
},
label = {
Text(
text = "@${bot.name}",
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.labelMedium,
)
},
icon = {
BotAvatar(
name = bot.name,
avatar = bot.botMeta()?.avatar,
size = 18.dp,
showPresence = false,
)
},
shape = RoundedCornerShape(8.dp),
modifier = Modifier.testTag("mention_chip_${bot.name}"),
)
}
}
}
// ── COMPOSER (uses TextFieldValue + embedded Send button matching ChatComposer) ──
val ambientLayoutDirection = LocalLayoutDirection.current
val inputLayoutDirection =
remember(inputFieldValue.text, ambientLayoutDirection) {
BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection)
}
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
BasicTextField(
value = inputFieldValue,
onValueChange = onInputValueChange,
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.onFocusChanged { onFocusChanged(it.isFocused) }
.testTag("group_chat_input"),
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),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSend() }),
decorationBox = { innerTextField ->
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
Surface(
shape = RoundedCornerShape(20.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
.fillMaxWidth()
.padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.weight(1f),
contentAlignment =
if (isInputRtl) {
Alignment.CenterEnd
} else {
Alignment.CenterStart
},
) {
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
if (inputFieldValue.text.isEmpty()) {
Text(
text =
stringResource(
R.string.group_chat_composer_hint,
groupName,
),
style = MaterialTheme.typography.bodyMedium,
color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.6f,
),
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
modifier = Modifier.fillMaxWidth(),
)
}
innerTextField()
}
}
Spacer(modifier = Modifier.width(4.dp))
if (activeSpeaker != null) {
IconButton(
onClick = onStop,
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
modifier =
Modifier
.size(36.dp)
.testTag("group_chat_composer_stop_button"),
) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = stringResource(R.string.group_chat_action_stop),
modifier = Modifier.size(18.dp),
)
}
} else {
IconButton(
onClick = onSend,
enabled = inputFieldValue.text.isNotBlank(),
colors = IconButtonDefaults.filledTonalIconButtonColors(),
modifier =
Modifier
.size(36.dp)
.testTag("group_chat_send_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
modifier = Modifier.size(18.dp),
)
}
}
}
}
}
},
)
}
}
@@ -0,0 +1,333 @@
package com.m57.hermescontrol.ui.bots.group.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.bots.group.DEFAULT_MAX_BOT_MESSAGES
import com.m57.hermescontrol.ui.bots.group.DEFAULT_MAX_CONTINUATION_PASSES
import com.m57.hermescontrol.ui.bots.group.MAX_ALLOWED_BOT_MESSAGES
import com.m57.hermescontrol.ui.bots.group.MAX_ALLOWED_CONTINUATION_PASSES
import com.m57.hermescontrol.ui.bots.group.WARN_MAX_BOT_MESSAGES
import com.m57.hermescontrol.ui.bots.group.WARN_MAX_CONTINUATION_PASSES
import kotlin.math.roundToInt
@Composable
fun GroupChatSettingsDialog(
currentMaxMessages: Int,
currentMaxPasses: Int,
currentSystemPrompt: String?,
onDismiss: () -> Unit,
onSave: (Int, Int, String?) -> Unit,
) {
var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) }
var maxPasses by remember(currentMaxPasses) { mutableStateOf(currentMaxPasses.toFloat()) }
var systemPrompt by remember(currentSystemPrompt) { mutableStateOf(currentSystemPrompt.orEmpty()) }
var showHighLimitsConfirmation by remember { mutableStateOf(false) }
val isHighLimit =
maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES ||
maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(R.string.group_chat_settings_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Max Bot Messages Slider
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.group_chat_settings_max_messages),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "${maxMessages.roundToInt()}",
style = MaterialTheme.typography.labelLarge,
color =
if (maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
},
fontWeight = FontWeight.Bold,
)
}
Text(
text =
stringResource(
R.string.group_chat_settings_max_messages_desc,
maxMessages.roundToInt(),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Slider(
value = maxMessages,
onValueChange = { maxMessages = it },
valueRange = 1f..MAX_ALLOWED_BOT_MESSAGES.toFloat(),
steps = MAX_ALLOWED_BOT_MESSAGES - 2,
modifier = Modifier.testTag("max_messages_slider"),
)
}
// Max Continuation Handoffs Slider
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.group_chat_settings_max_handoffs),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "${maxPasses.roundToInt()}",
style = MaterialTheme.typography.labelLarge,
color =
if (maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
},
fontWeight = FontWeight.Bold,
)
}
Text(
text =
stringResource(
R.string.group_chat_settings_max_handoffs_desc,
maxPasses.roundToInt(),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Slider(
value = maxPasses,
onValueChange = { maxPasses = it },
valueRange = 0f..MAX_ALLOWED_CONTINUATION_PASSES.toFloat(),
steps = MAX_ALLOWED_CONTINUATION_PASSES - 1,
modifier = Modifier.testTag("max_handoffs_slider"),
)
}
// Live In-Dialog Warning Banner (Option C)
AnimatedVisibility(
visible = isHighLimit,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Surface(
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f),
contentColor = MaterialTheme.colorScheme.onErrorContainer,
shape = RoundedCornerShape(12.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f)),
modifier =
Modifier
.fillMaxWidth()
.testTag("high_limits_warning_banner"),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp),
)
Text(
text =
stringResource(
R.string.group_chat_settings_high_limits_warning_desc,
maxMessages.roundToInt(),
maxPasses.roundToInt(),
),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
// Room Instructions / System Prompt
Column {
Text(
text = stringResource(R.string.group_chat_settings_system_prompt),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(R.string.group_chat_settings_system_prompt_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(6.dp))
OutlinedTextField(
value = systemPrompt,
onValueChange = { systemPrompt = it },
placeholder = {
Text(
text = stringResource(R.string.group_chat_settings_system_prompt_hint),
style = MaterialTheme.typography.bodySmall,
)
},
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 80.dp, max = 160.dp)
.testTag("room_system_prompt_input"),
textStyle = MaterialTheme.typography.bodyMedium,
maxLines = 4,
)
}
}
},
confirmButton = {
Button(
onClick = {
if (isHighLimit) {
showHighLimitsConfirmation = true
} else {
onSave(
maxMessages.roundToInt(),
maxPasses.roundToInt(),
systemPrompt.trim().ifBlank { null },
)
}
},
modifier = Modifier.testTag("save_settings_button"),
) {
Text(stringResource(R.string.group_chat_settings_save))
}
},
dismissButton = {
Row {
TextButton(
onClick = {
maxMessages = DEFAULT_MAX_BOT_MESSAGES.toFloat()
maxPasses = DEFAULT_MAX_CONTINUATION_PASSES.toFloat()
systemPrompt = ""
},
) {
Text(stringResource(R.string.group_chat_settings_reset_default))
}
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.common_cancel))
}
}
},
)
if (showHighLimitsConfirmation) {
AlertDialog(
onDismissRequest = { showHighLimitsConfirmation = false },
icon = {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
title = {
Text(
text = stringResource(R.string.group_chat_settings_high_limits_warning_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
},
text = {
Text(
text =
stringResource(
R.string.group_chat_settings_high_limits_warning_desc,
maxMessages.roundToInt(),
maxPasses.roundToInt(),
),
style = MaterialTheme.typography.bodyMedium,
)
},
confirmButton = {
Button(
onClick = {
showHighLimitsConfirmation = false
onSave(
maxMessages.roundToInt(),
maxPasses.roundToInt(),
systemPrompt.trim().ifBlank { null },
)
},
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
modifier = Modifier.testTag("confirm_high_limits_button"),
) {
Text(stringResource(R.string.group_chat_settings_high_limits_confirm))
}
},
dismissButton = {
TextButton(
onClick = { showHighLimitsConfirmation = false },
modifier = Modifier.testTag("dismiss_high_limits_button"),
) {
Text(stringResource(R.string.group_chat_settings_high_limits_adjust))
}
},
)
}
}
@@ -0,0 +1,210 @@
package com.m57.hermescontrol.ui.bots.group.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.bots.group.GroupChatToolCall
import com.m57.hermescontrol.util.BidiUtils
@Composable
fun GroupChatToolChip(
tool: GroupChatToolCall,
modifier: Modifier = Modifier,
) {
var expanded by rememberSaveable(tool.id) { mutableStateOf(false) }
val isTerminal =
tool.name.equals("terminal", ignoreCase = true) ||
tool.name.equals("execute_code", ignoreCase = true)
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
border =
BorderStroke(
1.dp,
if (tool.isError) {
MaterialTheme.colorScheme.error.copy(alpha = 0.5f)
} else {
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
},
),
modifier =
modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
) {
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
val icon =
when (tool.name.lowercase()) {
"terminal", "execute_code" -> Icons.Filled.Terminal
"web_search", "search_files" -> Icons.Filled.Search
"read_file", "write_file", "patch" -> Icons.Filled.Description
else -> Icons.Filled.Build
}
Icon(
imageVector = icon,
contentDescription = tool.name,
modifier = Modifier.size(13.dp),
tint =
if (tool.isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
},
)
val label =
if (!tool.summary.isNullOrBlank()) {
"${tool.name}: ${tool.summary}"
} else {
tool.name
}
val isLabelRtl = remember(label) { BidiUtils.isRtlText(label) }
Text(
text = label,
style =
MaterialTheme.typography.labelSmall.copy(
textDirection = if (isLabelRtl) TextDirection.Rtl else TextDirection.Ltr,
),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (tool.isRunning) {
CircularProgressIndicator(
modifier = Modifier.size(10.dp),
strokeWidth = 1.5.dp,
color = MaterialTheme.colorScheme.primary,
)
} else {
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
AnimatedVisibility(
visible = expanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(top = 6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (!tool.command.isNullOrBlank()) {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth(),
) {
SelectionContainer {
Text(
text = if (isTerminal) "$ ${tool.command}" else tool.command,
style =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
textDirection = TextDirection.Ltr,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
)
}
}
}
val outputText =
when {
tool.isRunning -> stringResource(R.string.group_chat_tool_running)
!tool.output.isNullOrBlank() -> tool.output.trim()
tool.exitCode == 0 -> stringResource(R.string.group_chat_tool_no_output_success)
else -> stringResource(R.string.group_chat_tool_no_output)
}
val isOutputRtl = remember(outputText) { BidiUtils.isRtlText(outputText) }
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.6f),
modifier = Modifier.fillMaxWidth(),
) {
SelectionContainer {
Text(
text = outputText,
style =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
textDirection =
if (isOutputRtl) {
TextDirection.Rtl
} else {
TextDirection.Ltr
},
),
color =
if (tool.isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 15,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
)
}
}
}
}
}
}
}
@@ -0,0 +1,372 @@
package com.m57.hermescontrol.ui.bots.group.components
import android.content.ClipData
import android.text.format.DateFormat
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.bots.group.CAPPED_SYSTEM_TEXT
import com.m57.hermescontrol.ui.bots.group.GroupChatMessage
import com.m57.hermescontrol.ui.bots.group.STOPPED_SYSTEM_TEXT
import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.chat.TokenEstimator
import com.m57.hermescontrol.ui.chat.formatTimestamp
import com.m57.hermescontrol.ui.common.BotAvatar
import com.m57.hermescontrol.util.BidiUtils
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun GroupMessageCard(
message: GroupChatMessage,
modifier: Modifier = Modifier,
) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
var copied by remember { mutableStateOf(false) }
LaunchedEffect(copied) {
if (copied) {
delay(1500)
copied = false
}
}
if (message.isSystem) {
val displayText =
when {
message.isPass -> {
val name = message.senderDisplayName.ifBlank { message.senderName }
stringResource(R.string.group_chat_bot_passed, name)
}
message.text == STOPPED_SYSTEM_TEXT -> {
stringResource(R.string.group_chat_stopped_by_user)
}
message.text == CAPPED_SYSTEM_TEXT -> {
stringResource(R.string.group_chat_capped_limit)
}
else -> {
message.text
}
}
val isSystemRtl = remember(displayText) { BidiUtils.isRtlText(displayText) }
val systemDirection = if (isSystemRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
Box(
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (message.isPass) 0.5f else 0.6f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
) {
CompositionLocalProvider(LocalLayoutDirection provides systemDirection) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (message.isPass) {
Icon(
imageVector = Icons.Filled.FastForward,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
Text(
text = displayText,
style =
MaterialTheme.typography.labelSmall.copy(
textDirection =
if (isSystemRtl) {
TextDirection.Rtl
} else {
TextDirection.Ltr
},
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
} else if (message.isUser) {
val isUserRtl = remember(message.text) { BidiUtils.isRtlText(message.text) }
val userBubbleDirection = if (isUserRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth(0.85f),
) {
CompositionLocalProvider(LocalLayoutDirection provides userBubbleDirection) {
Column(modifier = Modifier.padding(12.dp)) {
SelectionContainer {
Text(
text = message.text,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection =
if (isUserRtl) {
TextDirection.Rtl
} else {
TextDirection.Ltr
},
),
color = MaterialTheme.colorScheme.onPrimary,
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.align(Alignment.End),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(null, message.text)),
)
}
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 = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
)
}
Spacer(modifier = Modifier.width(2.dp))
Text(
text =
formatTimestamp(
message.timestamp,
DateFormat.is24HourFormat(LocalContext.current),
),
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelSmall,
)
val userTokens =
message.tokenCount ?: TokenEstimator.estimate(message.text).takeIf { it > 0 }
if (userTokens != null && userTokens > 0) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "•",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text =
stringResource(
R.string.chat_msg_tokens,
TokenEstimator.formatTokenCount(userTokens),
),
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
}
}
} else {
val isBotNameRtl = remember(message.senderDisplayName) { BidiUtils.isRtlText(message.senderDisplayName) }
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
) {
BotAvatar(
name = message.senderName,
avatar = message.avatarMeta,
size = 32.dp,
showPresence = false,
)
Spacer(modifier = Modifier.width(8.dp))
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.weight(1f, fill = false),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = message.senderDisplayName,
style =
MaterialTheme.typography.labelMedium.copy(
textDirection =
if (isBotNameRtl) {
TextDirection.Rtl
} else {
TextDirection.Ltr
},
),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
if (message.toolCalls.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(bottom = if (message.text.isNotBlank()) 4.dp else 0.dp),
) {
message.toolCalls.forEach { tool ->
GroupChatToolChip(tool = tool)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
if (message.isStreaming && message.text.isBlank() && message.toolCalls.none { it.isRunning }) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.5.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = stringResource(R.string.group_chat_typing),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
} else if (message.text.isNotBlank()) {
SelectionContainer {
MarkdownText(
text = message.text,
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (!message.isStreaming && message.text.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.align(Alignment.End),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(null, message.text)),
)
}
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 = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
Spacer(modifier = Modifier.width(2.dp))
Text(
text =
formatTimestamp(
message.timestamp,
DateFormat.is24HourFormat(LocalContext.current),
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelSmall,
)
val botTokens =
message.tokenCount ?: TokenEstimator.estimate(message.text).takeIf { it > 0 }
if (botTokens != null && botTokens > 0) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "•",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text =
stringResource(
R.string.chat_msg_tokens,
TokenEstimator.formatTokenCount(botTokens),
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelSmall,
)
}
if (message.tps != null && message.tps > 0.0) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "•",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text =
stringResource(
R.string.chat_msg_tps,
TokenEstimator.formatTps(message.tps),
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,196 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* Owns approval rendering, deduplication, user response submission, and
* reconnect/resume pending approval replay.
*
* Extracted behavior-preservingly from [ChatViewModel].
*/
class ChatApprovalsDelegate(
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher,
private val uiState: MutableStateFlow<ChatUiState>,
private val runtimeSessionId: () -> String?,
private val wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit,
private val trackRequest: (id: String, method: String) -> Unit,
private val addSystemMessage: (text: String) -> Unit,
) {
fun handleApprovalRequest(event: WsEvent.ApprovalRequest) {
val description = event.description ?: event.command ?: "Unknown command"
val content = "**Approval Required**\n$description"
val msg =
ChatMessage(
role = MessageRole.SYSTEM,
content = content,
approvalInfo =
ApprovalInfo(
command = event.command,
description = event.description,
patternKeys = event.patternKeys,
requestId = event.requestId,
choices = event.choices,
allowPermanent = event.allowPermanent,
smartDenied = event.smartDenied,
),
)
uiState.update { state ->
state.copy(
messages = state.messages + msg,
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) {
scope.launch(ioDispatcher) {
runCatching {
wsSend(
WsMethods.APPROVAL_RECEIVED,
mapOf(
"session_id" to sessionId,
"request_id" to requestId,
),
null,
)
}
}
}
}
fun replayPendingApproval(sessionId: String) {
val targetSessionId = runtimeSessionId() ?: sessionId
scope.launch(ioDispatcher) {
wsSend(
WsMethods.APPROVAL_PENDING,
mapOf("session_id" to targetSessionId),
) { id -> trackRequest(id, WsMethods.APPROVAL_PENDING) }
}
}
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
val alreadyShown =
uiState.value.messages.any { it.approvalInfo?.requestId == requestId }
if (alreadyShown) return
handleApprovalRequest(parseApprovalMap(first, sessionId))
}
fun handleApprovalRespondResult(result: Any?) {
val map = result as? Map<*, *>
val resolved = (map?.get("resolved") as? Number)?.toInt() ?: 0
if (resolved > 0) {
addSystemMessage("Approval submitted")
}
}
fun maybeSurfacePendingApproval(
map: Map<*, *>,
sessionId: String?,
) {
val requestId = map["request_id"] as? String
val alreadyShown =
requestId != null &&
uiState.value.messages.any { it.approvalInfo?.requestId == requestId }
if (!alreadyShown) {
handleApprovalRequest(parseApprovalMap(map, sessionId))
}
}
fun respondToApproval(action: String) {
val state = uiState.value
val approvalMsg = state.messages.lastOrNull { it.approvalInfo != null } ?: 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 ->
s.copy(
messages =
s.messages.map {
if (it.id == approvalMsg.id) {
it.copy(approvalInfo = null)
} else {
it
}
},
)
}
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"choice" to choice,
"all" to false,
)
if (requestId != null) params["request_id"] = requestId
wsSend(
WsMethods.APPROVAL_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) }
// The queue can hold more pendings — surface the next one.
replayPendingApproval(sessionId)
}
}
}
/**
* 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.
*/
internal 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,
)
}
@@ -257,6 +257,44 @@ fun ChatBubble(
color = userBubbleTextColor.copy(alpha = 0.6f),
style = MaterialTheme.typography.labelSmall,
)
if (message.tokenCount != null && message.tokenCount > 0) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "•",
style = MaterialTheme.typography.labelSmall,
color = userBubbleTextColor.copy(alpha = 0.4f),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text =
stringResource(
R.string.chat_msg_tokens,
TokenEstimator.formatTokenCount(message.tokenCount),
),
color = userBubbleTextColor.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.testTag("bubble_token_count"),
)
}
if (message.tps != null && message.tps > 0.0) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "•",
style = MaterialTheme.typography.labelSmall,
color = userBubbleTextColor.copy(alpha = 0.4f),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text =
stringResource(
R.string.chat_msg_tps,
TokenEstimator.formatTps(message.tps),
),
color = userBubbleTextColor.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.testTag("bubble_tps"),
)
}
}
}
}
@@ -0,0 +1,123 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ChatClarifyDelegate(
private val uiState: MutableStateFlow<ChatUiState>,
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher,
private val persistMessage: suspend (ChatMessage, String) -> Unit,
private val wsClient: HermesWsClient = HermesWsClient,
private val trackRequest: (String, String) -> Unit = { _, _ -> },
) {
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 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 = displayContent,
)
uiState.update { state ->
state.copy(
messages = state.messages + userMessage,
isAgentTyping = true,
)
}
scope.launch(ioDispatcher) {
persistMessage(userMessage, sessionId)
}
scope.launch(ioDispatcher) {
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) },
)
}
}
}
}
@@ -0,0 +1,942 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.ConnectorConnectResult
import com.m57.hermescontrol.data.model.ConnectorConnectStatus
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorItem
import com.m57.hermescontrol.data.model.ConnectorListResult
import com.m57.hermescontrol.data.model.ConnectorStatus
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.ConnectorRepository
import com.m57.hermescontrol.util.ConnectorUrlValidator
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Explicit load phases for connector list rendering.
*/
sealed interface ConnectorsLoadPhase {
data object Initial : ConnectorsLoadPhase
data object Loading : ConnectorsLoadPhase
data class Refreshing(
val previousItems: List<ConnectorUiItem>,
) : ConnectorsLoadPhase
data class Loaded(
val available: Boolean,
) : ConnectorsLoadPhase
data class Error(
val error: ConnectorError,
val canRetry: Boolean = true,
) : ConnectorsLoadPhase
data class Unavailable(
val message: String,
) : ConnectorsLoadPhase
data class UnsupportedRuntime(
val message: String,
) : ConnectorsLoadPhase
data class UnsupportedBackend(
val message: String,
) : ConnectorsLoadPhase
data class NotOwner(
val message: String,
) : ConnectorsLoadPhase
}
/**
* Mutating action phases for individual connector operations.
*/
sealed interface ConnectorActionState {
data object Idle : ConnectorActionState
data class Connecting(
val slug: String,
val isReconnect: Boolean,
) : ConnectorActionState
data class AwaitingAuthorization(
val slug: String,
) : ConnectorActionState
}
/**
* Immutable safe UI item representation. Raw connect URLs are strictly omitted.
*/
data class ConnectorUiItem(
val slug: String,
val name: String,
val isConnected: Boolean,
val isEnabled: Boolean,
val status: ConnectorStatus,
val connectionStatus: String?,
) {
companion object {
fun from(item: ConnectorItem): ConnectorUiItem =
ConnectorUiItem(
slug = item.connector,
name = item.name,
isConnected = item.connected,
isEnabled = item.enabled,
status = item.status,
connectionStatus = item.connectionStatus,
)
}
}
/**
* Transient one-shot browser launch event. Redacts URL in [toString].
*/
data class ConnectBrowserEvent(
val slug: String,
val url: String,
val eventId: Long = System.nanoTime(),
val sessionId: String? = null,
val generation: Long = 0L,
) {
override fun toString(): String =
"ConnectBrowserEvent(slug=$slug, url=[REDACTED], eventId=$eventId, sessionId=$sessionId, generation=$generation)"
}
/**
* Complete UI state exposed to the session integrations view.
*/
data class ChatConnectorsUiState(
val isVisible: Boolean = false,
val loadPhase: ConnectorsLoadPhase = ConnectorsLoadPhase.Initial,
val items: List<ConnectorUiItem> = emptyList(),
val actionState: ConnectorActionState = ConnectorActionState.Idle,
val browserLaunchEvent: ConnectBrowserEvent? = null,
val errorMessage: String? = null,
val isOldBackend: Boolean = false,
val sessionId: String? = null,
val generation: Long = 0L,
) {
val isLoading: Boolean
get() = loadPhase is ConnectorsLoadPhase.Loading || actionState is ConnectorActionState.Connecting
}
/**
* Controller / Delegate managing session Integrations (connectors) state,
* lifecycle, deduplication, and authorization flows (issue #1091).
*/
class ChatConnectorsDelegate(
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val repository: ConnectorRepository = ConnectorRepository,
private val runtimeSessionId: () -> String? = { ActiveSessionHolder.activeSessionId.value },
) {
private val _uiState = MutableStateFlow(ChatConnectorsUiState())
val uiState: StateFlow<ChatConnectorsUiState> = _uiState.asStateFlow()
private var isForeground: Boolean = false
private var sessionGeneration: Long = 0L
private var refreshRequestId: Long = 0L
private var refreshJob: Job? = null
private var connectJob: Job? = null
private var pendingRefreshAfterMutation: Boolean = false
private var pendingTrailingRefresh: Boolean = false
private var pendingBrowserEvent: ConnectBrowserEvent? = null
private var browserLaunched: Boolean = false
/**
* Show integrations panel and trigger load if in foreground.
*/
fun show() {
if (_uiState.value.isVisible) return
val sid = runtimeSessionId()
_uiState.update {
it.copy(
isVisible = true,
sessionId = sid,
generation = sessionGeneration,
)
}
if (isForeground) {
if (!sid.isNullOrBlank() && !_uiState.value.isOldBackend) {
refresh(force = false)
}
}
}
/**
* Hide integrations panel, cancelling ongoing jobs, incrementing generation,
* and clearing transient state.
*/
fun hide() {
sessionGeneration++
refreshJob?.cancel()
refreshJob = null
connectJob?.cancel()
connectJob = null
pendingRefreshAfterMutation = false
pendingTrailingRefresh = false
pendingBrowserEvent = null
browserLaunched = false
_uiState.update {
it.copy(
isVisible = false,
actionState = ConnectorActionState.Idle,
browserLaunchEvent = null,
generation = sessionGeneration,
)
}
}
/**
* Lifecycle resume hook. Refreshes once when returning from external browser authorization.
* Guarded to only execute on true paused -> resume transitions.
*/
fun onResume() {
val wasForeground = isForeground
isForeground = true
if (wasForeground) return
if (!_uiState.value.isVisible) return
val sid = runtimeSessionId()
val pending = pendingBrowserEvent
if (pending != null) {
pendingBrowserEvent = null
if (pending.generation == sessionGeneration &&
pending.sessionId == sid &&
_uiState.value.actionState is ConnectorActionState.AwaitingAuthorization
) {
_uiState.update { it.copy(browserLaunchEvent = pending) }
}
}
if (pendingRefreshAfterMutation || pendingTrailingRefresh) {
pendingRefreshAfterMutation = false
pendingTrailingRefresh = false
refresh(force = true)
return
}
val currentAction = _uiState.value.actionState
if (currentAction is ConnectorActionState.AwaitingAuthorization) {
if (browserLaunched) {
// Actual return from external browser authorization: refresh once to confirm authorization
browserLaunched = false
refresh(force = true)
return
}
// Minted unconsumed event waiting to be launched: do NOT prematurely refresh
return
}
// Returned from background: initial load or reload interrupted read if needed and not old backend
val currentPhase = _uiState.value.loadPhase
val needsCatalogLoad =
(
currentPhase is ConnectorsLoadPhase.Initial ||
currentPhase is ConnectorsLoadPhase.Loading ||
currentPhase is ConnectorsLoadPhase.Refreshing
) &&
refreshJob?.isActive != true &&
!_uiState.value.isOldBackend
if (needsCatalogLoad) {
refresh(force = false)
}
}
/**
* Lifecycle pause hook. Retains unconsumed browser event bound to session + generation.
*/
fun onPause() {
isForeground = false
val unconsumed = _uiState.value.browserLaunchEvent ?: pendingBrowserEvent
if (unconsumed != null &&
unconsumed.generation == sessionGeneration &&
unconsumed.sessionId == runtimeSessionId()
) {
pendingBrowserEvent = unconsumed
}
_uiState.update { it.copy(browserLaunchEvent = null) }
}
/**
* Notify of active session change with generation guard (A -> B -> A).
*/
fun onActiveSessionChanged(newSessionId: String?) {
sessionGeneration++
val currentGen = sessionGeneration
refreshJob?.cancel()
refreshJob = null
connectJob?.cancel()
connectJob = null
pendingRefreshAfterMutation = false
pendingTrailingRefresh = false
pendingBrowserEvent = null
browserLaunched = false
if (newSessionId.isNullOrBlank()) {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Initial,
items = emptyList(),
actionState = ConnectorActionState.Idle,
browserLaunchEvent = null,
errorMessage = null,
isOldBackend = false,
sessionId = null,
generation = currentGen,
)
}
return
}
if (_uiState.value.isVisible && isForeground) {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Loading,
items = emptyList(),
actionState = ConnectorActionState.Idle,
browserLaunchEvent = null,
errorMessage = null,
isOldBackend = false,
sessionId = newSessionId,
generation = currentGen,
)
}
executeRefresh(newSessionId, currentGen)
} else {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Initial,
items = emptyList(),
actionState = ConnectorActionState.Idle,
browserLaunchEvent = null,
errorMessage = null,
isOldBackend = false,
sessionId = newSessionId,
generation = currentGen,
)
}
}
}
/**
* Transport reconnected notification. Refreshes if visible, foreground, and backend supports connectors.
*/
fun onTransportReconnected() {
if (_uiState.value.isVisible && isForeground && !_uiState.value.isOldBackend) {
val sessionId = runtimeSessionId()
if (!sessionId.isNullOrBlank()) {
refresh(force = true)
}
}
}
/**
* Request connector catalog and authorization status.
* Deduplicated and coalesced for both normal and forced calls, and blocked in background.
*/
fun refresh(force: Boolean = false) {
if (!_uiState.value.isVisible || !isForeground) return
if (_uiState.value.isOldBackend && !force) return
val sessionId = runtimeSessionId()
if (sessionId.isNullOrBlank()) return
if (_uiState.value.actionState is ConnectorActionState.Connecting) {
pendingRefreshAfterMutation = true
return
}
if (refreshJob?.isActive == true) {
if (force) {
pendingTrailingRefresh = true
}
return
}
val gen = sessionGeneration
val previousItems = _uiState.value.items
_uiState.update { current ->
if (previousItems.isEmpty()) {
current.copy(
loadPhase = ConnectorsLoadPhase.Loading,
sessionId = sessionId,
generation = gen,
)
} else {
current.copy(
loadPhase = ConnectorsLoadPhase.Refreshing(previousItems),
sessionId = sessionId,
generation = gen,
)
}
}
executeRefresh(sessionId, gen)
}
private fun executeRefresh(
targetSessionId: String,
generation: Long,
) {
val requestId = ++refreshRequestId
refreshJob =
scope.launch {
var currentSession = targetSessionId
try {
var result = withContext(ioDispatcher) { repository.listConnectors(currentSession) }
// Bounded NOT_OWNER reconciliation: re-read active session once (READ ONLY)
if (result is ConnectorListResult.Error && result.error is ConnectorError.NotOwner) {
val freshSession = runtimeSessionId()
if (!freshSession.isNullOrBlank() && freshSession != currentSession) {
currentSession = freshSession
result = withContext(ioDispatcher) { repository.listConnectors(currentSession) }
}
}
// Check target sid AND generation AND request token AND visibility before applying
val activeSid = runtimeSessionId()
if (requestId != refreshRequestId ||
generation != sessionGeneration ||
currentSession != activeSid ||
!_uiState.value.isVisible
) {
return@launch
}
when (result) {
is ConnectorListResult.Success -> {
if (!result.available) {
pendingRefreshAfterMutation = false
pendingTrailingRefresh = false
pendingBrowserEvent = null
_uiState.update { current ->
current.copy(
loadPhase =
ConnectorsLoadPhase.Unavailable(
"Connectors are unavailable for this session.",
),
items = emptyList(),
actionState = ConnectorActionState.Idle,
browserLaunchEvent = null,
errorMessage = null,
sessionId = currentSession,
generation = generation,
)
}
return@launch
}
val uiItems = result.connectors.map { ConnectorUiItem.from(it) }
_uiState.update { current ->
val currentAction = current.actionState
val updatedAction =
if (currentAction is ConnectorActionState.AwaitingAuthorization) {
val matchingItem = uiItems.find { it.slug == currentAction.slug }
// End authorization if connector removed from catalog or now connected
if (matchingItem == null || matchingItem.isConnected) {
ConnectorActionState.Idle
} else {
currentAction
}
} else {
currentAction
}
current.copy(
loadPhase = ConnectorsLoadPhase.Loaded(result.available),
items = uiItems,
actionState = updatedAction,
isOldBackend = false,
errorMessage = null, // error clears on refresh success
sessionId = currentSession,
generation = generation,
)
}
}
is ConnectorListResult.Error -> {
when (val err = result.error) {
is ConnectorError.UnsupportedBackend -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.UnsupportedBackend(err.message),
isOldBackend = true,
items = emptyList(),
actionState = ConnectorActionState.Idle,
sessionId = currentSession,
generation = generation,
)
}
}
is ConnectorError.Unavailable -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Unavailable(err.message),
items = emptyList(),
actionState = ConnectorActionState.Idle,
sessionId = currentSession,
generation = generation,
)
}
}
is ConnectorError.UnsupportedRuntime -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.UnsupportedRuntime(err.message),
items = emptyList(),
actionState = ConnectorActionState.Idle,
sessionId = currentSession,
generation = generation,
)
}
}
is ConnectorError.NotOwner -> {
val notOwnerMsg =
"Session is not owned by this transport. Please reconnect the session."
_uiState.update {
it.copy(
loadPhase =
ConnectorsLoadPhase.NotOwner(
notOwnerMsg,
),
items = emptyList(),
actionState = ConnectorActionState.Idle,
sessionId = currentSession,
generation = generation,
)
}
}
else -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Error(err, canRetry = true),
sessionId = currentSession,
generation = generation,
)
}
}
}
}
}
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
_uiState.update {
it.copy(
loadPhase =
ConnectorsLoadPhase.Error(
ConnectorError.NetworkError(t.message ?: "Failed to load connectors"),
canRetry = true,
),
sessionId = currentSession,
generation = generation,
)
}
} finally {
refreshJob = null
if (pendingTrailingRefresh &&
generation == sessionGeneration &&
_uiState.value.isVisible
) {
if (isForeground) {
pendingTrailingRefresh = false
refresh(force = true)
}
} else {
pendingTrailingRefresh = false
}
}
}
}
/**
* Initiate authorization flow for connector [slug].
* Guarded by visibility, foreground state, catalog readiness, and item enabled status.
* Never replays connect mutations into a different session.
*/
fun connect(
slug: String,
reconnect: Boolean = false,
) {
if (!_uiState.value.isVisible || !isForeground) return
if (_uiState.value.isOldBackend || _uiState.value.loadPhase is ConnectorsLoadPhase.UnsupportedBackend) {
_uiState.update { it.copy(errorMessage = "Connectors are not supported by this backend.") }
return
}
val currentPhase = _uiState.value.loadPhase
if (currentPhase !is ConnectorsLoadPhase.Loaded && currentPhase !is ConnectorsLoadPhase.Refreshing) {
_uiState.update { it.copy(errorMessage = "Cannot connect: connectors catalog is not ready.") }
return
}
val targetSession = runtimeSessionId()
if (targetSession.isNullOrBlank()) {
_uiState.update { it.copy(errorMessage = "No active session.") }
return
}
if (!ConnectorRepository.isValidSlug(slug)) {
_uiState.update { it.copy(errorMessage = "Invalid connector slug.") }
return
}
val catalogItem = _uiState.value.items.firstOrNull { it.slug == slug }
if (catalogItem == null) {
_uiState.update { it.copy(errorMessage = "Connector '$slug' not found in catalog.") }
return
}
if (!catalogItem.isEnabled) {
_uiState.update { it.copy(errorMessage = "Connector '$slug' is disabled.") }
return
}
if (_uiState.value.actionState is ConnectorActionState.Connecting) {
return
}
// Cancel any prior in-flight read before starting mutation
refreshJob?.cancel()
refreshJob = null
pendingTrailingRefresh = false
val gen = sessionGeneration
_uiState.update {
it.copy(
actionState = ConnectorActionState.Connecting(slug, reconnect),
errorMessage = null,
sessionId = targetSession,
generation = gen,
)
}
connectJob =
scope.launch {
var needsRefresh = false
try {
val result =
withContext(ioDispatcher) {
repository.connect(targetSession, listOf(slug), reconnect)
}
// Verify generation, session equality, and visibility before applying
val activeSid = runtimeSessionId()
if (gen != sessionGeneration ||
targetSession != activeSid ||
!_uiState.value.isVisible
) {
return@launch
}
when (result) {
is ConnectorConnectResult.Success -> {
// Require exact slug match; never fallback to unrelated connector
val item = result.results.firstOrNull { it.connector == slug }
if (item == null) {
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = "Connector '$slug' not found in response.",
)
}
return@launch
}
when (item.status) {
ConnectorConnectStatus.ACTIVE -> {
_uiState.update { it.copy(actionState = ConnectorActionState.Idle) }
needsRefresh = true
}
ConnectorConnectStatus.INITIATED -> {
val safeUrl = item.safeConnectUrl
if (safeUrl != null && ConnectorUrlValidator.isValidHttpsUrl(safeUrl)) {
val event =
ConnectBrowserEvent(
slug = slug,
url = safeUrl,
sessionId = targetSession,
generation = gen,
)
browserLaunched = false
if (isForeground) {
_uiState.update {
it.copy(
actionState = ConnectorActionState.AwaitingAuthorization(slug),
browserLaunchEvent = event,
)
}
} else {
// In background: hold transiently for foreground resume
pendingBrowserEvent = event
_uiState.update {
it.copy(
actionState = ConnectorActionState.AwaitingAuthorization(slug),
browserLaunchEvent = null,
)
}
}
} else {
// Unsafe or absent URL is a retryable generic failure
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = "Invalid or unsafe authorization URL received.",
)
}
}
}
ConnectorConnectStatus.FAILED -> {
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = "Connector authorization failed. Please try again.",
)
}
}
else -> {
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = "Connector operation returned unexpected status.",
)
}
}
}
}
is ConnectorConnectResult.Error -> {
when (val err = result.error) {
is ConnectorError.UnsupportedBackend -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.UnsupportedBackend(err.message),
actionState = ConnectorActionState.Idle,
isOldBackend = true,
items = emptyList(),
errorMessage = err.message,
)
}
}
is ConnectorError.Unavailable -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Unavailable(err.message),
actionState = ConnectorActionState.Idle,
items = emptyList(),
errorMessage = err.message,
)
}
}
is ConnectorError.UnsupportedRuntime -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.UnsupportedRuntime(err.message),
actionState = ConnectorActionState.Idle,
items = emptyList(),
errorMessage = err.message,
)
}
}
is ConnectorError.NotOwner -> {
val msg = "Session is not owned by this transport. Please reconnect the session."
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.NotOwner(msg),
actionState = ConnectorActionState.Idle,
errorMessage = msg,
)
}
}
else -> {
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = err.message,
)
}
}
}
}
}
} catch (e: CancellationException) {
_uiState.update {
if (it.actionState is ConnectorActionState.Connecting) {
it.copy(actionState = ConnectorActionState.Idle)
} else {
it
}
}
throw e
} catch (t: Throwable) {
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = t.message ?: "Connector connection failed.",
)
}
} finally {
connectJob = null
_uiState.update {
if (it.actionState is ConnectorActionState.Connecting) {
it.copy(actionState = ConnectorActionState.Idle)
} else {
it
}
}
val shouldRefresh = needsRefresh || pendingRefreshAfterMutation
if (shouldRefresh &&
gen == sessionGeneration &&
_uiState.value.isVisible
) {
if (isForeground) {
pendingRefreshAfterMutation = false
refresh(force = true)
} else {
pendingRefreshAfterMutation = true
}
} else if (gen != sessionGeneration || !_uiState.value.isVisible) {
pendingRefreshAfterMutation = false
}
}
}
}
/**
* Consume transient browser launch event (legacy no-arg API).
*/
fun consumeBrowserEvent() {
val currentSid = runtimeSessionId()
val event = _uiState.value.browserLaunchEvent ?: pendingBrowserEvent
if (event != null &&
isForeground &&
_uiState.value.isVisible &&
event.generation == sessionGeneration &&
event.sessionId == currentSid
) {
browserLaunched = true
}
pendingBrowserEvent = null
_uiState.update { it.copy(browserLaunchEvent = null) }
}
/**
* Consume transient browser launch event validating identity, session, generation, and foreground state.
*/
fun consumeBrowserEvent(eventId: Long): ConnectBrowserEvent? {
var consumed: ConnectBrowserEvent? = null
val currentSid = runtimeSessionId()
val pending = pendingBrowserEvent
if (pending != null && pending.eventId == eventId) {
if (isForeground &&
_uiState.value.isVisible &&
pending.generation == sessionGeneration &&
pending.sessionId == currentSid
) {
consumed = pending
pendingBrowserEvent = null
} else {
pendingBrowserEvent = null
}
}
_uiState.update { current ->
val event = current.browserLaunchEvent
if (event != null && event.eventId == eventId) {
if (isForeground &&
current.isVisible &&
event.generation == sessionGeneration &&
event.sessionId == currentSid
) {
consumed = event
current.copy(browserLaunchEvent = null)
} else {
// Invalid context: clear stale event and reject handoff
current.copy(browserLaunchEvent = null)
}
} else {
current
}
}
if (consumed != null) {
browserLaunched = true
}
return consumed
}
/**
* Safely take transient browser launch event if valid.
*/
fun takeBrowserEvent(eventId: Long? = null): ConnectBrowserEvent? {
val targetId =
eventId
?: _uiState.value.browserLaunchEvent?.eventId
?: pendingBrowserEvent?.eventId
?: return null
return consumeBrowserEvent(targetId)
}
/**
* Report an error launching the external browser intent.
*/
fun launchError(message: String) {
pendingBrowserEvent = null
browserLaunched = false
_uiState.update {
it.copy(
browserLaunchEvent = null,
actionState = ConnectorActionState.Idle,
errorMessage = message,
)
}
}
fun onLaunchError(message: String) = launchError(message)
/**
* Clear active error message.
*/
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
/**
* Teardown ongoing coroutines and invalidate state generation.
*/
fun destroy() {
sessionGeneration++
refreshJob?.cancel()
refreshJob = null
connectJob?.cancel()
connectJob = null
pendingRefreshAfterMutation = false
pendingTrailingRefresh = false
pendingBrowserEvent = null
browserLaunched = false
}
}
@@ -0,0 +1,84 @@
package com.m57.hermescontrol.ui.chat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
/**
* Scoped ViewModel managing session connectors / integrations lifecycle (issue #1091).
*
* Hosts [ChatConnectorsDelegate] across configuration changes, wires [ActiveSessionHolder]
* and server/profile context transitions to invalidate context on changes, and forwards
* lifecycle and browser launch events safely.
*/
class ChatConnectorsViewModel(
delegate: ChatConnectorsDelegate? = null,
) : ViewModel() {
private val delegateInstance: ChatConnectorsDelegate =
delegate ?: ChatConnectorsDelegate(
scope = viewModelScope,
runtimeSessionId = { ActiveSessionHolder.activeSessionId.value },
)
val uiState: StateFlow<ChatConnectorsUiState> = delegateInstance.uiState
init {
var lastContext: Triple<String?, String?, String?>? = null
viewModelScope.launch {
combine(
ActiveSessionHolder.activeSessionId,
AuthManager.activeProfileId,
AuthManager.baseUrlFlow,
) { sessionId, profileId, baseUrl ->
Triple(sessionId, profileId, baseUrl)
}.collect { current ->
if (lastContext == null || current != lastContext) {
lastContext = current
delegateInstance.onActiveSessionChanged(current.first)
}
}
}
viewModelScope.launch {
HermesWsClient.connectionStatus
.filter { it == ConnectionStatus.CONNECTED }
.collect {
delegateInstance.onTransportReconnected()
}
}
}
fun show() = delegateInstance.show()
fun hide() = delegateInstance.hide()
fun onResume() = delegateInstance.onResume()
fun onPause() = delegateInstance.onPause()
fun refresh(force: Boolean = false) = delegateInstance.refresh(force)
fun connect(
slug: String,
reconnect: Boolean = false,
) = delegateInstance.connect(slug, reconnect)
fun takeBrowserEvent(eventId: Long? = null): ConnectBrowserEvent? = delegateInstance.takeBrowserEvent(eventId)
fun launchError(message: String) = delegateInstance.launchError(message)
fun clearError() = delegateInstance.clearError()
override fun onCleared() {
super.onCleared()
delegateInstance.destroy()
}
}
@@ -0,0 +1,412 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* Owns sudo and secret prompt state, timeouts, dismissal, and response RPCs.
*
* Extracted behavior-preservingly from [ChatViewModel] (issue #524).
*/
class ChatCredentialPromptsDelegate(
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher,
private val uiState: MutableStateFlow<ChatUiState>,
private val wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit,
private val trackRequest: (id: String, method: String) -> Unit,
) {
/**
* The agent needs the user's sudo password. Surface a secure dialog and
* reply via sudo.respond.
*/
fun handleSudoRequest(event: WsEvent.SudoRequest) {
uiState.update {
it.copy(
sudoPrompt = SudoPromptUi(event.requestId, event.sessionId),
isAgentTyping = false,
)
}
}
/**
* Backend sudo timeout (120s) — clear only the matching dialog so a late
* expire for an old prompt never kills the current one.
*/
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). Surface a secure dialog
* and reply via secret.respond.
*/
fun handleSecretRequest(event: WsEvent.SecretRequest) {
uiState.update {
it.copy(
secretPrompt =
SecretPromptUi(
event.requestId,
event.sessionId,
event.envVar,
event.prompt,
),
isAgentTyping = false,
)
}
}
/**
* Backend secret timeout — match-only clear like [handleSudoExpire].
*/
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). Backend treats empty sudo as
* failed sudo (no command runs), unblocking the turn instantly.
*/
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
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"password" to "",
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.SUDO_RESPOND,
params,
) { 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
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"value" to "",
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.SECRET_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }
}
}
/**
* Send the user's sudo password back to the gateway. Clear prompt immediately,
* then fire the RPC.
*/
fun respondToSudo(password: String) {
val prompt = uiState.value.sudoPrompt ?: return
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
if (password.isBlank()) return
uiState.update { it.copy(sudoPrompt = null) }
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"password" to password,
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.SUDO_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) }
}
}
/**
* Send the user's secret value back to the gateway.
*/
fun respondToSecret(value: String) {
val prompt = uiState.value.secretPrompt ?: return
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
if (value.isBlank()) return
uiState.update { it.copy(secretPrompt = null) }
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"value" to value,
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.SECRET_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }
}
}
// ── Vault prompts (issue #1090) ───────────────────────────────────────
/**
* The agent needs to unlock a password manager (1Password, Bitwarden).
* Surface an interactive prompt card in chat and reply via vault.unlock.respond.
*/
fun handleVaultUnlockRequest(event: WsEvent.VaultUnlockRequest) {
uiState.update {
it.copy(
vaultUnlockPrompt =
VaultUnlockPromptUi(
requestId = event.requestId,
sessionId = event.sessionId,
backend = event.backend,
displayName = event.displayName,
),
isAgentTyping = false,
)
}
}
fun handleVaultUnlockExpire(event: WsEvent.VaultUnlockExpire) {
uiState.update { state ->
val current = state.vaultUnlockPrompt ?: return@update state
if (event.requestId != null && current.requestId != null &&
event.requestId != current.requestId
) {
return@update state
}
state.copy(vaultUnlockPrompt = null)
}
}
/**
* Cancel/dismiss → send empty password (keeps manager locked, unblocks turn).
*/
fun dismissVaultUnlock() {
val prompt = uiState.value.vaultUnlockPrompt ?: return
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId
uiState.update { it.copy(vaultUnlockPrompt = null) }
if (sessionId == null) return
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"password" to "",
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.VAULT_UNLOCK_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) }
}
}
fun respondToVaultUnlock(password: String) {
val prompt = uiState.value.vaultUnlockPrompt ?: return
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
if (password.isBlank()) return
uiState.update { it.copy(vaultUnlockPrompt = null) }
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"password" to password,
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.VAULT_UNLOCK_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) }
}
}
/**
* The agent captured credentials on a login page and asks whether to store them in the vault.
*/
fun handleVaultSaveLoginRequest(event: WsEvent.VaultSaveLoginRequest) {
uiState.update {
it.copy(
vaultSaveLoginPrompt =
VaultSaveLoginPromptUi(
requestId = event.requestId,
sessionId = event.sessionId,
origin = event.origin,
site = event.site,
),
isAgentTyping = false,
)
}
}
fun handleVaultSaveLoginExpire(event: WsEvent.VaultSaveLoginExpire) {
uiState.update { state ->
val current = state.vaultSaveLoginPrompt ?: return@update state
if (event.requestId != null && current.requestId != null &&
event.requestId != current.requestId
) {
return@update state
}
state.copy(vaultSaveLoginPrompt = null)
}
}
/**
* Decline saving → send empty login (backend treats empty login as declined).
*/
fun dismissVaultSaveLogin() {
val prompt = uiState.value.vaultSaveLoginPrompt ?: return
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId
uiState.update { it.copy(vaultSaveLoginPrompt = null) }
if (sessionId == null) return
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"login" to "",
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.VAULT_SAVE_LOGIN_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) }
}
}
fun respondToVaultSaveLogin(
identifier: String,
password: String,
) {
val prompt = uiState.value.vaultSaveLoginPrompt ?: return
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
if (identifier.isBlank() || password.isBlank()) return
uiState.update { it.copy(vaultSaveLoginPrompt = null) }
scope.launch(ioDispatcher) {
val loginJson =
buildJsonObject {
put("identifier", identifier)
put("password", password)
}.toString()
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"login" to loginJson,
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.VAULT_SAVE_LOGIN_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) }
}
}
/**
* The site requested a 2FA/MFA verification code.
*/
fun handleVaultCodeRequest(event: WsEvent.VaultCodeRequest) {
uiState.update {
it.copy(
vaultCodePrompt =
VaultCodePromptUi(
requestId = event.requestId,
sessionId = event.sessionId,
site = event.site,
hint = event.hint,
),
isAgentTyping = false,
)
}
}
fun handleVaultCodeExpire(event: WsEvent.VaultCodeExpire) {
uiState.update { state ->
val current = state.vaultCodePrompt ?: return@update state
if (event.requestId != null && current.requestId != null &&
event.requestId != current.requestId
) {
return@update state
}
state.copy(vaultCodePrompt = null)
}
}
/**
* Skip entering code → send empty code.
*/
fun dismissVaultCode() {
val prompt = uiState.value.vaultCodePrompt ?: return
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId
uiState.update { it.copy(vaultCodePrompt = null) }
if (sessionId == null) return
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"code" to "",
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.VAULT_CODE_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) }
}
}
fun respondToVaultCode(code: String) {
val prompt = uiState.value.vaultCodePrompt ?: return
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
if (code.isBlank()) return
uiState.update { it.copy(vaultCodePrompt = null) }
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"code" to code,
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.VAULT_CODE_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) }
}
}
}
@@ -25,33 +25,66 @@ import androidx.compose.ui.unit.sp
*/
const val TOOL_CALL_DIVIDER_INTERVAL = 5
private const val MAX_ITERATIONS_SYSTEM_MARKER =
"You've reached the maximum number of tool-calling iterations allowed."
/**
* Returns the message indices that complete a tool-call milestone — a [MessageRole.TOOL]
* A genuine user message that establishes a new turn boundary.
* Timeline markers (`displayKind != null`) and synthetic system rows
* (such as max-iterations nudges) ride with `role == MessageRole.USER`
* in storage but belong to the surrounding agent turn.
*/
internal fun ChatMessage.isUserTurnBoundary(): Boolean =
role == MessageRole.USER &&
displayKind == null &&
!content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
/**
* Returns the message IDs that complete a tool-call milestone — a [MessageRole.TOOL]
* message whose per-turn tool-call count is a multiple of [TOOL_CALL_DIVIDER_INTERVAL] —
* mapped to the per-turn count at that point.
*
* The counter resets at each [MessageRole.USER] message (a new turn), so
* The counter resets at each genuine user turn boundary ([isUserTurnBoundary]), so
* milestones read `5, 10, 15...` within a turn and restart at `5` on the next.
*/
fun toolCallMilestones(messages: List<ChatMessage>): Map<Int, Int> {
val milestones = mutableMapOf<Int, Int>()
fun toolCallMilestones(messages: List<ChatMessage>): Map<String, Int> {
val milestones = mutableMapOf<String, Int>()
var toolCount = 0
messages.forEachIndexed { index, message ->
when (message.role) {
MessageRole.USER -> {
messages.forEach { message ->
when {
message.isUserTurnBoundary() -> {
toolCount = 0
}
MessageRole.TOOL -> {
message.role == MessageRole.TOOL -> {
toolCount += 1
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
milestones[message.id] = toolCount
}
}
}
}
return milestones
}
/**
* Index-based milestone map for position-based callers and backwards compatibility.
*/
fun toolCallMilestoneIndices(messages: List<ChatMessage>): Map<Int, Int> {
val milestones = mutableMapOf<Int, Int>()
var toolCount = 0
messages.forEachIndexed { index, message ->
when {
message.isUserTurnBoundary() -> {
toolCount = 0
}
message.role == MessageRole.TOOL -> {
toolCount += 1
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
milestones[index] = toolCount
}
}
else -> {
Unit
}
}
}
return milestones
@@ -79,6 +79,10 @@ data class ChatMessage(
* When present, rendered at the bottom beside the copy button.
*/
val finishTimestamp: Long? = null,
/** Token count used by this message (persisted to SQLite). */
val tokenCount: Int? = null,
/** Tokens per second generation speed (persisted to SQLite). */
val tps: Double? = null,
)
/**
@@ -124,6 +128,7 @@ data class SubagentIndicator(
val logs: List<SubagentLogLine> = emptyList(),
val durationSeconds: Double? = null,
val model: String? = null,
val lastEventTimestamp: Long = 0L,
) {
val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done"
val isFailed: Boolean get() = status == "failed" || status == "interrupted"
@@ -133,6 +138,20 @@ data class SubagentIndicator(
val isRunning: Boolean get() = !isComplete && !isFailed && !isCancelled && !isQueued
}
/**
* State for inspecting a live subagent's rolling execution transcript (issue #1089).
*/
data class SubagentTranscriptUiState(
val subagentId: String,
val text: String = "",
val isLoading: Boolean = false,
val isTruncated: Boolean = false,
val bytesRead: Long? = null,
val error: String? = null,
) {
val isEmpty: Boolean get() = text.isEmpty() && !isLoading && error == null
}
enum class MessageRole {
USER,
ASSISTANT,
@@ -0,0 +1,440 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.ModelCapabilities
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
/**
* Tracks an in-flight `config.set key=model` RPC and the model it would replace
* so confirmation dialog dismiss can roll back optimistically.
*/
private data class ActiveModelSwitch(
val spec: String,
val previousModel: String?,
val sequence: Long,
)
private data class ActiveFastSwitch(
val targetFast: Boolean,
)
/**
* Owns model picker loading/caching, in-session model switches, model capabilities,
* pinned model toggling, and expensive model switch confirmation dialogs.
*
* Extracted behavior-preservingly from [ChatViewModel] (issue #589).
*/
class ChatModelSwitchDelegate(
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher,
private val uiState: MutableStateFlow<ChatUiState>,
private val runtimeSessionId: () -> String?,
private val wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit,
private val trackRequest: (id: String, method: String) -> Unit,
private val addAssistantMessage: (text: String) -> Unit,
private val handleSlashCommand: (command: String) -> Unit,
private val fetchContextUsage: () -> Unit,
private val onModelSwitchInitiated: () -> Unit = {},
private val getModelOptionsCall: suspend (
refresh: Boolean,
) -> NetworkResult<com.m57.hermescontrol.data.model.ModelOptionsResponse> =
{ refresh -> safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh) } },
private val getPinnedModels: () -> List<PinnedModel> = { AuthManager.getPinnedModels() },
private val savePinnedModels: (List<PinnedModel>) -> Unit = { AuthManager.savePinnedModels(it) },
) {
private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>()
private val pendingFastSwitchRequests = ConcurrentHashMap<String, ActiveFastSwitch>()
private val fastRejectedModels = ConcurrentHashMap.newKeySet<String>()
private var activeModelSwitchConfirmation: ActiveModelSwitch? = null
private var optimisticPreviousModel: String? = null
private var lastConfirmedModel: String? = null
private var unconfirmedTargetModel: String? = null
private var modelSwitchSequence: Long = 0L
private var cachedModelOptions: List<ModelProvider> = emptyList()
fun isModelPickerCommand(command: String): Boolean {
val trimmed = command.trim()
if (!trimmed.startsWith("/", ignoreCase = true)) return false
val body = trimmed.removePrefix("/").trimStart()
return body.equals("model", ignoreCase = true) ||
(
body.startsWith("model ", ignoreCase = true) &&
body.substringAfter("model").trim().isEmpty()
)
}
fun preloadModelOptions() {
scope.launch(ioDispatcher) {
val result = getModelOptionsCall(false)
if (result is NetworkResult.Success) {
cachedModelOptions = result.data.providers.orEmpty()
uiState.update { it.copy(modelPickerPinned = getPinnedModels()) }
syncCurrentModelCapabilities()
}
}
}
fun openModelPicker() {
val hasCached = cachedModelOptions.isNotEmpty()
uiState.update {
it.copy(
showModelPicker = true,
modelPickerProviders = if (hasCached) cachedModelOptions else emptyList(),
modelPickerPinned = getPinnedModels(),
modelPickerLoading = !hasCached,
)
}
if (!hasCached) {
refreshModelOptions()
}
}
fun refreshModelOptions() {
uiState.update { it.copy(modelPickerLoading = true) }
scope.launch(ioDispatcher) {
val result = getModelOptionsCall(true)
when (result) {
is NetworkResult.Success -> {
cachedModelOptions = result.data.providers.orEmpty()
uiState.update {
it.copy(
modelPickerProviders = cachedModelOptions,
modelPickerLoading = false,
)
}
syncCurrentModelCapabilities()
}
is NetworkResult.Failure -> {
uiState.update {
it.copy(
modelPickerLoading = false,
errorMessage = "Failed to load models: ${result.error.message}",
)
}
}
}
}
}
fun closeModelPicker() {
uiState.update { it.copy(showModelPicker = false, modelPickerLoading = false) }
}
fun togglePinModel(
providerSlug: String,
modelName: String,
) {
val currentPinned = getPinnedModels().toMutableList()
val target = PinnedModel(providerSlug, modelName)
if (currentPinned.contains(target)) {
currentPinned.remove(target)
} else {
currentPinned.add(target)
}
savePinnedModels(currentPinned)
uiState.update { it.copy(modelPickerPinned = currentPinned) }
}
fun consumeOptimisticPreviousModel(): String? = optimisticPreviousModel.also { optimisticPreviousModel = null }
fun sendSlashModel(
provider: String,
model: String,
) {
val targetModelLabel = "$provider/$model"
val isDifferent = !targetModelLabel.equals(uiState.value.currentSessionModel, ignoreCase = true)
if (!isDifferent) {
uiState.update {
it.copy(
showModelPicker = false,
modelPickerLoading = false,
)
}
return
}
if (optimisticPreviousModel == null) {
optimisticPreviousModel = uiState.value.currentSessionModel
}
unconfirmedTargetModel = targetModelLabel
uiState.update {
it.copy(
showModelPicker = false,
modelPickerLoading = false,
currentSessionModel = targetModelLabel,
fullContextTokens = null,
)
}
onModelSwitchInitiated()
syncCurrentModelCapabilities()
handleSlashCommand("/model $model --provider $provider --session")
}
fun handleModelSwitch(
command: String,
confirmExpensive: Boolean = false,
) {
val sessionId = runtimeSessionId()
if (sessionId == null) {
addAssistantMessage("No active session. Use `/new` to create one.")
return
}
val spec =
if (command.startsWith("/model", ignoreCase = true)) {
command.substring(6).trim()
} else {
command.trim()
}
val previousModel = lastConfirmedModel ?: optimisticPreviousModel ?: uiState.value.currentSessionModel
// Retain optimisticPreviousModel for consumeOptimisticPreviousModel() when SessionInfo arrives.
if (optimisticPreviousModel == null && !spec.equals(uiState.value.currentSessionModel, ignoreCase = true)) {
optimisticPreviousModel = uiState.value.currentSessionModel
uiState.update { it.copy(fullContextTokens = null) }
onModelSwitchInitiated()
}
unconfirmedTargetModel = spec
val switchSeq = ++modelSwitchSequence
val params =
mutableMapOf<String, Any>(
"key" to "model",
"value" to spec,
"session_id" to sessionId,
)
if (confirmExpensive) {
params["confirm_expensive_model"] = true
}
scope.launch(ioDispatcher) {
wsSend(
WsMethods.CONFIG_SET,
params,
) { id ->
trackRequest(id, WsMethods.CONFIG_SET)
pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel, switchSeq)
}
}
}
fun handleConfigSetResult(
id: String,
result: Any?,
) {
val pending = pendingModelSwitchRequests.remove(id)
val pendingFast = pendingFastSwitchRequests.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) {
if (pending != null && pending.sequence == modelSwitchSequence) {
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)
}
}
}
} else if (key == "fast") {
val rawVal = map["value"] as? String
val confirmedFast =
if (rawVal != null) {
rawVal == "fast" || rawVal == "priority"
} else {
pendingFast?.targetFast ?: uiState.value.fastMode
}
uiState.update {
it.copy(
fastMode = confirmedFast,
isFastModeChanging = false,
)
}
}
}
fun dismissModelSwitchConfirm() {
val pending = activeModelSwitchConfirmation
activeModelSwitchConfirmation = null
optimisticPreviousModel = null
unconfirmedTargetModel = null
val revertModel = lastConfirmedModel ?: pending?.previousModel
uiState.update {
it.copy(
modelSwitchConfirmMessage = null,
currentSessionModel = revertModel ?: it.currentSessionModel,
fullContextTokens = null,
)
}
syncCurrentModelCapabilities()
onModelSwitchInitiated()
fetchContextUsage()
}
fun confirmModelSwitchExpensive() {
val pending = activeModelSwitchConfirmation
activeModelSwitchConfirmation = null
uiState.update { it.copy(modelSwitchConfirmMessage = null) }
if (pending != null) {
handleModelSwitch(pending.spec, confirmExpensive = true)
}
}
fun toggleFastMode() {
val state = uiState.value
val sessionId = runtimeSessionId() ?: return
if (state.currentModelCapabilities?.fast != true) return
if (state.isFastModeChanging) return
val target = !state.fastMode
uiState.update { it.copy(isFastModeChanging = true) }
scope.launch(ioDispatcher) {
wsSend(
WsMethods.CONFIG_SET,
mapOf(
"key" to "fast",
"value" to if (target) "fast" else "normal",
"session_id" to sessionId,
),
) { id ->
trackRequest(id, WsMethods.CONFIG_SET)
pendingFastSwitchRequests[id] = ActiveFastSwitch(target)
}
}
}
fun handleConfigSetError(
id: String,
error: Any?,
) {
val pendingModel = pendingModelSwitchRequests.remove(id)
if (pendingModel != null && pendingModel.sequence == modelSwitchSequence) {
val revertModel = lastConfirmedModel ?: pendingModel.previousModel
optimisticPreviousModel = null
unconfirmedTargetModel = null
activeModelSwitchConfirmation = null
if (revertModel != null) {
uiState.update {
it.copy(
currentSessionModel = revertModel,
fullContextTokens = null,
modelSwitchConfirmMessage = null,
)
}
syncCurrentModelCapabilities()
onModelSwitchInitiated()
fetchContextUsage()
} else {
uiState.update {
it.copy(
fullContextTokens = null,
modelSwitchConfirmMessage = null,
)
}
onModelSwitchInitiated()
}
}
val pendingFast = pendingFastSwitchRequests.remove(id)
if (pendingFast != null) {
val errorMsg =
when (error) {
is Map<*, *> -> error["message"] as? String ?: error.toString()
else -> error.toString()
}.lowercase()
val currentModel = uiState.value.currentSessionModel
if (currentModel != null &&
(errorMsg.contains("fast mode is not available") || errorMsg.contains("unknown fast mode"))
) {
fastRejectedModels.add(currentModel)
syncCurrentModelCapabilities()
}
uiState.update { it.copy(isFastModeChanging = false) }
}
}
fun isSwitchPending(): Boolean =
optimisticPreviousModel != null ||
unconfirmedTargetModel != null ||
activeModelSwitchConfirmation != null
fun onModelConfirmed(modelLabel: String) {
lastConfirmedModel = modelLabel
optimisticPreviousModel = null
unconfirmedTargetModel = null
}
fun reset() {
pendingModelSwitchRequests.clear()
pendingFastSwitchRequests.clear()
activeModelSwitchConfirmation = null
optimisticPreviousModel = null
lastConfirmedModel = null
unconfirmedTargetModel = null
}
fun setReasoningLevel(level: String?) {
uiState.update { it.copy(reasoningLevel = level) }
val sessionId = runtimeSessionId() ?: return
if (level == null) return
scope.launch(ioDispatcher) {
wsSend(
WsMethods.CONFIG_SET,
mapOf(
"key" to "reasoning",
"value" to level,
"session_id" to sessionId,
),
) { id -> trackRequest(id, WsMethods.CONFIG_SET) }
}
}
fun getModelCapabilities(
providerSlug: String,
modelName: String,
): ModelCapabilities? = cachedModelOptions.find { it.slug == providerSlug }?.capabilities?.get(modelName)
fun getCurrentModelCapabilities(): ModelCapabilities? {
val label = uiState.value.currentSessionModel ?: return null
val idx = label.indexOf('/')
if (idx <= 0) return null
val provider = label.substring(0, idx)
val model = label.substring(idx + 1)
return getModelCapabilities(provider, model)
}
fun syncCurrentModelCapabilities() {
val label = uiState.value.currentSessionModel
val caps =
if (label == null) {
null
} else {
val idx = label.indexOf('/')
if (idx <= 0) {
null
} else {
val provider = label.substring(0, idx)
val model = label.substring(idx + 1)
val baseCaps = cachedModelOptions.find { it.slug == provider }?.capabilities?.get(model)
if (fastRejectedModels.contains(label) && baseCaps?.fast == true) {
baseCaps.copy(fast = false)
} else {
baseCaps
}
}
}
uiState.update { state ->
if (state.currentModelCapabilities != caps) state.copy(currentModelCapabilities = caps) else state
}
}
}
@@ -1,14 +1,10 @@
package com.m57.hermescontrol.ui.chat
import android.Manifest
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.ContextWrapper
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.OpenableColumns
import android.speech.RecognizerIntent
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
@@ -36,9 +32,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -50,6 +50,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -64,14 +65,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
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.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
@@ -82,13 +82,11 @@ import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.update.AppUpdateCache
import com.m57.hermescontrol.data.update.AppUpdateState
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
import com.m57.hermescontrol.ui.chat.components.ChatInputBar
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
@@ -101,29 +99,26 @@ import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
import com.m57.hermescontrol.ui.chat.components.tailContentKey
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
import com.m57.hermescontrol.ui.common.ActionProgressDialog
import com.m57.hermescontrol.ui.common.AppUpdateDialog
import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
import com.m57.hermescontrol.ui.settings.AppUpdateViewModel
import com.m57.hermescontrol.util.ConnectorUrlValidator
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private const val SESSION_SYNC_INTERVAL_MS = 30_000L
@@ -159,10 +154,60 @@ fun ChatScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
val connectorsViewModel: ChatConnectorsViewModel = viewModel()
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
// Snapshot-backed search state — read directly so only the scopes that
// read its fields recompose on search changes (bar, matched bubbles).
val searchState = viewModel.searchState
val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
var browserAuthDeparted by rememberSaveable { mutableStateOf(false) }
DisposableEffect(lifecycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE, Lifecycle.Event.ON_STOP -> {
if (browserAuthInFlight) {
browserAuthDeparted = true
}
connectorsViewModel.onPause()
}
Lifecycle.Event.ON_RESUME -> {
connectorsViewModel.onResume()
if (browserAuthInFlight && browserAuthDeparted) {
browserAuthInFlight = false
browserAuthDeparted = false
ExternalActivityLifecycleGuard.externalActivityReturned()
}
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
val activity =
generateSequence(context) { (it as? ContextWrapper)?.baseContext }
.filterIsInstance<Activity>()
.firstOrNull()
val isChangingConfigs = activity?.isChangingConfigurations == true
if (!isChangingConfigs) {
if (browserAuthInFlight && browserAuthDeparted) {
browserAuthInFlight = false
browserAuthDeparted = false
ExternalActivityLifecycleGuard.externalActivityReturned()
}
connectorsViewModel.onPause()
connectorsViewModel.hide()
}
}
}
val browserEvent = connectorsState.browserLaunchEvent
val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
val scrollScope = rememberCoroutineScope()
val scrollController = rememberChatScrollController(listState, scrollScope)
@@ -304,14 +349,17 @@ fun ChatScreen(
viewModel.consumeComposerTextRestore()
}
}
var isListening by rememberSaveable { mutableStateOf(false) }
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(showSubagentInspectionSheet) {
if (showSubagentInspectionSheet) {
viewModel.hydrateSubagents()
}
}
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val isDark = isSystemInDarkTheme()
val context = LocalContext.current
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
ExternalActivityLifecycleGuard.launchExternalActivity(
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
@@ -322,137 +370,63 @@ fun ChatScreen(
)
}
val micListeningPrompt = stringResource(R.string.chat_mic_listening)
val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied)
val invalidResponseError = stringResource(R.string.session_integrations_err_invalid_response)
val browserLaunchError = stringResource(R.string.session_integrations_err_browser_launch)
// Speech-to-text recognition launcher (issue #194)
val speechLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
ExternalActivityLifecycleGuard.externalActivityReturned()
isListening = false
if (result.resultCode == Activity.RESULT_OK) {
val spokenText =
result.data
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
?.firstOrNull()
.orEmpty()
if (spokenText.isNotBlank()) {
val merged =
if (inputFieldValue.text.isBlank()) {
spokenText
} else {
"${inputFieldValue.text} $spokenText"
}
inputFieldValue = ChatInputPolicy.commandFieldValue(merged)
LaunchedEffect(browserEvent) {
if (browserEvent != null) {
val eventId = browserEvent.eventId
val taken = connectorsViewModel.takeBrowserEvent(eventId)
if (taken != null) {
if (!ConnectorUrlValidator.isValidHttpsUrl(taken.url)) {
connectorsViewModel.launchError(invalidResponseError)
return@LaunchedEffect
}
}
}
// Mic permission launcher
val micPermissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
ExternalActivityLifecycleGuard.externalActivityReturned()
if (granted) {
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
isListening = true
launchExternalActivity {
try {
speechLauncher.launch(intent)
} catch (_: ActivityNotFoundException) {
isListening = false
scrollScope.launch {
snackbarHostState.showSnackbar(sttNotAvailableMsg)
}
}
}
} else {
scrollScope.launch {
snackbarHostState.showSnackbar(sttNotAvailableMsg)
}
}
} else {
scrollScope.launch {
snackbarHostState.showSnackbar(sttPermissionDeniedMsg)
}
}
}
// Multi-file picker for attachments (issue #195).
val filePickerLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.GetMultipleContents(),
) { uris: List<Uri> ->
ExternalActivityLifecycleGuard.externalActivityReturned()
val attachments =
uris.mapNotNull { uri ->
runCatching {
var name = uri.lastPathSegment ?: "file"
var size = 0L
context.contentResolver
.query(
uri,
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
null,
null,
null,
)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
if (nameIdx >= 0 && !cursor.isNull(nameIdx)) name = cursor.getString(nameIdx)
if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) size = cursor.getLong(sizeIdx)
}
}
Attachment(
uri = uri.toString(),
name = name,
mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream",
size = size,
)
}.onFailure { error ->
Log.w("ChatScreen", "Skipping unreadable picked attachment", error)
}.getOrNull()
}
viewModel.addAttachments(attachments)
}
// Camera photo launcher (issue #195)
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
val cameraErrorMsg = stringResource(R.string.chat_camera_error)
val cameraLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.TakePicture(),
) { success ->
ExternalActivityLifecycleGuard.externalActivityReturned()
val uri = pendingCameraUri
pendingCameraUri = null
if (success && uri != null) {
try {
val fileName =
"photo_${
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(
Date(),
)
}.jpg"
val inputStream = context.contentResolver.openInputStream(uri)
val size = inputStream?.use { it.available().toLong() } ?: 0L
viewModel.addAttachment(uri.toString(), fileName, "image/jpeg", size)
} catch (e: Exception) {
Log.e("ChatScreen", "Camera capture failed", e)
scrollScope.launch {
snackbarHostState.showSnackbar(
cameraErrorMsg,
)
val intent =
Intent(Intent.ACTION_VIEW, Uri.parse(taken.url)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
launchExternalActivity {
browserAuthDeparted = false
browserAuthInFlight = true
context.startActivity(intent)
}
} catch (_: ActivityNotFoundException) {
browserAuthInFlight = false
browserAuthDeparted = false
connectorsViewModel.launchError(browserLaunchError)
} catch (_: SecurityException) {
browserAuthInFlight = false
browserAuthDeparted = false
connectorsViewModel.launchError(browserLaunchError)
} catch (_: Exception) {
browserAuthInFlight = false
browserAuthDeparted = false
connectorsViewModel.launchError(browserLaunchError)
}
}
}
}
val mediaLaunchers =
rememberChatMediaLaunchers(
inputFieldValue = inputFieldValue,
onInputFieldValueChange = { inputFieldValue = it },
onAddAttachment = { uri, name, mimeType, size ->
viewModel.addAttachment(uri, name, mimeType, size)
},
onAddAttachments = { attachments ->
viewModel.addAttachments(attachments)
},
onShowMessage = { msg ->
scrollScope.launch {
snackbarHostState.showSnackbar(msg)
}
},
launchExternalActivity = launchExternalActivity,
context = context,
)
// Lifecycle effects, permissions, session switching, auto-scroll, errors
ChatLifecycleEffects(
@@ -553,6 +527,39 @@ fun ChatScreen(
contentDescription = stringResource(R.string.content_desc_new_chat),
)
}
// Session actions overflow menu (issue #1091)
var showSessionMenu by remember { mutableStateOf(false) }
Box {
IconButton(
onClick = { showSessionMenu = true },
modifier = Modifier.testTag("chat_session_menu_button"),
) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = stringResource(R.string.session_integrations_menu_item),
)
}
DropdownMenu(
expanded = showSessionMenu,
onDismissRequest = { showSessionMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Extension,
contentDescription = null,
)
},
onClick = {
showSessionMenu = false
connectorsViewModel.show()
},
modifier = Modifier.testTag("chat_menu_session_integrations"),
)
}
}
},
) { _ ->
Column(
@@ -592,80 +599,7 @@ fun ChatScreen(
// Issue #890: launch update check — non-blocking banner when a
// newer release exists. Tapping "Update" opens the in-place dialog.
val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle()
val appUpdateViewModel: AppUpdateViewModel =
viewModel {
val app =
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
?: error("Application not available")
AppUpdateViewModel(app)
}
val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle()
val updateLifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
androidx.compose.runtime.DisposableEffect(updateLifecycleOwner) {
val observer =
androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) {
appUpdateViewModel.resumeInstallAfterPermission()
}
}
}
updateLifecycleOwner.lifecycle.addObserver(observer)
onDispose {
updateLifecycleOwner.lifecycle.removeObserver(observer)
}
}
if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) {
val noticeTag =
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
?: UpdateNoticeManager.noticeTag()
if (noticeTag != null) {
com.m57.hermescontrol.ui.common.UpdateNoticeBanner(
latestTag = noticeTag,
onUpdate = { AppUpdateCache.showDialog() },
onDismiss = { AppUpdateCache.dismiss() },
)
}
}
if (AppUpdateCache.isDialogVisible) {
val context = androidx.compose.ui.platform.LocalContext.current
val dialogState =
if (appUpdateState !is AppUpdateState.Idle) {
appUpdateState
} else {
updateNotice
}
AppUpdateDialog(
state = dialogState,
onDismiss = { AppUpdateCache.hideDialog() },
onStartUpdate = { appUpdateViewModel.startUpdate() },
onCancelDownload = { appUpdateViewModel.cancelDownload() },
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
onOpenSettings = {
val intent =
android.content
.Intent(
android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
android.net.Uri.parse("package:${context.packageName}"),
).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(intent)
} catch (e: Exception) {
context.startActivity(
android.content.Intent(android.provider.Settings.ACTION_SECURITY_SETTINGS).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
},
)
}
ChatAppUpdateSection()
AnimatedVisibility(
visible = searchState.isActive,
@@ -751,6 +685,15 @@ fun ChatScreen(
onRespondClarify = viewModel::respondToClarify,
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
onDismissClarify = viewModel::dismissClarify,
vaultUnlockPrompt = state.vaultUnlockPrompt,
onRespondVaultUnlock = viewModel::respondToVaultUnlock,
onDismissVaultUnlock = viewModel::dismissVaultUnlock,
vaultSaveLoginPrompt = state.vaultSaveLoginPrompt,
onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin,
onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin,
vaultCodePrompt = state.vaultCodePrompt,
onRespondVaultCode = viewModel::respondToVaultCode,
onDismissVaultCode = viewModel::dismissVaultCode,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
openingAttachmentPath = state.openingAttachmentPath,
@@ -808,75 +751,16 @@ fun ChatScreen(
// Jump to bottom after send (serialized through the controller).
scrollController.jumpToBottom(animated = true)
},
onMicTap = {
if (isListening) {
isListening = false
} else if (
ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
) {
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
isListening = true
launchExternalActivity {
try {
speechLauncher.launch(intent)
} catch (_: ActivityNotFoundException) {
isListening = false
scrollScope.launch {
snackbarHostState.showSnackbar(sttNotAvailableMsg)
}
}
}
} else {
scrollScope.launch {
snackbarHostState.showSnackbar(sttNotAvailableMsg)
}
}
} else {
launchExternalActivity {
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
},
isListening = isListening,
onMicTap = mediaLaunchers.onMicTap,
isListening = mediaLaunchers.isListening,
isAgentTyping = state.isAgentTyping,
isConnected = state.isConnected,
commandCatalog = state.commandCatalog,
slashUsageCounts = state.slashUsageCounts,
pendingAttachments = state.pendingAttachments,
onCameraTap = {
try {
val timeStamp =
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val photoFile =
File.createTempFile("camera_${timeStamp}_", ".jpg", context.cacheDir)
val uri =
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
photoFile,
)
pendingCameraUri = uri
launchExternalActivity {
cameraLauncher.launch(uri)
}
} catch (e: Exception) {
Log.e("ChatScreen", "Camera launch failed", e)
}
},
onImageTap = {
launchExternalActivity {
filePickerLauncher.launch("image/*")
}
},
onFileTap = {
launchExternalActivity {
filePickerLauncher.launch("*/*")
}
},
onCameraTap = mediaLaunchers.onCameraTap,
onImageTap = mediaLaunchers.onImageTap,
onFileTap = mediaLaunchers.onFileTap,
onRemoveAttachment = viewModel::removeAttachment,
onPreviewAttachment = { attachment ->
if (attachment.isImage) {
@@ -895,6 +779,10 @@ fun ChatScreen(
onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning,
supportsReasoning = state.currentModelCapabilities?.reasoning,
fastMode = state.fastMode,
fastSupported = state.currentModelCapabilities?.fast == true,
isFastModeChanging = state.isFastModeChanging,
onToggleFastMode = { viewModel.toggleFastMode() },
)
}
@@ -981,9 +869,16 @@ fun ChatScreen(
SubagentInspectionSheet(
indicators = state.subagentIndicators,
todos = state.todos,
inspectingSubagentId = state.inspectingSubagentId,
subagentTranscript = state.subagentTranscript,
onToggleTranscript = { subagentId -> viewModel.toggleSubagentTranscript(subagentId) },
onRetryTranscript = { viewModel.retrySubagentTranscript() },
onSteerSubagent = { indicator, msg -> viewModel.steerSubagent(indicator, msg) },
onStopSubagent = { indicator -> viewModel.stopSubagent(indicator) },
onDismiss = { showSubagentInspectionSheet = false },
onDismiss = {
showSubagentInspectionSheet = false
viewModel.closeSubagentTranscript()
},
)
}
@@ -1000,5 +895,14 @@ fun ChatScreen(
onDismiss = { viewingImage = null },
)
}
SessionIntegrationsSheet(
uiState = connectorsState,
sessionId = connectorsState.sessionId,
onDismiss = connectorsViewModel::hide,
onRefresh = { connectorsViewModel.refresh(force = true) },
onConnect = { slug, reconnect -> connectorsViewModel.connect(slug, reconnect) },
onClearError = connectorsViewModel::clearError,
)
}
}
@@ -171,6 +171,7 @@ internal fun mapServerMessages(
}
}
val tokenCount = msg.tokenCount ?: TokenEstimator.estimate(finalContent).takeIf { it > 0 }
mapped.add(
ChatMessage(
id = restId,
@@ -182,6 +183,7 @@ internal fun mapServerMessages(
timestamp = timestamp,
isStreaming = false,
displayKind = msg.display_kind,
tokenCount = tokenCount,
),
)
}
@@ -2,8 +2,11 @@ package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.ws.WsEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* Holds chat message-streaming state and logic, extracted from [ChatViewModel]
@@ -27,14 +30,23 @@ class ChatStreamingController(
private val isCurrentSession: (String?) -> Boolean,
private val isTestEnvironment: () -> Boolean,
) {
private companion object {
const val FLUSH_INTERVAL_MS = 33L
}
private val streamingBuffer = java.lang.StringBuilder()
private var lastFlushMs = 0L
private var trailingMessageFlushJob: Job? = null
private val thinkingBuffer = java.lang.StringBuilder()
private var lastThinkingFlushMs = 0L
private var trailingThinkingFlushJob: Job? = null
private val reasoningBuffer = java.lang.StringBuilder()
private var lastReasoningFlushMs = 0L
private var trailingReasoningFlushJob: Job? = null
private fun nowMs(): Long = System.nanoTime() / 1_000_000L
/**
* Resets all streaming buffers. Centralizes the clear logic that was
@@ -42,6 +54,9 @@ class ChatStreamingController(
* ToolStart, ClarifyRequest, session switches, and interrupt handling.
*/
fun resetStreaming() {
trailingMessageFlushJob?.cancel()
trailingThinkingFlushJob?.cancel()
trailingReasoningFlushJob?.cancel()
flushReasoning()
streamingBuffer.clear()
thinkingBuffer.clear()
@@ -73,6 +88,9 @@ class ChatStreamingController(
* buffer-only clear is safe and prevents stale deltas from re-flushing.
*/
fun clearStreamingBuffers() {
trailingMessageFlushJob?.cancel()
trailingThinkingFlushJob?.cancel()
trailingReasoningFlushJob?.cancel()
streamingBuffer.clear()
thinkingBuffer.clear()
reasoningBuffer.clear()
@@ -94,6 +112,7 @@ class ChatStreamingController(
* synchronously so the finalized message carries the latest reasoning.
*/
fun flushPendingReasoning() {
trailingReasoningFlushJob?.cancel()
flushReasoning()
}
@@ -111,8 +130,10 @@ class ChatStreamingController(
* tokens synchronously first makes the sealed orphan complete.
*/
fun flushPendingTokens() {
trailingMessageFlushJob?.cancel()
val currentContent = streamingBuffer.toString()
if (currentContent.isEmpty()) return
lastFlushMs = nowMs()
streamingState.update { state ->
val current = state.streamingMessage ?: return@update state
state.copy(
@@ -130,63 +151,97 @@ class ChatStreamingController(
if (!isCurrentSession(event.sessionId)) return
streamingBuffer.append(event.token)
val now = System.currentTimeMillis()
val now = nowMs()
// Always flush in tests, or if enough time has passed
val shouldFlush = (now - lastFlushMs >= 33L) || lastFlushMs == 0L || isTestEnvironment()
val shouldFlush = (now - lastFlushMs >= FLUSH_INTERVAL_MS) || lastFlushMs == 0L || isTestEnvironment()
if (shouldFlush) {
val currentContent = streamingBuffer.toString()
lastFlushMs = now
streamingState.update { state ->
val current = state.streamingMessage
if (current != null) {
val currentReasoning =
if (current.reasoningText.isNotBlank()) current.reasoningText else state.reasoningText
state.copy(
streamingMessage =
current.copy(
content = currentContent,
reasoningText = currentReasoning,
),
isThinking = false,
)
} else {
// Fallback: no MessageStart was received — create one now
val msg =
ChatMessage(
role = MessageRole.ASSISTANT,
content = currentContent,
reasoningText = state.reasoningText,
isStreaming = true,
)
state.copy(
streamingMessage = msg,
isThinking = false,
)
}
}
uiState.update { it.copy(isAgentTyping = true) }
trailingMessageFlushJob?.cancel()
flushTokens(now)
} else {
scheduleTrailingMessageFlush()
}
}
private fun scheduleTrailingMessageFlush() {
if (trailingMessageFlushJob?.isActive == true) return
trailingMessageFlushJob =
scope.launch {
delay(FLUSH_INTERVAL_MS)
flushTokens(nowMs())
}
}
private fun flushTokens(now: Long) {
val currentContent = streamingBuffer.toString()
if (currentContent.isEmpty()) return
lastFlushMs = now
streamingState.update { state ->
val current = state.streamingMessage
if (current != null) {
val currentReasoning =
if (current.reasoningText.isNotBlank()) current.reasoningText else state.reasoningText
state.copy(
streamingMessage =
current.copy(
content = currentContent,
reasoningText = currentReasoning,
),
isThinking = false,
)
} else {
// Fallback: no MessageStart was received — create one now
val msg =
ChatMessage(
role = MessageRole.ASSISTANT,
content = currentContent,
reasoningText = state.reasoningText,
isStreaming = true,
)
state.copy(
streamingMessage = msg,
isThinking = false,
)
}
}
uiState.update { it.copy(isAgentTyping = true) }
}
fun handleThinkingDelta(event: WsEvent.ThinkingDelta) {
if (!isCurrentSession(event.sessionId)) return
thinkingBuffer.append(event.token)
val now = System.currentTimeMillis()
val now = nowMs()
// Always flush in tests, or if enough time has passed
val shouldFlush =
(now - lastThinkingFlushMs >= 33L) || lastThinkingFlushMs == 0L || isTestEnvironment()
(now - lastThinkingFlushMs >= FLUSH_INTERVAL_MS) || lastThinkingFlushMs == 0L || isTestEnvironment()
if (shouldFlush) {
val currentContent = thinkingBuffer.toString()
lastThinkingFlushMs = now
streamingState.update { state ->
state.copy(
isThinking = true,
thinkingText = currentContent,
)
trailingThinkingFlushJob?.cancel()
flushThinking(now)
} else {
scheduleTrailingThinkingFlush()
}
}
private fun scheduleTrailingThinkingFlush() {
if (trailingThinkingFlushJob?.isActive == true) return
trailingThinkingFlushJob =
scope.launch {
delay(FLUSH_INTERVAL_MS)
flushThinking(nowMs())
}
}
private fun flushThinking(now: Long) {
val currentContent = thinkingBuffer.toString()
if (currentContent.isEmpty()) return
lastThinkingFlushMs = now
streamingState.update { state ->
state.copy(
isThinking = true,
thinkingText = currentContent,
)
}
}
@@ -194,17 +249,29 @@ class ChatStreamingController(
if (!isCurrentSession(event.sessionId)) return
reasoningBuffer.append(event.token)
val now = System.currentTimeMillis()
val now = nowMs()
// Always flush in tests, or if enough time has passed
val shouldFlush =
(now - lastReasoningFlushMs >= 33L) || lastReasoningFlushMs == 0L || isTestEnvironment()
(now - lastReasoningFlushMs >= FLUSH_INTERVAL_MS) || lastReasoningFlushMs == 0L || isTestEnvironment()
if (shouldFlush) {
trailingReasoningFlushJob?.cancel()
flushReasoning(now)
} else {
scheduleTrailingReasoningFlush()
}
}
private fun flushReasoning(now: Long = System.currentTimeMillis()) {
private fun scheduleTrailingReasoningFlush() {
if (trailingReasoningFlushJob?.isActive == true) return
trailingReasoningFlushJob =
scope.launch {
delay(FLUSH_INTERVAL_MS)
flushReasoning(nowMs())
}
}
private fun flushReasoning(now: Long = nowMs()) {
val currentContent = reasoningBuffer.toString()
if (currentContent.isEmpty()) return
lastReasoningFlushMs = now
@@ -0,0 +1,305 @@
package com.m57.hermescontrol.ui.chat
import android.util.Log
import com.m57.hermescontrol.data.model.SubagentListItem
import com.m57.hermescontrol.data.ws.SubagentRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Delegate managing subagent roster hydration via `subagent.list` and live transcript
* tail polling via `subagent.tail` (issue #1089).
*/
class ChatSubagentsDelegate(
private val uiState: MutableStateFlow<ChatUiState>,
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher,
private val runtimeSessionId: () -> String?,
private val subagentRepository: SubagentRepository = SubagentRepository,
private val pollingIntervalMs: Long = 1500L,
) {
companion object {
private const val TAG = "ChatSubagentsDelegate"
const val SUBAGENT_TAIL_MAX_BYTES = 16384
}
private var pollingJob: Job? = null
private var hydrationJob: Job? = null
/**
* Request the authoritative subagent roster from the gateway and merge it into [ChatUiState].
*
* A failed request degrades gracefully without clearing existing push-event indicators.
*/
fun hydrateSubagents(sessionId: String? = runtimeSessionId()) {
val targetSession = sessionId?.trim()
if (targetSession.isNullOrEmpty()) return
val requestTime = System.currentTimeMillis()
hydrationJob?.cancel()
hydrationJob =
scope.launch(ioDispatcher) {
try {
val response = subagentRepository.listSubagents(targetSession) ?: return@launch
uiState.update { current ->
val merged = mergeSubagentList(current.subagentIndicators, response.subagents, requestTime)
current.copy(subagentIndicators = merged)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Failed to hydrate subagents for session $targetSession: ${e.message}")
}
}
}
/**
* Toggle transcript inspection for [subagentId].
*
* If already inspecting [subagentId], closes the transcript view; otherwise opens it.
*/
fun toggleSubagentTranscript(subagentId: String) {
if (uiState.value.inspectingSubagentId == subagentId) {
closeSubagentTranscript()
} else {
inspectSubagentTranscript(subagentId)
}
}
/**
* Start inspecting a live subagent's execution transcript, initiating bounded polling.
*/
fun inspectSubagentTranscript(subagentId: String) {
if (subagentId.isBlank()) return
pollingJob?.cancel()
uiState.update { current ->
current.copy(
inspectingSubagentId = subagentId,
subagentTranscript =
SubagentTranscriptUiState(
subagentId = subagentId,
isLoading = true,
),
)
}
pollingJob =
scope.launch(ioDispatcher) {
val sessionId = runtimeSessionId() ?: return@launch
pollTranscriptLoop(sessionId, subagentId)
}
}
/**
* Retry loading the transcript for the currently inspected subagent.
*/
fun retryTranscript() {
val subagentId = uiState.value.inspectingSubagentId ?: return
inspectSubagentTranscript(subagentId)
}
/**
* Stop transcript polling and dismiss the transcript disclosure.
*/
fun closeSubagentTranscript() {
pollingJob?.cancel()
pollingJob = null
uiState.update { current ->
current.copy(
inspectingSubagentId = null,
subagentTranscript = null,
)
}
}
private suspend fun pollTranscriptLoop(
sessionId: String,
subagentId: String,
) {
while (scope.isActive) {
try {
val tailResponse =
subagentRepository.tailSubagent(
sessionId = sessionId,
subagentId = subagentId,
maxBytes = SUBAGENT_TAIL_MAX_BYTES,
)
if (tailResponse != null) {
val content = tailResponse.content()
val truncated = tailResponse.truncated ?: false
val bytesRead = tailResponse.bytesRead
uiState.update { current ->
if (current.inspectingSubagentId != subagentId) return@update current
val prev = current.subagentTranscript
if (prev == null || prev.text != content || prev.isLoading || prev.error != null) {
current.copy(
subagentTranscript =
SubagentTranscriptUiState(
subagentId = subagentId,
text = content,
isLoading = false,
isTruncated = truncated,
bytesRead = bytesRead,
error = null,
),
)
} else {
current
}
}
} else {
uiState.update { current ->
if (current.inspectingSubagentId != subagentId) return@update current
val prev = current.subagentTranscript
if (prev == null || prev.text.isEmpty()) {
current.copy(
subagentTranscript =
SubagentTranscriptUiState(
subagentId = subagentId,
isLoading = false,
error = "Unable to load transcript",
),
)
} else {
current.copy(subagentTranscript = prev.copy(isLoading = false))
}
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Error fetching transcript tail for subagent $subagentId: ${e.message}")
uiState.update { current ->
if (current.inspectingSubagentId != subagentId) return@update current
val prev = current.subagentTranscript
if (prev == null || prev.text.isEmpty()) {
current.copy(
subagentTranscript =
SubagentTranscriptUiState(
subagentId = subagentId,
isLoading = false,
error = e.message ?: "Unable to load transcript",
),
)
} else {
current.copy(subagentTranscript = prev.copy(isLoading = false))
}
}
}
// Stop polling once the subagent reaches a terminal state (one final refresh is enough)
val currentIndicator = uiState.value.subagentIndicators.firstOrNull { it.subagentId == subagentId }
if (currentIndicator != null && !currentIndicator.isRunning) {
break
}
delay(pollingIntervalMs)
}
}
/**
* Merges snapshot items from `subagent.list` with current indicators.
*
* Rules:
* - `subagent_id` is the primary identity.
* - Protects against races: if an indicator received a push event with timestamp > [requestTime],
* its push-event status/type is preserved.
* - Preserves local details (logs, taskIndex, taskCount, summary) not provided in the snapshot.
* - Does not discard locally known indicators absent from the snapshot.
*/
internal fun mergeSubagentList(
current: List<SubagentIndicator>,
listItems: List<SubagentListItem>,
requestTime: Long,
): List<SubagentIndicator> {
val merged = current.toMutableList()
for (item in listItems) {
val subagentId = item.subagentId.trim()
if (subagentId.isEmpty() && item.goal.isNullOrBlank()) continue
val idx =
if (subagentId.isNotEmpty()) {
merged.indexOfFirst { it.subagentId == subagentId }
} else {
merged.indexOfFirst { it.subagentId == null && it.goal == item.goal }
}
if (idx >= 0) {
val existing = merged[idx]
val isPushEventNewer = existing.lastEventTimestamp > requestTime
val finalStatus =
if (isPushEventNewer) {
existing.status ?: item.status ?: "running"
} else if (existing.isComplete || existing.isFailed || existing.isCancelled) {
// Terminal state guard: don't revert to running
if (item.status == "completed" || item.status == "failed" ||
item.status == "cancelled" || item.status == "stopped"
) {
item.status
} else {
existing.status
}
} else {
item.status ?: existing.status ?: "running"
}
val finalType =
if (isPushEventNewer) {
existing.type
} else if (finalStatus == "completed" || finalStatus == "done" || existing.isComplete) {
"subagent.complete"
} else if (existing.type.isNotBlank()) {
existing.type
} else {
"subagent.progress"
}
merged[idx] =
existing.copy(
type = finalType,
goal = item.goal ?: existing.goal,
status = finalStatus,
subagentId = if (subagentId.isNotEmpty()) subagentId else existing.subagentId,
durationSeconds =
if (isPushEventNewer) {
existing.durationSeconds ?: item.elapsedSeconds
} else {
item.elapsedSeconds ?: existing.durationSeconds
},
model = item.model ?: existing.model,
lastEventTimestamp = maxOf(existing.lastEventTimestamp, requestTime),
)
} else {
val status = item.status ?: "running"
val isComp = status == "completed" || status == "done"
val type = if (isComp) "subagent.complete" else "subagent.progress"
merged.add(
SubagentIndicator(
type = type,
goal = item.goal,
status = status,
subagentId = subagentId.takeIf { it.isNotEmpty() },
durationSeconds = item.elapsedSeconds,
model = item.model,
lastEventTimestamp = requestTime,
),
)
}
}
return merged
}
}
@@ -0,0 +1,218 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Canonical comparison key for a tool message's result payload.
*
* WS tool messages store the full tool.complete payload
* (`{"tool_id":..., "name":..., "args":..., "result": {...}}`) while REST
* transcript rows store just the result object (`{"output":..., "exit_code":...}`).
* This key normalizes both sides — preferring the `result` field when present,
* and treating int/float JSON numbers as equal — so the two representations of
* the SAME tool call can be matched regardless of position or pagination.
* Returns null for unparseable content (no match possible).
*/
internal fun canonicalToolResultKey(content: String): String? {
val element =
try {
OkHttpProvider.json.parseToJsonElement(content)
} catch (_: Exception) {
return null
}
fun canon(e: JsonElement): String =
when (e) {
is JsonObject -> {
e.entries.sortedBy { it.key }.joinToString("|") { "${it.key}=${canon(it.value)}" }
}
is JsonArray -> {
e.joinToString(",") { canon(it) }
}
is JsonPrimitive -> {
// Canonicalize ALL numbers through double, collapsing int/float
// spellings of the same value (0 vs 0.0 → "i0", 0.5 → "d0.5").
val s = e.content
val d = s.toDoubleOrNull()
if (d != null) {
if (d == d.toLong().toDouble()) "i${d.toLong()}" else "d$d"
} else {
"s$s"
}
}
}
return when (element) {
is JsonObject -> {
element["result"]?.let { canon(it) } ?: canon(element)
}
else -> {
canon(element)
}
}
}
/**
* True when [a] and [b] are the same logical message (the WS-persisted and
* REST-persisted copies of one row — they carry different ids, see #771).
* Tool messages match on their normalized result payload; other roles on
* exact content, IGNORING leading/trailing whitespace.
*
* Issue #842: the app seals the RAW streamed text (which can carry leading
* blank lines the model emits before its narration), while the backend
* persists a CLEANED copy (leading whitespace stripped). An exact-content
* compare made the reload merge treat them as different messages and add
* the REST copy on top — the commentary duplicated ~10s after the stream
* ended. Trim closes the drift: the sealed live bubble is covered by the
* REST row and the duplicate never renders.
*/
internal fun sameLogicalMessage(
a: ChatMessage,
b: ChatMessage,
): Boolean {
if (a.role != b.role) return false
if (a.role == MessageRole.TOOL) {
// Issue #842: prefer the gateway's tool call id when both sides carry
// it — the REST transcript stores `tool_call_id` and the live WS
// bubble keeps it from `tool.start`. Content canonicalization cannot
// cover MCP/web tools: the REST side stores the payload as raw
// `<untrusted_tool_result>` text (not JSON), so it has no key at all.
if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) {
return a.toolCallId == b.toolCallId
}
val ka = canonicalToolResultKey(a.content)
val kb = canonicalToolResultKey(b.content)
return ka != null && ka == kb
}
val ta = a.content.trim()
val tb = b.content.trim()
if (ta == tb) return true
// Attachment dedupe: when the user sends an image/file, the backend
// persists an ENRICHED copy of the prompt — the real caption plus
// `@image:`/`@file:` reference lines and a `[screenshot]` marker
// (tui_gateway _build_image_ref_message / run_agent flattening). The
// optimistic bubble carries the raw caption only, so the exact compare
// above fails and the REST sync renders the same message twice. Compare
// USER rows on the caption with injection lines stripped (both sides —
// stripping a clean string is a no-op). USER-only so assistant text that
// legitimately mentions these tokens is never collapsed.
if (a.role == MessageRole.USER) {
val ca = stripAttachmentRefLines(ta)
val cb = stripAttachmentRefLines(tb)
if (ca == cb) return true
}
// Issue #842: a seal race can leave the live orphan a few trailing tokens
// short of the streamed narration (the last delta was still in the
// throttled buffer when tool.start sealed the message). The backend
// persists the COMPLETE copy, so the orphan is a strict prefix of the
// REST row. Accept prefix-covering only for substantial texts (>=40
// chars) so a short reply can never be swallowed by a longer message
// that merely starts with it.
return ta.length >= 40 &&
tb.length >= 40 &&
(tb.startsWith(ta) || ta.startsWith(tb))
}
/**
* Remove backend attachment-injection lines from a user message so the
* optimistic bubble and the server-enriched REST copy compare equal.
* Lines the gateway adds on top of the user's caption: `@image:<path>`,
* `@file:<ref>`, and `[screenshot]` (multipart image placeholder written by
* run_agent). Blank lines left behind are dropped too.
*/
internal fun stripAttachmentRefLines(content: String): String =
content
.lines()
.map { it.trim() }
.filterNot { line ->
line.startsWith("@image:") ||
line.startsWith("@file:") ||
line == "[screenshot]"
}.joinToString("\n")
.trim()
/**
* Room accumulates BOTH the WS-persisted copy (UUID id, rich tool payload,
* tool name) and the REST-persisted copy (`rest-` id, result-only payload,
* no tool name) of every message. Painting the cache verbatim renders the
* same call twice. Drop the `rest-` copy whenever a WS copy of the same
* logical message exists (issue #771).
*/
internal fun dedupeCachedMessages(messages: List<ChatMessage>): List<ChatMessage> {
val rest = messages.filter { it.id.startsWith("rest-") }
if (rest.isEmpty()) return messages
val nonRest = messages.filterNot { it.id.startsWith("rest-") }
if (nonRest.isEmpty()) return messages
val keepRest = rest.filter { restMsg -> nonRest.none { sameLogicalMessage(it, restMsg) } }
return (nonRest + keepRest).sortedBy { it.timestamp }
}
/**
* A transcript reload must NOT yank live WS bubbles the server has not
* persisted yet. The gateway stores a tool row only once the tool
* COMPLETES server-side, so a reload that lands while a tool is running
* (app background/foreground mid-turn, reconnect re-resume, pull-refresh)
* returns a page without the tool row — replacing the list outright made
* the in-flight tool bubble vanish and left tool.complete with no RUNNING
* message to update (issue #771).
*
* Merge instead of replace: append any current message the REST page does
* not already cover (checked by id AND logical content via
* [sameLogicalMessage]) and keep chronological order.
*/
internal fun mergeTranscriptWithLive(
restMessages: List<ChatMessage>,
currentMessages: List<ChatMessage>,
): List<ChatMessage> {
val dedupedRest = restMessages.dedupeById()
val dedupedCurrent = currentMessages.dedupeById()
// User rows can carry live/cache-only metadata (for example whether the
// bubble continues the active turn). Keep that richer copy when REST
// returns the same logical row.
val currentUsers = dedupedCurrent.filter { it.role == MessageRole.USER }.toMutableList()
val mergedRest =
dedupedRest.map { rest ->
if (rest.role != MessageRole.USER) return@map rest
val matchIndex =
currentUsers.indexOfFirst { it.id == rest.id }.takeIf { it >= 0 }
?: currentUsers.indexOfFirst { sameLogicalMessage(rest, it) }
if (matchIndex >= 0) currentUsers.removeAt(matchIndex) else rest
}
val restIds = dedupedRest.map { it.id }.toSet()
val liveTail =
dedupedCurrent.filter { old ->
old.id !in restIds && mergedRest.none { sameLogicalMessage(it, old) }
}
if (liveTail.isEmpty()) return mergedRest.dedupeById()
return (mergedRest + liveTail).dedupeById().sortedBy { it.timestamp }
}
/** Merge a REST page without matching it against already-settled transcript rows. */
internal fun mergeIncrementalTranscriptPage(
restMessages: List<ChatMessage>,
currentMessages: List<ChatMessage>,
sessionId: String,
offset: Int,
): List<ChatMessage> {
val settledEnd =
currentMessages.indexOfLast { message ->
serverMessageIndex(message.id, sessionId)?.let { it < offset } == true
}
return (
currentMessages.take(settledEnd + 1) +
mergeTranscriptWithLive(restMessages, currentMessages.drop(settledEnd + 1))
).dedupeById()
}
internal fun serverMessageIndex(
id: String,
sessionId: String,
): Int? = id.removePrefix("rest-$sessionId-").takeIf { it != id }?.toIntOrNull()
internal fun List<ChatMessage>.dedupeById(): List<ChatMessage> = associateBy { it.id }.values.toList()
File diff suppressed because it is too large. Load diff
@@ -74,6 +74,13 @@ object ChatWsEventReducer {
is WsEvent.ReviewSummary -> event.sessionId
is WsEvent.BtwComplete -> event.sessionId
is WsEvent.SessionUsage -> event.sessionId
is WsEvent.TranscriptResyncRequired -> event.sessionId
is WsEvent.VaultUnlockRequest -> event.sessionId
is WsEvent.VaultUnlockExpire -> event.sessionId
is WsEvent.VaultSaveLoginRequest -> event.sessionId
is WsEvent.VaultSaveLoginExpire -> event.sessionId
is WsEvent.VaultCodeRequest -> event.sessionId
is WsEvent.VaultCodeExpire -> event.sessionId
else -> null
}
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
@@ -169,11 +176,23 @@ object ChatWsEventReducer {
is WsEvent.SecretExpire -> ReducerResult(state = state, streamingState = streamingState)
// Vault prompts are handled by the ViewModel (issue #1090)
is WsEvent.VaultUnlockRequest,
is WsEvent.VaultUnlockExpire,
is WsEvent.VaultSaveLoginRequest,
is WsEvent.VaultSaveLoginExpire,
is WsEvent.VaultCodeRequest,
is WsEvent.VaultCodeExpire,
-> ReducerResult(state = state, streamingState = streamingState)
// ReactionEvent is handled by the ViewModel — purely cosmetic animation
is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState)
// Change events (issue #784) are consumed by their screens' ViewModels
is WsEvent.ChangeEvent -> ReducerResult(state = state, streamingState = streamingState)
// TranscriptResyncRequired is handled by ChatViewModel directly (refetch history on replay gap)
is WsEvent.TranscriptResyncRequired -> ReducerResult(state = state, streamingState = streamingState)
}
// ── GatewayReady ──────────────────────────────────────────────────
@@ -217,6 +236,8 @@ object ChatWsEventReducer {
streamingState.streamingMessage.copy(
isStreaming = false,
finishTimestamp = System.currentTimeMillis(),
tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 },
tps = state.latestTps?.takeIf { it > 0.0 },
)
orphan = finalized
state.copy(
@@ -375,17 +396,25 @@ object ChatWsEventReducer {
streamingState = streamingState.copy(streamingMessage = null),
)
}
val rawUsage = event.rawPayload?.get("usage") as? Map<*, *>
val tpsFromPayload = (rawUsage?.get("avg_tps") as? Number)?.toDouble()
val tps = (tpsFromPayload ?: state.latestTps)?.takeIf { it > 0.0 }
val tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 }
val msg =
streaming?.copy(
content = text,
isStreaming = false,
reasoningText = reasoning,
finishTimestamp = System.currentTimeMillis(),
tokenCount = tokenCount,
tps = tps,
) ?: ChatMessage(
role = MessageRole.ASSISTANT,
content = text,
reasoningText = reasoning,
finishTimestamp = System.currentTimeMillis(),
tokenCount = tokenCount,
tps = tps,
)
val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId
@@ -431,11 +460,14 @@ object ChatWsEventReducer {
} else {
streaming.reasoningText
}
val tokenCount = TokenEstimator.estimate(streaming.content).takeIf { it > 0 }
val msg =
streaming.copy(
isStreaming = false,
reasoningText = reasoning,
finishTimestamp = System.currentTimeMillis(),
tokenCount = streaming.tokenCount ?: tokenCount,
tps = streaming.tps ?: state.latestTps?.takeIf { it > 0.0 },
)
val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId
@@ -500,6 +532,8 @@ object ChatWsEventReducer {
isStreaming = false,
reasoningText = reasoning,
finishTimestamp = System.currentTimeMillis(),
tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 },
tps = state.latestTps?.takeIf { it > 0.0 },
)
orphanToPersist = finalized
state.copy(
@@ -912,6 +946,7 @@ object ChatWsEventReducer {
logs = trimmedLogs,
durationSeconds = durationSeconds ?: (if (idx >= 0) indicators[idx].durationSeconds else null),
model = model ?: (if (idx >= 0) indicators[idx].model else null),
lastEventTimestamp = System.currentTimeMillis(),
)
if (idx >= 0) {
@@ -958,6 +993,9 @@ object ChatWsEventReducer {
if (snapshot.contextMax != null && snapshot.contextMax > 0L) {
updatedState = updatedState.copy(fullContextTokens = snapshot.contextMax)
}
if (snapshot.avgTps != null && snapshot.avgTps > 0.0) {
updatedState = updatedState.copy(latestTps = snapshot.avgTps)
}
return ReducerResult(state = updatedState, streamingState = streamingState)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,55 @@
package com.m57.hermescontrol.ui.chat
import java.util.Locale
/**
* Token count estimation and formatting utilities.
*
* The estimation algorithm matches the backend's `estimate_tokens_rough`
* in `agent/model_metadata.py`:
* - CJK ideographs, Hangul, Kana count as ~1 token each.
* - Non-CJK characters count as ceil(UTF-8 bytes / 4).
*/
object TokenEstimator {
fun estimate(text: String?): Int {
if (text.isNullOrEmpty()) return 0
var dense = 0
var nonDenseByteCount = 0
for (i in 0 until text.length) {
val ch = text[i]
if (isCjkDense(ch)) {
dense++
} else {
val code = ch.code
nonDenseByteCount +=
when {
code <= 0x7F -> 1
code <= 0x7FF -> 2
code <= 0xFFFF -> 3
else -> 4
}
}
}
return dense + ((nonDenseByteCount + 3) / 4)
}
private fun isCjkDense(ch: Char): Boolean {
val code = ch.code
return (code in 0x4E00..0x9FFF) ||
(code in 0x3400..0x4DBF) ||
(code in 0xAC00..0xD7AF) ||
(code in 0x1100..0x11FF) ||
(code in 0x3040..0x309F) ||
(code in 0x30A0..0x30FF)
}
fun formatTokenCount(tokens: Int): String =
when {
tokens >= 1_000_000 -> String.format(Locale.US, "%.1fM", tokens / 1_000_000.0)
tokens >= 10_000 -> String.format(Locale.US, "%.1fk", tokens / 1_000.0)
tokens >= 1_000 -> String.format(Locale.US, "%,d", tokens)
else -> tokens.toString()
}
fun formatTps(tps: Double): String = Math.round(tps).toString()
}
@@ -16,12 +16,15 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.CheckCircle
@@ -45,7 +48,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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.layout.ContentScale
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
@@ -58,6 +63,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LocalHermesStatusColors
@@ -84,6 +90,7 @@ internal fun composeToolSummaryLines(
): Pair<String, String?>? {
val titleIsGeneric = view.title.equals(ToolViewBuilder.genericTitleFor(toolName), ignoreCase = true)
val subtitle = view.subtitle.takeIf { it.isNotBlank() && it != view.title }
val statsSuffix = view.diffStats?.let { " (+${it.added}/-${it.removed})" } ?: ""
val firstLine: String
val secondLine: String?
@@ -93,6 +100,7 @@ internal fun composeToolSummaryLines(
if (subtitle != null) {
append(subtitle)
}
append(statsSuffix)
view.countLabel?.let { append(" ($it)") }
}
secondLine = null
@@ -100,6 +108,7 @@ internal fun composeToolSummaryLines(
firstLine =
buildString {
append(view.title)
append(statsSuffix)
view.countLabel?.let { append(" ($it)") }
}
secondLine = subtitle
@@ -233,6 +242,11 @@ internal fun ToolBubble(
SelectionContainer {
Text(
text = message.content,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.8f),
@@ -301,6 +315,11 @@ internal fun ToolBubble(
SelectionContainer {
Text(
text = message.content,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.8f),
@@ -383,6 +402,11 @@ private fun ExpandedToolContent(
view.stdout?.let {
Text(
text = it,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.9f),
@@ -394,6 +418,11 @@ private fun ExpandedToolContent(
view.stderr?.let {
Text(
text = it,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.6f),
@@ -408,13 +437,27 @@ private fun ExpandedToolContent(
text = stringResource(R.string.chat_tool_exit_code, code),
style =
MaterialTheme.typography.labelSmall.copy(
color = statusColors.error,
color = statusColors.warning,
fontWeight = FontWeight.Medium,
),
)
}
}
} else {
// ── Generated/resolved image preview ──
view.imageUrl?.let { url ->
AsyncImage(
model = url,
contentDescription = view.title,
contentScale = ContentScale.Crop,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 200.dp)
.clip(RoundedCornerShape(8.dp)),
)
}
// ── File diff ──
if (view.inlineDiff != null) {
DiffViewCard(
@@ -479,6 +522,11 @@ private fun ExpandedToolContent(
if (view.detail.isNotBlank() && view.inlineDiff == null) {
Text(
text = view.detail,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.9f),
@@ -131,6 +131,12 @@ object ToolSchemaRegistry {
summaryArgKey = null,
icon = Icons.Filled.Checklist,
),
"todo_list" to
ToolDisplayConfig(
name = "todo_list",
summaryArgKey = null,
icon = Icons.Filled.Checklist,
),
"fact_store" to
ToolDisplayConfig(
name = "fact_store",
@@ -150,6 +156,12 @@ object ToolSchemaRegistry {
summaryArgKey = "action",
icon = Icons.Filled.Schedule,
),
"cronjob_manage" to
ToolDisplayConfig(
name = "cronjob_manage",
summaryArgKey = "action",
icon = Icons.Filled.Schedule,
),
"memory" to
ToolDisplayConfig(
name = "memory",
@@ -168,6 +180,12 @@ object ToolSchemaRegistry {
summaryArgKey = "action",
icon = Icons.Filled.Settings,
),
"process_manage" to
ToolDisplayConfig(
name = "process_manage",
summaryArgKey = "action",
icon = Icons.Filled.Settings,
),
"skill_manage" to
ToolDisplayConfig(
name = "skill_manage",
@@ -187,6 +205,42 @@ object ToolSchemaRegistry {
summaryArgKey = null,
icon = Icons.Filled.Keyboard,
),
"browser_scroll" to
ToolDisplayConfig(
name = "browser_scroll",
summaryArgKey = "direction",
icon = Icons.Filled.TouchApp,
),
"browser_back" to
ToolDisplayConfig(
name = "browser_back",
summaryArgKey = null,
icon = Icons.Filled.Language,
),
"browser_press" to
ToolDisplayConfig(
name = "browser_press",
summaryArgKey = "key",
icon = Icons.Filled.Keyboard,
),
"browser_get_images" to
ToolDisplayConfig(
name = "browser_get_images",
summaryArgKey = null,
icon = Icons.Filled.Image,
),
"browser_vision" to
ToolDisplayConfig(
name = "browser_vision",
summaryArgKey = "question",
icon = Icons.Filled.Visibility,
),
"browser_console" to
ToolDisplayConfig(
name = "browser_console",
summaryArgKey = null,
icon = Icons.Filled.Terminal,
),
"browser_cdp" to
ToolDisplayConfig(
name = "browser_cdp",
@@ -0,0 +1,103 @@
package com.m57.hermescontrol.ui.chat.components
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.data.update.AppUpdateCache
import com.m57.hermescontrol.data.update.AppUpdateState
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.ui.common.AppUpdateDialog
import com.m57.hermescontrol.ui.common.UpdateNoticeBanner
import com.m57.hermescontrol.ui.settings.AppUpdateViewModel
@Composable
fun ChatAppUpdateSection() {
val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle()
val appUpdateViewModel: AppUpdateViewModel =
viewModel {
val app =
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
?: error("Application not available")
AppUpdateViewModel(app)
}
val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle()
val updateLifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(updateLifecycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) {
appUpdateViewModel.resumeInstallAfterPermission()
}
}
}
updateLifecycleOwner.lifecycle.addObserver(observer)
onDispose {
updateLifecycleOwner.lifecycle.removeObserver(observer)
}
}
if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) {
val noticeTag =
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
?: UpdateNoticeManager.noticeTag()
if (noticeTag != null) {
UpdateNoticeBanner(
latestTag = noticeTag,
onUpdate = { AppUpdateCache.showDialog() },
onDismiss = { AppUpdateCache.dismiss() },
)
}
}
if (AppUpdateCache.isDialogVisible) {
val context = LocalContext.current
val dialogState =
if (appUpdateState !is AppUpdateState.Idle) {
appUpdateState
} else {
updateNotice
}
AppUpdateDialog(
state = dialogState,
onDismiss = { AppUpdateCache.hideDialog() },
onStartUpdate = { appUpdateViewModel.startUpdate() },
onCancelDownload = { appUpdateViewModel.cancelDownload() },
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
onOpenSettings = {
launchUnknownAppSourcesSettings(context)
},
)
}
}
private fun launchUnknownAppSourcesSettings(context: Context) {
val intent =
Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}"),
).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(intent)
} catch (_: Exception) {
context.startActivity(
Intent(Settings.ACTION_SECURITY_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
}
@@ -103,6 +103,10 @@ fun ChatInputBar(
onReasoningTap: (String?) -> Unit = {},
canDisableReasoning: Boolean? = null,
supportsReasoning: Boolean? = null,
fastMode: Boolean = false,
fastSupported: Boolean = false,
isFastModeChanging: Boolean = false,
onToggleFastMode: () -> Unit = {},
) {
// Allow sending while the agent is mid-turn or awaiting approval: the
// gateway's prompt.submit busy-input policy queues it as the next turn
@@ -377,6 +381,10 @@ fun ChatInputBar(
modifier = Modifier.testTag("chat_composer_toolbar"),
canDisableReasoning = canDisableReasoning,
supportsReasoning = supportsReasoning,
fastMode = fastMode,
fastSupported = fastSupported,
isFastModeChanging = isFastModeChanging,
onToggleFastMode = onToggleFastMode,
)
// Attachment dropdown (anchored to the attach button in ComposerToolbar)
@@ -0,0 +1,257 @@
package com.m57.hermescontrol.ui.chat.components
import android.Manifest
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.OpenableColumns
import android.speech.RecognizerIntent
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
import com.m57.hermescontrol.ui.chat.SpeechInputHelper
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class ChatMediaLaunchers(
val isListening: Boolean,
val onMicTap: () -> Unit,
val onCameraTap: () -> Unit,
val onImageTap: () -> Unit,
val onFileTap: () -> Unit,
)
@Composable
fun rememberChatMediaLaunchers(
inputFieldValue: TextFieldValue,
onInputFieldValueChange: (TextFieldValue) -> Unit,
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
onAddAttachments: (List<Attachment>) -> Unit,
onShowMessage: (String) -> Unit,
launchExternalActivity: (() -> Unit) -> Unit,
context: Context = LocalContext.current,
): ChatMediaLaunchers {
var isListening by remember { mutableStateOf(false) }
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
val currentInputFieldValue by rememberUpdatedState(inputFieldValue)
val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange)
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
val currentOnShowMessage by rememberUpdatedState(onShowMessage)
val currentLaunchExternalActivity by rememberUpdatedState(launchExternalActivity)
val micListeningPrompt = stringResource(R.string.chat_mic_listening)
val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied)
val cameraErrorMsg = stringResource(R.string.chat_camera_error)
// Speech-to-text recognition launcher (issue #194)
val speechLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
ExternalActivityLifecycleGuard.externalActivityReturned()
isListening = false
if (result.resultCode == Activity.RESULT_OK) {
val spokenText =
result.data
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
?.firstOrNull()
.orEmpty()
if (spokenText.isNotBlank()) {
val currentText = currentInputFieldValue.text
val merged =
if (currentText.isBlank()) {
spokenText
} else {
"$currentText $spokenText"
}
currentOnInputFieldValueChange(ChatInputPolicy.commandFieldValue(merged))
}
}
}
// Mic permission launcher
val micPermissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
ExternalActivityLifecycleGuard.externalActivityReturned()
if (granted) {
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
isListening = true
currentLaunchExternalActivity {
try {
speechLauncher.launch(intent)
} catch (_: ActivityNotFoundException) {
isListening = false
currentOnShowMessage(sttNotAvailableMsg)
}
}
} else {
currentOnShowMessage(sttNotAvailableMsg)
}
} else {
currentOnShowMessage(sttPermissionDeniedMsg)
}
}
// Multi-file picker for attachments (issue #195)
val filePickerLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.GetMultipleContents(),
) { uris: List<Uri> ->
ExternalActivityLifecycleGuard.externalActivityReturned()
val attachments =
uris.mapNotNull { uri ->
runCatching {
var name = uri.lastPathSegment ?: "file"
var size = 0L
context.contentResolver
.query(
uri,
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
null,
null,
null,
)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
if (nameIdx >= 0 && !cursor.isNull(nameIdx)) name = cursor.getString(nameIdx)
if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) size = cursor.getLong(sizeIdx)
}
}
Attachment(
uri = uri.toString(),
name = name,
mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream",
size = size,
)
}.onFailure { error ->
Log.w("ChatScreen", "Skipping unreadable picked attachment", error)
}.getOrNull()
}
currentOnAddAttachments(attachments)
}
// Camera photo launcher (issue #195)
val cameraLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.TakePicture(),
) { success ->
ExternalActivityLifecycleGuard.externalActivityReturned()
val uri = pendingCameraUri
pendingCameraUri = null
if (success && uri != null) {
try {
val fileName =
"photo_${
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(
Date(),
)
}.jpg"
val inputStream = context.contentResolver.openInputStream(uri)
val size = inputStream?.use { it.available().toLong() } ?: 0L
currentOnAddAttachment(uri.toString(), fileName, "image/jpeg", size)
} catch (e: Exception) {
Log.e("ChatScreen", "Camera capture failed", e)
currentOnShowMessage(cameraErrorMsg)
}
}
}
val onMicTap: () -> Unit = {
if (isListening) {
isListening = false
} else if (
ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
) {
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
isListening = true
currentLaunchExternalActivity {
try {
speechLauncher.launch(intent)
} catch (_: ActivityNotFoundException) {
isListening = false
currentOnShowMessage(sttNotAvailableMsg)
}
}
} else {
currentOnShowMessage(sttNotAvailableMsg)
}
} else {
currentLaunchExternalActivity {
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
}
val onCameraTap: () -> Unit = {
try {
val timeStamp =
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val photoFile =
File.createTempFile("camera_${timeStamp}_", ".jpg", context.cacheDir)
val uri =
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
photoFile,
)
pendingCameraUri = uri
currentLaunchExternalActivity {
cameraLauncher.launch(uri)
}
} catch (e: Exception) {
Log.e("ChatScreen", "Camera launch failed", e)
}
}
val onImageTap: () -> Unit = {
currentLaunchExternalActivity {
filePickerLauncher.launch("image/*")
}
}
val onFileTap: () -> Unit = {
currentLaunchExternalActivity {
filePickerLauncher.launch("*/*")
}
}
return remember(isListening) {
ChatMediaLaunchers(
isListening = isListening,
onMicTap = onMicTap,
onCameraTap = onCameraTap,
onImageTap = onImageTap,
onFileTap = onFileTap,
)
}
}
@@ -3,16 +3,20 @@ package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip
@@ -21,6 +25,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -29,8 +35,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
@@ -60,6 +70,10 @@ fun ComposerToolbar(
modifier: Modifier = Modifier,
canDisableReasoning: Boolean? = null,
supportsReasoning: Boolean? = null,
fastMode: Boolean = false,
fastSupported: Boolean = false,
isFastModeChanging: Boolean = false,
onToggleFastMode: () -> Unit = {},
) {
var showReasoningMenu by remember { mutableStateOf(false) }
val reasoningDisabledForModel = supportsReasoning == false
@@ -109,24 +123,44 @@ fun ComposerToolbar(
.testTag("model_chip"),
)
// Reasoning chip with dropdown menu (right side, next to mic)
// Reasoning & Generation controls with dropdown menu (Option 3)
Box {
FilterChip(
selected = reasoningLevel != null,
selected = reasoningLevel != null || fastMode,
onClick = { showReasoningMenu = true },
enabled = !reasoningDisabledForModel,
enabled = isConnected,
label = {
Text(
text =
if (reasoningDisabledForModel) {
"No reasoning"
} else {
buildReasoningLabel(reasoningLevel)
},
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (fastMode) {
Icon(
imageVector = Icons.Filled.Bolt,
contentDescription = stringResource(R.string.chat_fast_mode_label),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(14.dp),
)
}
Text(
text =
if (reasoningDisabledForModel) {
if (fastMode) stringResource(R.string.chat_fast_mode_label) else "No reasoning"
} else {
val rLabel = buildReasoningLabel(reasoningLevel)
if (fastMode) {
"${stringResource(
R.string.chat_fast_mode_label,
)} · $rLabel"
} else {
rLabel
}
},
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
modifier =
Modifier
@@ -137,11 +171,74 @@ fun ComposerToolbar(
DropdownMenu(
expanded = showReasoningMenu,
onDismissRequest = { showReasoningMenu = false },
modifier = Modifier.widthIn(min = 220.dp),
) {
// ── Fast Mode Toggle Item ──
val fastAvailable = isConnected && fastSupported
DropdownMenuItem(
leadingIcon = {
Icon(
imageVector = Icons.Filled.Bolt,
contentDescription = null,
tint =
if (fastMode) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(20.dp),
)
},
text = {
Column {
Text(
text = stringResource(R.string.chat_fast_mode_label),
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (fastMode) FontWeight.SemiBold else FontWeight.Normal,
)
if (!fastSupported) {
Text(
text = stringResource(R.string.chat_fast_mode_unavailable),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
},
trailingIcon = {
if (isFastModeChanging) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
} else {
Switch(
checked = fastMode,
onCheckedChange = null, // MenuItem click owns the trigger
enabled = fastAvailable && !isFastModeChanging,
modifier =
Modifier
.scale(0.85f)
.testTag("fast_mode_switch"),
)
}
},
onClick = {
if (fastAvailable && !isFastModeChanging) {
onToggleFastMode()
}
},
enabled = fastAvailable && !isFastModeChanging,
)
HorizontalDivider()
Text(
text = "Reasoning",
text = "REASONING",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
if (canDisable == false) {
@@ -160,7 +257,6 @@ fun ComposerToolbar(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
)
}
HorizontalDivider()
val allLevels =
listOf(
"none" to "None",
@@ -0,0 +1,752 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.HourglassTop
import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SheetState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
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.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorStatus
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.ChatConnectorsUiState
import com.m57.hermescontrol.ui.chat.ConnectorActionState
import com.m57.hermescontrol.ui.chat.ConnectorUiItem
import com.m57.hermescontrol.ui.chat.ConnectorsLoadPhase
/**
* Visual elements for a connector status badge.
*/
data class ConnectorStatusVisuals(
val icon: ImageVector,
val color: Color,
val label: String,
)
@Composable
fun resolveConnectorStatusVisuals(
status: ConnectorStatus,
isConnected: Boolean,
): ConnectorStatusVisuals {
val statusColors = LocalHermesStatusColors.current
return when {
isConnected || status == ConnectorStatus.CONNECTED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.CheckCircle,
color = statusColors.success,
label = stringResource(R.string.session_integrations_status_connected),
)
}
status == ConnectorStatus.INITIATED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.HourglassTop,
color = statusColors.info,
label = stringResource(R.string.session_integrations_awaiting),
)
}
status == ConnectorStatus.EXPIRED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.Warning,
color = statusColors.warning,
label = stringResource(R.string.session_integrations_status_expired),
)
}
status == ConnectorStatus.REVOKED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.Warning,
color = statusColors.warning,
label = stringResource(R.string.session_integrations_status_revoked),
)
}
status == ConnectorStatus.FAILED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.ErrorOutline,
color = statusColors.error,
label = stringResource(R.string.session_integrations_status_failed),
)
}
status == ConnectorStatus.DISCONNECTED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.LinkOff,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
label = stringResource(R.string.session_integrations_status_disconnected),
)
}
else -> {
ConnectorStatusVisuals(
icon = Icons.AutoMirrored.Filled.HelpOutline,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
label = stringResource(R.string.session_integrations_status_unknown),
)
}
}
}
@Composable
fun mapLoadPhaseErrorMessage(loadPhase: ConnectorsLoadPhase): String? =
when (loadPhase) {
is ConnectorsLoadPhase.Error -> {
mapConnectorErrorMessage(loadPhase.error)
}
is ConnectorsLoadPhase.Unavailable -> {
stringResource(R.string.session_integrations_err_unavailable)
}
is ConnectorsLoadPhase.UnsupportedRuntime -> {
stringResource(
R.string.session_integrations_err_unsupported_runtime,
)
}
is ConnectorsLoadPhase.UnsupportedBackend -> {
stringResource(
R.string.session_integrations_err_unsupported_backend,
)
}
is ConnectorsLoadPhase.NotOwner -> {
stringResource(R.string.session_integrations_err_not_owner)
}
else -> {
null
}
}
@Composable
fun mapConnectorErrorMessage(error: ConnectorError): String =
when (error) {
is ConnectorError.InvalidParams -> {
stringResource(R.string.session_integrations_err_invalid_params)
}
is ConnectorError.NotOwner -> {
stringResource(R.string.session_integrations_err_not_owner)
}
is ConnectorError.Unavailable -> {
stringResource(R.string.session_integrations_err_unavailable)
}
is ConnectorError.UnsupportedRuntime -> {
stringResource(R.string.session_integrations_err_unsupported_runtime)
}
is ConnectorError.UnsupportedBackend -> {
stringResource(R.string.session_integrations_err_unsupported_backend)
}
is ConnectorError.RequestFailed -> {
stringResource(R.string.session_integrations_err_request_failed)
}
is ConnectorError.InvalidResponse, is ConnectorError.MalformedEnvelope -> {
stringResource(R.string.session_integrations_err_invalid_response)
}
is ConnectorError.NetworkError -> {
stringResource(R.string.session_integrations_err_network)
}
is ConnectorError.Other -> {
stringResource(R.string.session_integrations_err_generic)
}
}
@Composable
fun mapActionErrorMessage(rawError: String?): String =
when {
rawError.isNullOrBlank() -> {
stringResource(R.string.session_integrations_err_generic)
}
rawError.contains("not owned", ignoreCase = true) -> {
stringResource(R.string.session_integrations_err_not_owner)
}
rawError.contains(
"No active session",
ignoreCase = true,
) -> {
stringResource(R.string.session_integrations_no_session)
}
rawError.contains("Invalid connector slug", ignoreCase = true) ||
rawError.contains("Invalid params", ignoreCase = true) -> {
stringResource(R.string.session_integrations_err_invalid_params)
}
rawError.contains("authorization URL", ignoreCase = true) ||
rawError.contains("unexpected status", ignoreCase = true) -> {
stringResource(R.string.session_integrations_err_invalid_response)
}
rawError.contains("authorization failed", ignoreCase = true) -> {
stringResource(R.string.session_integrations_err_request_failed)
}
rawError.contains("browser", ignoreCase = true) -> {
stringResource(R.string.session_integrations_err_browser_launch)
}
else -> {
stringResource(R.string.session_integrations_err_generic)
}
}
/**
* Native bottom sheet for session integrations / connectors (issue #1091).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SessionIntegrationsSheet(
uiState: ChatConnectorsUiState,
sessionId: String? = uiState.sessionId,
onDismiss: () -> Unit,
onRefresh: () -> Unit,
onConnect: (slug: String, reconnect: Boolean) -> Unit,
onClearError: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
if (!uiState.isVisible) return
val effectiveSessionId = sessionId ?: uiState.sessionId
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
modifier = modifier.testTag("session_integrations_sheet"),
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
SessionIntegrationsContent(
uiState = uiState,
sessionId = effectiveSessionId,
onDismiss = onDismiss,
onRefresh = onRefresh,
onConnect = onConnect,
onClearError = onClearError,
)
}
}
/**
* Stateless content composable for testing and decoupled layout.
*/
@Composable
fun SessionIntegrationsContent(
uiState: ChatConnectorsUiState,
sessionId: String? = uiState.sessionId,
onDismiss: () -> Unit,
onRefresh: () -> Unit,
onConnect: (slug: String, reconnect: Boolean) -> Unit,
onClearError: () -> Unit,
modifier: Modifier = Modifier,
) {
val statusColors = LocalHermesStatusColors.current
val effectiveSessionId = sessionId ?: uiState.sessionId
val isSessionBlank = effectiveSessionId.isNullOrBlank()
Column(
modifier =
modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
) {
// Top Header
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.session_integrations_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.testTag("session_integrations_title"),
)
Spacer(modifier = Modifier.height(2.dp))
val subtitleText =
if (!isSessionBlank) {
val displayId =
if (effectiveSessionId.length >
12
) {
"${effectiveSessionId.take(12)}…"
} else {
effectiveSessionId
}
stringResource(R.string.session_integrations_subtitle, displayId)
} else {
stringResource(R.string.session_integrations_no_session)
}
Text(
text = subtitleText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag("session_integrations_subtitle"),
)
}
IconButton(
onClick = onRefresh,
enabled = !uiState.isLoading && !isSessionBlank,
modifier = Modifier.testTag("session_integrations_refresh_button"),
) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.session_integrations_refresh),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(
onClick = onDismiss,
modifier = Modifier.testTag("session_integrations_close_button"),
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.session_integrations_close),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Action Error Banner
if (uiState.errorMessage != null) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.testTag("session_integrations_action_error_banner"),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.errorContainer,
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.ErrorOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = mapActionErrorMessage(uiState.errorMessage),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = onClearError,
modifier = Modifier.size(48.dp),
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.session_integrations_close),
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(24.dp),
)
}
}
}
}
// Awaiting Authorization Banner (manual Refresh status affordance)
val awaitingSlug =
(uiState.actionState as? ConnectorActionState.AwaitingAuthorization)?.slug
if (awaitingSlug != null) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.testTag("session_integrations_awaiting_banner"),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.OpenInBrowser,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.session_integrations_awaiting),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.session_integrations_awaiting_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(modifier = Modifier.height(8.dp))
FilledTonalButton(
onClick = onRefresh,
modifier =
Modifier
.align(Alignment.End)
.testTag("session_integrations_refresh_status_button"),
) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = null,
modifier = Modifier.size(14.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = stringResource(R.string.session_integrations_refresh_status),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
// Content States
when {
isSessionBlank || (uiState.loadPhase is ConnectorsLoadPhase.Initial && uiState.items.isEmpty()) -> {
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 180.dp)
.testTag("session_integrations_no_session_state"),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(24.dp),
) {
Icon(
imageVector = Icons.Filled.LinkOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(40.dp),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.session_integrations_no_session),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
uiState.loadPhase is ConnectorsLoadPhase.Loading -> {
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 180.dp)
.testTag("session_integrations_loading"),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(24.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.session_integrations_connecting),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
uiState.loadPhase !is ConnectorsLoadPhase.Loading &&
uiState.loadPhase !is ConnectorsLoadPhase.Refreshing &&
uiState.loadPhase !is ConnectorsLoadPhase.Loaded &&
uiState.items.isEmpty() -> {
// Non-loaded error / unavailable / unsupported / not owner terminal states
val errorMessage = mapLoadPhaseErrorMessage(uiState.loadPhase) ?: ""
val canRetry = (uiState.loadPhase as? ConnectorsLoadPhase.Error)?.canRetry ?: false
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 180.dp)
.testTag("session_integrations_error"),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(24.dp),
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = statusColors.warning,
modifier = Modifier.size(36.dp),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = errorMessage,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.testTag("session_integrations_error_text"),
)
if (canRetry) {
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = onRefresh,
modifier = Modifier.testTag("session_integrations_retry_button"),
) {
Text(stringResource(R.string.session_integrations_retry))
}
}
}
}
}
uiState.items.isEmpty() -> {
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 180.dp)
.testTag("session_integrations_empty"),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(24.dp),
) {
Icon(
imageVector = Icons.Filled.Extension,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(40.dp),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.session_integrations_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
else -> {
// List of items
LazyColumn(
modifier =
Modifier
.fillMaxWidth()
.testTag("session_integrations_list"),
) {
if (uiState.loadPhase is ConnectorsLoadPhase.Refreshing) {
item {
LinearProgressIndicator(
modifier =
Modifier
.fillMaxWidth()
.testTag("session_integrations_refreshing_indicator"),
)
}
}
items(
items = uiState.items,
key = { it.slug },
) { item ->
ConnectorRow(
item = item,
actionState = uiState.actionState,
isLoading = uiState.isLoading,
onConnect = onConnect,
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
}
}
}
}
}
@Composable
fun ConnectorRow(
item: ConnectorUiItem,
actionState: ConnectorActionState,
isLoading: Boolean,
onConnect: (slug: String, reconnect: Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val visuals = resolveConnectorStatusVisuals(item.status, item.isConnected)
val isCurrentConnecting =
actionState is ConnectorActionState.Connecting && actionState.slug == item.slug
val isAnyActionPending =
actionState is ConnectorActionState.Connecting || actionState is ConnectorActionState.AwaitingAuthorization
val needsReconnect =
item.isConnected ||
item.status == ConnectorStatus.EXPIRED ||
item.status == ConnectorStatus.REVOKED
Row(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 12.dp)
.testTag("session_integrations_item_${item.slug}"),
verticalAlignment = Alignment.CenterVertically,
) {
// Accessible Status Indicator Icon
Box(
modifier =
Modifier
.size(28.dp)
.clip(CircleShape)
.background(visuals.color.copy(alpha = 0.15f))
.testTag("session_integrations_status_${item.slug}"),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = visuals.icon,
contentDescription = visuals.label,
tint = visuals.color,
modifier = Modifier.size(16.dp),
)
}
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = visuals.label,
style = MaterialTheme.typography.bodySmall,
color = visuals.color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(12.dp))
if (isCurrentConnecting) {
CircularProgressIndicator(
modifier =
Modifier
.size(20.dp)
.testTag("session_integrations_spinner_${item.slug}"),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
} else {
val buttonText =
if (needsReconnect) {
stringResource(R.string.session_integrations_reconnect)
} else {
stringResource(R.string.session_integrations_connect)
}
OutlinedButton(
onClick = { onConnect(item.slug, needsReconnect) },
enabled = item.isEnabled && !isAnyActionPending && !isLoading,
modifier = Modifier.testTag("session_integrations_action_${item.slug}"),
) {
Text(
text = buttonText,
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}
@@ -4,20 +4,25 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.AltRoute
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
@@ -32,11 +37,14 @@ import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
@@ -45,6 +53,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -63,6 +72,7 @@ import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.SubagentIndicator
import com.m57.hermescontrol.ui.chat.SubagentTranscriptUiState
import com.m57.hermescontrol.ui.chat.TodoItem
/**
@@ -73,6 +83,10 @@ import com.m57.hermescontrol.ui.chat.TodoItem
fun SubagentInspectionSheet(
indicators: List<SubagentIndicator> = emptyList(),
todos: List<TodoItem> = emptyList(),
inspectingSubagentId: String? = null,
subagentTranscript: SubagentTranscriptUiState? = null,
onToggleTranscript: ((String) -> Unit)? = null,
onRetryTranscript: (() -> Unit)? = null,
onSteerSubagent: ((SubagentIndicator, String) -> Unit)? = null,
onStopSubagent: ((SubagentIndicator) -> Unit)? = null,
onDismiss: () -> Unit,
@@ -197,7 +211,7 @@ fun SubagentInspectionSheet(
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "SUBAGENTS",
text = stringResource(R.string.subagent_subagents_header).uppercase(),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
@@ -211,8 +225,14 @@ fun SubagentInspectionSheet(
"subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}"
},
) { _, indicator ->
val isInspectingThis =
indicator.subagentId != null && indicator.subagentId == inspectingSubagentId
InspectionItemCard(
indicator = indicator,
isInspectingTranscript = isInspectingThis,
transcriptState = if (isInspectingThis) subagentTranscript else null,
onToggleTranscript = onToggleTranscript,
onRetryTranscript = onRetryTranscript,
onSteer =
if (onSteerSubagent != null) {
{ message -> onSteerSubagent(indicator, message) }
@@ -448,6 +468,10 @@ private fun TodoInspectionCard(
@Composable
private fun InspectionItemCard(
indicator: SubagentIndicator,
isInspectingTranscript: Boolean = false,
transcriptState: SubagentTranscriptUiState? = null,
onToggleTranscript: ((String) -> Unit)? = null,
onRetryTranscript: (() -> Unit)? = null,
onSteer: ((String) -> Unit)? = null,
onStop: (() -> Unit)? = null,
) {
@@ -619,6 +643,44 @@ private fun InspectionItemCard(
}
}
// Full Rolling Transcript Disclosure (issue #1089)
if (!indicator.subagentId.isNullOrBlank() && onToggleTranscript != null) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = { onToggleTranscript(indicator.subagentId) },
modifier =
Modifier
.fillMaxWidth()
.testTag("subagent_transcript_toggle_${indicator.subagentId}"),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
) {
Icon(
imageVector = if (isInspectingTranscript) Icons.Filled.ExpandLess else Icons.Filled.Terminal,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text =
if (isInspectingTranscript) {
stringResource(R.string.subagent_hide_transcript)
} else {
stringResource(R.string.subagent_inspect_transcript)
},
style = MaterialTheme.typography.labelMedium,
)
}
AnimatedVisibility(visible = isInspectingTranscript) {
Column(modifier = Modifier.padding(top = 8.dp)) {
SubagentTranscriptView(
state = transcriptState,
onRetry = onRetryTranscript,
)
}
}
}
// Interactive Controls for active subagent (issue #1030)
if (indicator.isRunning || indicator.isSteered) {
Spacer(modifier = Modifier.height(10.dp))
@@ -721,3 +783,175 @@ private fun InspectionItemCard(
}
}
}
@Composable
private fun SubagentTranscriptView(
state: SubagentTranscriptUiState?,
onRetry: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.fillMaxWidth().testTag("subagent_transcript_container"),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerLowest,
) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
when {
state == null || (state.isLoading && state.text.isEmpty()) -> {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.subagent_transcript_loading),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
state.error != null && state.text.isEmpty() -> {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = state.error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
if (onRetry != null) {
OutlinedButton(
onClick = onRetry,
modifier = Modifier.testTag("subagent_transcript_retry"),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
) {
Text(
text = stringResource(R.string.subagent_transcript_retry),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
state.isEmpty -> {
Text(
text = stringResource(R.string.subagent_transcript_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.testTag("subagent_transcript_empty"),
)
}
else -> {
// Header badges (truncation note & background loading indicator)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.subagent_live_transcript),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.outline,
fontSize = 9.sp,
)
if (state.isTruncated) {
Text(
text = stringResource(R.string.subagent_transcript_truncated),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
fontSize = 8.sp,
)
}
}
if (state.isLoading) {
LinearProgressIndicator(
modifier =
Modifier
.fillMaxWidth()
.height(2.dp),
color = MaterialTheme.colorScheme.primary,
)
}
val scrollState = rememberScrollState()
LaunchedEffect(state.text) {
scrollState.animateScrollTo(scrollState.maxValue)
}
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 60.dp, max = 240.dp)
.verticalScroll(scrollState),
) {
Text(
text = state.text,
style =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.testTag("subagent_transcript_text"),
)
}
if (state.error != null && onRetry != null) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = state.error,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = onRetry,
modifier = Modifier.size(24.dp),
) {
Icon(
imageVector = Icons.Filled.Autorenew,
contentDescription = stringResource(R.string.subagent_transcript_retry),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,394 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Pin
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
/**
* Interactive prompt cards for mid-turn Credential Vault operations (issue #1090).
* Rendered inline in chat (matching ClarifyBubble pattern) to avoid blocking dialog scrims.
*/
@Composable
fun VaultUnlockCard(
prompt: VaultUnlockPromptUi,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var password by remember(prompt.requestId) { mutableStateOf("") }
var passwordVisible by remember(prompt.requestId) { mutableStateOf(false) }
val displayName = prompt.displayName ?: prompt.backend ?: "Password Manager"
Surface(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.testTag("vault_unlock_card"),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
),
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.vault_unlock_title, displayName),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = stringResource(R.string.vault_unlock_desc, displayName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.vault_unlock_password_label)) },
modifier =
Modifier
.fillMaxWidth()
.testTag("vault_unlock_password_input"),
singleLine = true,
visualTransformation =
if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector =
if (passwordVisible) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription = null,
)
}
},
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.testTag("vault_unlock_dismiss"),
) {
Text(stringResource(R.string.vault_unlock_dismiss))
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
if (password.isNotBlank()) {
onConfirm(password)
}
},
enabled = password.isNotBlank(),
modifier = Modifier.testTag("vault_unlock_confirm"),
) {
Text(stringResource(R.string.vault_unlock_action))
}
}
}
}
}
@Composable
fun VaultSaveLoginCard(
prompt: VaultSaveLoginPromptUi,
onConfirm: (identifier: String, password: String) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var identifier by remember(prompt.requestId) { mutableStateOf("") }
var password by remember(prompt.requestId) { mutableStateOf("") }
var passwordVisible by remember(prompt.requestId) { mutableStateOf(false) }
val siteLabel = prompt.site ?: prompt.origin ?: "this site"
Surface(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.testTag("vault_save_login_card"),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
),
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Key,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.vault_save_login_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = stringResource(R.string.vault_save_login_desc, siteLabel),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = identifier,
onValueChange = { identifier = it },
label = { Text(stringResource(R.string.vault_save_login_identifier)) },
modifier =
Modifier
.fillMaxWidth()
.testTag("vault_save_login_identifier_input"),
singleLine = true,
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Email,
),
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.vault_save_login_password)) },
modifier =
Modifier
.fillMaxWidth()
.testTag("vault_save_login_password_input"),
singleLine = true,
visualTransformation =
if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector =
if (passwordVisible) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription = null,
)
}
},
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.testTag("vault_save_login_dismiss"),
) {
Text(stringResource(R.string.vault_save_login_dismiss))
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
if (identifier.isNotBlank() && password.isNotBlank()) {
onConfirm(identifier.trim(), password)
}
},
enabled = identifier.isNotBlank() && password.isNotBlank(),
modifier = Modifier.testTag("vault_save_login_confirm"),
) {
Text(stringResource(R.string.vault_save_login_action))
}
}
}
}
}
@Composable
fun VaultCodeCard(
prompt: VaultCodePromptUi,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var code by remember(prompt.requestId) { mutableStateOf("") }
val siteLabel = prompt.site ?: "site"
val desc = prompt.hint?.takeIf { it.isNotBlank() } ?: stringResource(R.string.vault_code_desc, siteLabel)
Surface(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.testTag("vault_code_card"),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
),
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Pin,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.vault_code_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = desc,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = code,
onValueChange = { code = it },
label = { Text(stringResource(R.string.vault_code_label)) },
placeholder = { Text("123456") },
modifier =
Modifier
.fillMaxWidth()
.testTag("vault_code_input"),
singleLine = true,
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Number,
),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.testTag("vault_code_dismiss"),
) {
Text(stringResource(R.string.vault_code_dismiss))
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
if (code.isNotBlank()) {
onConfirm(code.trim())
}
},
enabled = code.isNotBlank(),
modifier = Modifier.testTag("vault_code_confirm"),
) {
Text(stringResource(R.string.vault_code_action))
}
}
}
}
}
@@ -40,6 +40,7 @@ import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.InlineAttachment
import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.chat.TokenEstimator
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -171,6 +172,44 @@ internal fun FullBleedAgentMessage(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.testTag("fullbleed_finish_time"),
)
if (message.tokenCount != null && message.tokenCount > 0) {
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "•",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text =
stringResource(
R.string.chat_msg_tokens,
TokenEstimator.formatTokenCount(message.tokenCount),
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.testTag("fullbleed_token_count"),
)
}
if (message.tps != null && message.tps > 0.0) {
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "•",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text =
stringResource(
R.string.chat_msg_tps,
TokenEstimator.formatTps(message.tps),
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.testTag("fullbleed_tps"),
)
}
}
}
}
@@ -28,9 +28,15 @@ import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.ToolCallDivider
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
import com.m57.hermescontrol.ui.chat.components.VaultCodeCard
import com.m57.hermescontrol.ui.chat.components.VaultSaveLoginCard
import com.m57.hermescontrol.ui.chat.components.VaultUnlockCard
import com.m57.hermescontrol.ui.chat.toolCallMilestones
import com.m57.hermescontrol.ui.common.EmptyState
@@ -69,6 +75,15 @@ fun FullBleedChatList(
onRespondClarify: ((String) -> Unit)? = null,
onRespondClarifyBatch: ((Map<String, String>) -> Unit)? = null,
onDismissClarify: (() -> Unit)? = null,
vaultUnlockPrompt: VaultUnlockPromptUi? = null,
onRespondVaultUnlock: ((String) -> Unit)? = null,
onDismissVaultUnlock: (() -> Unit)? = null,
vaultSaveLoginPrompt: VaultSaveLoginPromptUi? = null,
onRespondVaultSaveLogin: ((String, String) -> Unit)? = null,
onDismissVaultSaveLogin: (() -> Unit)? = null,
vaultCodePrompt: VaultCodePromptUi? = null,
onRespondVaultCode: ((String) -> Unit)? = null,
onDismissVaultCode: (() -> Unit)? = null,
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {},
savingAttachmentPath: String? = null,
openingAttachmentPath: String? = null,
@@ -85,7 +100,7 @@ fun FullBleedChatList(
)
}
} else {
val toolMilestones = toolCallMilestones(messages)
val toolMilestones = remember(messages) { toolCallMilestones(messages) }
val turns =
remember(messages, streamingMessage) {
groupIntoTurnsWithStreaming(messages, streamingMessage)
@@ -148,14 +163,12 @@ fun FullBleedChatList(
}
}
var entryIndex = 0
turns.forEach { turn ->
when (turn) {
is ChatTurn.User -> {
// Eager captures: item lambda reads these at
// composition time (lazy), so capture now.
val userMessage = turn.message
val milestone = toolMilestones[entryIndex]
item(key = "user-${userMessage.id}") {
Column(modifier = Modifier.padding(bottom = 12.dp)) {
renderChatBubble(
@@ -170,12 +183,8 @@ fun FullBleedChatList(
openingAttachmentPath = openingAttachmentPath,
onImageClick = onImageClick,
)
milestone?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
}
}
}
entryIndex++
}
is ChatTurn.Agent -> {
@@ -210,7 +219,6 @@ fun FullBleedChatList(
val hoistedReasoning =
turnReasoning != null &&
proseMessage.id == turnReasoning.message.id
val milestone = toolMilestones[entryIndex]
item(key = "prose-${proseMessage.id}") {
Column(modifier = Modifier.padding(bottom = 12.dp)) {
if (proseMessage.isStreaming && typingEffectEnabled) {
@@ -248,18 +256,14 @@ fun FullBleedChatList(
onImageClick = onImageClick,
)
}
milestone?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
}
}
}
firstProseSeen = true
entryIndex++
}
is AgentEntry.ToolRow -> {
val toolMessage = entry.message
val milestone = toolMilestones[entryIndex]
val milestone = toolMilestones[toolMessage.id]
item(key = "tool-${toolMessage.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
FullBleedToolRow(toolMessage)
@@ -268,7 +272,6 @@ fun FullBleedChatList(
}
}
}
entryIndex++
}
is AgentEntry.SystemEvent -> {
@@ -288,7 +291,6 @@ fun FullBleedChatList(
}
}
}
entryIndex++
}
}
}
@@ -307,6 +309,38 @@ fun FullBleedChatList(
)
}
}
if (vaultUnlockPrompt != null) {
item(key = "vault_unlock_card") {
VaultUnlockCard(
prompt = vaultUnlockPrompt,
onConfirm = { password -> onRespondVaultUnlock?.invoke(password) },
onDismiss = { onDismissVaultUnlock?.invoke() },
)
}
}
if (vaultSaveLoginPrompt != null) {
item(key = "vault_save_login_card") {
VaultSaveLoginCard(
prompt = vaultSaveLoginPrompt,
onConfirm = { identifier, password ->
onRespondVaultSaveLogin?.invoke(identifier, password)
},
onDismiss = { onDismissVaultSaveLogin?.invoke() },
)
}
}
if (vaultCodePrompt != null) {
item(key = "vault_code_card") {
VaultCodeCard(
prompt = vaultCodePrompt,
onConfirm = { code -> onRespondVaultCode?.invoke(code) },
onDismiss = { onDismissVaultCode?.invoke() },
)
}
}
}
}
}
@@ -0,0 +1,342 @@
package com.m57.hermescontrol.ui.chat.markdown
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.theme.SearchHighlightColors
import com.m57.hermescontrol.util.BidiUtils
object MarkdownInlineStyler {
private val URL_PATTERN =
Regex(
"""https?://[^\s<>'"\)\],;]+(?<![.\),;])""",
RegexOption.IGNORE_CASE,
)
fun parseInline(
text: String,
textColor: Color,
searchQuery: String,
isCurrentMatch: Boolean,
linkColor: Color,
highlights: SearchHighlightColors,
isRtl: Boolean = BidiUtils.isRtlText(text),
): AnnotatedString =
parseInlineSource(
text =
splitInlineMath(text).joinToString(separator = "") { segment ->
when (segment) {
is InlineMathSegment.Math -> segment.latex
is InlineMathSegment.Text -> segment.value
}
},
textColor = textColor,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
isRtl = isRtl,
)
fun parseInlineSource(
text: String,
textColor: Color,
searchQuery: String,
isCurrentMatch: Boolean,
linkColor: Color,
highlights: SearchHighlightColors,
isRtl: Boolean = BidiUtils.isRtlText(text),
): AnnotatedString {
val searchHighlightColor =
if (isCurrentMatch) {
highlights.currentSearchBackground to highlights.currentSearchForeground
} else {
highlights.searchBackground to highlights.searchForeground
}
return buildAnnotatedString {
var i = 0
val src = text
while (i < src.length) {
when {
// ***bold italic***
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(toAppend)
}
i = end + 3
} else {
append(src[i])
i++
}
}
// **bold**
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(toAppend)
}
i = end + 2
} else {
append(src[i])
i++
}
}
// ~~strike~~
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(toAppend)
}
i = end + 2
} else {
append(src[i])
i++
}
}
// *italic*
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(toAppend)
}
i = end + 1
} else {
append(src[i])
i++
}
}
// ==highlight==
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(toAppend)
}
i = end + 2
} else {
append(src[i])
i++
}
}
// ^superscript^
src.startsWith("^", i) -> {
val end = src.indexOf('^', i + 1)
if (end != -1 && end > i + 1) {
withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) {
append(src.substring(i + 1, end))
}
i = end + 1
} else {
append(src[i])
i++
}
}
// ~subscript~ (single tilde; ~~ handled above)
src.startsWith("~", i) -> {
val end = src.indexOf('~', i + 1)
if (end != -1 && end > i + 1) {
withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) {
append(src.substring(i + 1, end))
}
i = end + 1
} else {
append(src[i])
i++
}
}
// <kbd>key</kbd>
src.startsWith("<kbd>", i) -> {
val end = src.indexOf("</kbd>", i)
if (end != -1) {
val raw = src.substring(i + 5, end)
val toAppend = if (isRtr(isRtl)) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
background = textColor.copy(alpha = 0.12f),
),
) {
append(toAppend)
}
i = end + 6
} else {
append(src[i])
i++
}
}
// [^ref] footnote marker -> superscript
src.startsWith("[^", i) -> {
val close = src.indexOf(']', i)
if (close != -1) {
val id = src.substring(i + 2, close)
withStyle(
SpanStyle(
baselineShift = BaselineShift.Superscript,
color = linkColor,
fontWeight = FontWeight.Bold,
),
) {
append("[$id]")
}
i = close + 1
} else {
append(src[i])
i++
}
}
// [text](url)
src.startsWith("[", i) -> {
val close = src.indexOf(']', i)
if (close != -1 && close + 1 < src.length && src[close + 1] == '(') {
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(
SpanStyle(
color = linkColor,
textDecoration = TextDecoration.Underline,
),
) {
append(labelToAppend)
}
pop()
i = urlEnd + 1
} else {
append(src[i])
i++
}
} else {
append(src[i])
i++
}
}
// `inline code`
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,
fontSize = 13.sp,
background = textColor.copy(alpha = 0.08f),
),
) {
append(toAppend)
}
i = end + 1
} else {
append(src[i])
i++
}
}
// bare URL
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(
color = linkColor,
textDecoration = TextDecoration.Underline,
),
) {
append(urlToAppend)
}
pop()
i = match.range.last + 1
}
// Plain text / words in RTL
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) -> {
withStyle(
SpanStyle(
background = searchHighlightColor.first,
color = searchHighlightColor.second,
),
) {
append(src.substring(i, i + searchQuery.length))
}
i += searchQuery.length
}
else -> {
append(src[i])
i++
}
}
}
}
}
private fun isRtr(isRtl: Boolean): Boolean = isRtl
}
@@ -0,0 +1,113 @@
package com.m57.hermescontrol.ui.chat.markdown
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.LayoutDirection
import com.hrm.latex.renderer.measure.LatexMeasurerState
import com.hrm.latex.renderer.model.LatexConfig
import com.hrm.latex.renderer.model.LatexTheme
import com.m57.hermescontrol.theme.SearchHighlightColors
import com.m57.hermescontrol.util.BidiUtils
@Composable
fun MarkdownInlineText(
text: String,
textColor: Color,
latexMeasurer: LatexMeasurerState,
style: TextStyle,
searchQuery: String,
isCurrentMatch: Boolean,
linkColor: Color,
highlights: SearchHighlightColors,
modifier: 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,
)
val processedText =
remember(text, isRtl) {
if (isRtl) BidiUtils.anchorTrailingRtl(text) else text
}
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) {
MarkdownInlineStyler.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(
MarkdownInlineStyler.parseInlineSource(
text = segment.value,
textColor = textColor,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
isRtl = isRtl,
),
)
}
}
}
}
Text(
text = annotated,
inlineContent = inlineContent,
color = textColor,
style = resolvedStyle,
modifier = modifier,
)
}
}
@@ -0,0 +1,29 @@
package com.m57.hermescontrol.ui.chat.markdown
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.GatewayFileClient
object MarkdownMediaResolver {
fun resolveImageUrl(uri: String): String {
val trimmed = uri.trim()
if (trimmed.isBlank()) return uri
if (trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.startsWith("data:image/")) {
return trimmed
}
val baseUrl = runCatching { AuthManager.getBaseUrl() }.getOrDefault("")
val token = runCatching { AuthManager.getToken() }.getOrNull().orEmpty()
val mediaUrl = GatewayFileClient.buildMediaUrl(baseUrl, token, trimmed)
if (mediaUrl != null) return mediaUrl
if (baseUrl.isNotBlank() && (trimmed.startsWith("/api/") || trimmed.startsWith("api/"))) {
val cleanPath = if (trimmed.startsWith("/")) trimmed else "/$trimmed"
val sep = if (cleanPath.contains("?")) "&" else "?"
return if (token.isNotBlank() && !cleanPath.contains("token=")) {
"${baseUrl.trimEnd('/')}$cleanPath${sep}token=$token"
} else {
"${baseUrl.trimEnd('/')}$cleanPath"
}
}
return uri
}
}
@@ -0,0 +1,99 @@
package com.m57.hermescontrol.ui.chat.markdown
sealed interface MdBlock {
data class Code(
val code: String,
val language: String? = null,
) : MdBlock
data class Math(
val latex: String,
) : MdBlock
data class Heading(
val level: Int,
val text: String,
) : MdBlock
data class Bullet(
val text: String,
val level: Int = 0,
) : MdBlock
data class Task(
val checked: Boolean,
val text: String,
val level: Int = 0,
) : MdBlock
data class Ordered(
val index: Int,
val text: String,
val level: Int = 0,
) : MdBlock
data class Image(
val uri: String,
val alt: String = "",
) : MdBlock
data class Video(
val uri: String,
val alt: String = "",
) : MdBlock
data class Quote(
val text: String,
) : MdBlock
data class Paragraph(
val text: String,
) : MdBlock
data class Table(
val header: List<String>,
val alignments: List<TableAlign>,
val rows: List<List<String>>,
) : MdBlock
object Hr : MdBlock
data class DefList(
val items: List<DefItem>,
) : MdBlock
data class Footnotes(
val notes: List<FnNote>,
) : MdBlock
}
data class DefItem(
val term: String,
val definitions: List<String>,
)
data class Footnote(
val id: String,
val text: String,
)
data class FnNote(
val id: String,
val text: String,
)
enum class TableAlign {
LEFT,
CENTER,
RIGHT,
}
sealed interface InlineMathSegment {
data class Text(
val value: String,
) : InlineMathSegment
data class Math(
val latex: String,
) : InlineMathSegment
}
@@ -0,0 +1,539 @@
package com.m57.hermescontrol.ui.chat.markdown
private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""")
private val TASK_LINE_RE = Regex("""^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$""")
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 val IMAGE_RE = Regex("""^!\[([^\]]*)\]\(([^)\s]+)\s*\)""")
/** Splits `$…$` without treating escaped dollars or inline-code contents as math. */
fun splitInlineMath(text: String): List<InlineMathSegment> {
val segments = mutableListOf<InlineMathSegment>()
var plainStart = 0
var i = 0
while (i < text.length) {
if (text[i] == '`' && !text.isEscaped(i)) {
val codeEnd = text.indexOf('`', i + 1)
i = if (codeEnd == -1) text.length else codeEnd + 1
continue
}
if (text.startsWith("\\(", i)) {
val end = text.indexOf("\\)", i + 2)
if (end != -1 && end > i + 2) {
if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i)))
segments.add(InlineMathSegment.Math(text.substring(i + 2, end)))
i = end + 2
plainStart = i
continue
}
}
if (text[i] != '$' || text.isEscaped(i) || text.getOrNull(i + 1) == '$') {
i++
continue
}
var end = i + 1
while (end < text.length && (text[end] != '$' || text.isEscaped(end))) end++
if (end >= text.length || end == i + 1 || text[end - 1].isWhitespace()) {
i++
continue
}
if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i)))
segments.add(InlineMathSegment.Math(text.substring(i + 1, end)))
i = end + 1
plainStart = i
}
if (plainStart < text.length) segments.add(InlineMathSegment.Text(text.substring(plainStart)))
return segments.ifEmpty { listOf(InlineMathSegment.Text(text)) }
}
private fun String.isEscaped(index: Int): Boolean {
var slashes = 0
var i = index - 1
while (i >= 0 && this[i] == '\\') {
slashes++
i--
}
return slashes % 2 == 1
}
private fun tryParseDisplayMath(
lines: List<String>,
start: Int,
): Pair<MdBlock.Math, Int>? {
val first = lines[start].trim()
if (first.startsWith("\\[")) {
if (first.length > 4 && first.endsWith("\\]")) {
val latex = first.substring(2, first.length - 2).trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 }
}
if (first != "\\[") return null
val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "\\]" } ?: return null
val latex = lines.subList(start + 1, end).joinToString("\n").trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 }
}
if (!first.startsWith("$$")) return null
if (first.length > 4 && first.endsWith("$$")) {
val latex = first.substring(2, first.length - 2).trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 }
}
if (first != "$$") return null
val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "$$" } ?: return null
val latex = lines.subList(start + 1, end).joinToString("\n").trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 }
}
/**
* Splits source text into Markdown blocks. Fenced code blocks (```...```) are extracted first;
* everything else is grouped into headings, lists, tables, rules, footnotes, or paragraphs.
*/
fun parseBlocks(src: String): List<MdBlock> {
val lines = src.lines()
val blocks = mutableListOf<MdBlock>()
val footnotes = mutableListOf<Footnote>()
var i = 0
while (i < lines.size) {
val line = lines[i]
// Footnote definition: [^id]: text (collected, not rendered inline)
val fnMatch = FN_DEF_RE.matchAt(line, 0)
if (fnMatch != null) {
footnotes.add(Footnote(fnMatch.groupValues[1], fnMatch.groupValues[2]))
i++
continue
}
val displayMath = tryParseDisplayMath(lines, i)
if (displayMath != null) {
blocks.add(displayMath.first)
i = displayMath.second
continue
}
val codeFence = parseCodeFenceStart(line)
when {
codeFence != null -> {
val end = (i + 1 until lines.size).firstOrNull { isCodeFenceEnd(lines[it], codeFence) }
if (end != null) {
blocks.add(
MdBlock.Code(
code = lines.subList(i + 1, end).joinToString("\n"),
language = codeFence.language,
),
)
i = end + 1
} else {
blocks.add(
MdBlock.Code(
code = lines.subList(i + 1, lines.size).joinToString("\n"),
language = codeFence.language,
),
)
i = lines.size
}
}
line.isBlank() -> {
i++
}
isHorizontalRule(line) -> {
blocks.add(MdBlock.Hr)
i++
}
// Heading: requires a space after the '#' run so "#572" stays a paragraph
isValidHeading(line) -> {
val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6)
blocks.add(MdBlock.Heading(level, line.substring(level).trim()))
i++
}
isTableStart(lines, i) -> {
val (header, alignments, body) = parseTable(lines, i)
blocks.add(MdBlock.Table(header, alignments, body))
i += body.size + 2
}
line.startsWith(">") -> {
val quote = mutableListOf<String>()
while (i < lines.size && lines[i].startsWith(">")) {
quote.add(lines[i].removePrefix(">").trim())
i++
}
blocks.add(MdBlock.Quote(quote.joinToString("\n")))
}
// Definition list: term line followed by one+ ": definition" lines
isDefListStart(lines, i) -> {
val term = line.trim()
val defs = mutableListOf<String>()
i++
while (i < lines.size && lines[i].trim().startsWith(":")) {
defs.add(lines[i].trim().removePrefix(":").trim())
i++
}
blocks.add(MdBlock.DefList(listOf(DefItem(term, defs))))
}
LIST_ITEM_LINE_RE.matches(line) -> {
val (listBlocks, nextIndex) = parseList(lines, i)
blocks.addAll(listBlocks)
i = nextIndex
}
// Standalone Markdown image: ![alt](uri) on its own line.
line.isNotBlank() && tryParseVideo(line) != null -> {
blocks.add(tryParseVideo(line)!!)
i++
}
line.isNotBlank() && tryParseImage(line) != null -> {
blocks.add(tryParseImage(line)!!)
i++
}
else -> {
i = fallthroughToParagraph(lines, i, blocks)
}
}
}
if (footnotes.isNotEmpty()) {
blocks.add(MdBlock.Footnotes(footnotes.map { FnNote(it.id, it.text) }))
}
return blocks
}
private sealed interface ParsedItem {
val indent: Int
val continuationLines: MutableList<String>
data class Bullet(
override val indent: Int,
val text: String,
override val continuationLines: MutableList<String> = mutableListOf(),
) : ParsedItem
data class Task(
override val indent: Int,
val checked: Boolean,
val text: String,
override val continuationLines: MutableList<String> = mutableListOf(),
) : ParsedItem
data class Ordered(
override val indent: Int,
val rawNumber: Int,
val text: String,
override val continuationLines: MutableList<String> = mutableListOf(),
) : ParsedItem
}
private fun computeIndent(prefix: String): Int {
var count = 0
for (ch in prefix) {
if (ch == '\t') {
count += 4 - (count % 4)
} else {
count++
}
}
return count
}
private fun parseList(
lines: List<String>,
startIndex: Int,
): Pair<List<MdBlock>, Int> {
val items = mutableListOf<ParsedItem>()
var j = startIndex
while (j < lines.size) {
val line = lines[j]
val taskMatch = TASK_LINE_RE.matchEntire(line)
val bulletMatch = if (taskMatch == null) BULLET_LINE_RE.matchEntire(line) else null
val orderedMatch = if (taskMatch == null && bulletMatch == null) ORDERED_LINE_RE.matchEntire(line) else null
if (taskMatch != null) {
val indent = computeIndent(taskMatch.groupValues[1])
val checked = taskMatch.groupValues[2].equals("x", ignoreCase = true)
val text = taskMatch.groupValues[3]
items.add(ParsedItem.Task(indent, checked, text))
j++
} else if (bulletMatch != null) {
val indent = computeIndent(bulletMatch.groupValues[1])
val text = bulletMatch.groupValues[2]
items.add(ParsedItem.Bullet(indent, text))
j++
} else if (orderedMatch != null) {
val indent = computeIndent(orderedMatch.groupValues[1])
val num = orderedMatch.groupValues[2].toIntOrNull() ?: 1
val text = orderedMatch.groupValues[3]
items.add(ParsedItem.Ordered(indent, num, text))
j++
} else if (line.isBlank()) {
// Check if there is a following list item
var lookahead = j + 1
while (lookahead < lines.size && lines[lookahead].isBlank()) {
lookahead++
}
if (lookahead < lines.size && LIST_ITEM_LINE_RE.matches(lines[lookahead])) {
j = lookahead
} else {
break
}
} else {
// Check if this is a continuation line for the previous list item
val indent = computeIndent(line.takeWhile { it == ' ' || it == '\t' })
if (items.isNotEmpty() && (indent >= 2 || line.startsWith(" "))) {
items.last().continuationLines.add(line.trim())
j++
} else {
break
}
}
}
if (items.isEmpty()) {
return emptyList<MdBlock>() to j
}
val blocks = mutableListOf<MdBlock>()
// Track state of list items and their relative nesting depth using an indent stack
val indentStack = mutableListOf<Int>() // maps level (index) -> base indent
var lastItemWasOrdered = false
var currentSequenceNumber = 1
var lastLevel = -1
for (item in items) {
// Resolve level using the indent stack
val level: Int
if (indentStack.isEmpty() || item.indent == 0) {
indentStack.clear()
indentStack.add(item.indent)
level = 0
} else if (item.indent > indentStack.last()) {
indentStack.add(item.indent)
level = indentStack.size - 1
} else if (item.indent == indentStack.last()) {
level = indentStack.size - 1
} else {
// Unwind stack to the matching or nearest smaller indent
while (indentStack.size > 1 && indentStack.last() > item.indent) {
indentStack.removeAt(indentStack.size - 1)
}
level = indentStack.size - 1
}
val fullText =
if (item.continuationLines.isEmpty()) {
when (item) {
is ParsedItem.Bullet -> item.text
is ParsedItem.Task -> item.text
is ParsedItem.Ordered -> item.text
}
} else {
val base =
when (item) {
is ParsedItem.Bullet -> item.text
is ParsedItem.Task -> item.text
is ParsedItem.Ordered -> item.text
}
base + " " + item.continuationLines.joinToString(" ")
}
when (item) {
is ParsedItem.Bullet -> {
blocks.add(MdBlock.Bullet(fullText, level = level))
lastItemWasOrdered = false
lastLevel = level
}
is ParsedItem.Task -> {
blocks.add(MdBlock.Task(item.checked, fullText, level = level))
lastItemWasOrdered = false
lastLevel = level
}
is ParsedItem.Ordered -> {
val indexToUse =
if (level == lastLevel && lastItemWasOrdered) {
if (item.rawNumber > currentSequenceNumber) {
currentSequenceNumber = item.rawNumber
} else {
currentSequenceNumber++
}
currentSequenceNumber
} else {
currentSequenceNumber = item.rawNumber
currentSequenceNumber
}
blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level))
lastItemWasOrdered = true
lastLevel = level
}
}
}
return blocks to j
}
private fun isHorizontalRule(line: String): Boolean {
val trimmed = line.trim()
if (trimmed.length < 3) return false
val c = trimmed[0]
return (c == '-' || c == '*' || c == '_') && trimmed.all { it == c }
}
private fun isTableStart(
lines: List<String>,
i: Int,
): Boolean {
val l = lines[i]
if (!l.contains('|')) return false
if (i + 1 >= lines.size) return false
return isTableSeparator(lines[i + 1])
}
private fun isTableSeparator(line: String): Boolean {
val t = line.trim().trim('|')
if (t.isEmpty()) return false
// every cell must be only dashes/colons/spaces, with at least one dash
return t.split('|').all { cell ->
val c = cell.trim()
c.isNotEmpty() && c.all { it == '-' || it == ':' || it == ' ' } && c.any { it == '-' }
}
}
private fun parseTable(
lines: List<String>,
i: Int,
): Triple<List<String>, List<TableAlign>, List<List<String>>> {
val header = splitRow(lines[i])
val alignments =
splitRow(lines[i + 1]).map { cell ->
val c = cell.trim()
when {
c.startsWith(":") && c.endsWith(":") -> TableAlign.CENTER
c.endsWith(":") -> TableAlign.RIGHT
c.startsWith(":") -> TableAlign.LEFT
else -> TableAlign.LEFT
}
}
val body = mutableListOf<List<String>>()
var j = i + 2
while (j < lines.size && lines[j].contains('|') && lines[j].trim().isNotEmpty()) {
body.add(splitRow(lines[j]))
j++
}
return Triple(header, alignments, body)
}
private fun splitRow(line: String): List<String> {
val trimmed = line.trim().trim('|')
return trimmed.split('|').map { it.trim() }
}
private data class CodeFenceInfo(
val fenceChar: Char,
val fenceLength: Int,
val language: String?,
)
private fun parseCodeFenceStart(line: String): CodeFenceInfo? {
val trimmed = line.trimStart()
if (trimmed.length < 3) return null
val firstChar = trimmed[0]
if (firstChar != '`' && firstChar != '~') return null
val count = trimmed.takeWhile { it == firstChar }.length
if (count < 3) return null
val rest = trimmed.substring(count).trim()
if (firstChar == '`' && rest.contains('`')) return null
return CodeFenceInfo(
fenceChar = firstChar,
fenceLength = count,
language = rest.ifBlank { null },
)
}
private fun isCodeFenceEnd(
line: String,
fence: CodeFenceInfo,
): Boolean {
val trimmed = line.trimStart()
val count = trimmed.takeWhile { it == fence.fenceChar }.length
if (count < fence.fenceLength) return false
val rest = trimmed.substring(count).trim()
return rest.isEmpty()
}
private fun isDefListStart(
lines: List<String>,
i: Int,
): Boolean {
val l = lines[i].trim()
if (l.isBlank() || l.startsWith("#") || l.startsWith(">") || parseCodeFenceStart(l) != null) return false
if (LIST_ITEM_LINE_RE.matches(lines[i])) return false
if (i + 1 >= lines.size) return false
return lines[i + 1].trim().startsWith(":")
}
private fun isValidHeading(line: String): Boolean {
if (!line.startsWith("#")) return false
val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6)
return level < line.length && line[level] == ' '
}
private fun tryParseImage(line: String): MdBlock.Image? {
val m = IMAGE_RE.matchAt(line, 0) ?: return null
val uri = m.groupValues[2].trim()
if (uri.isEmpty()) return null
return MdBlock.Image(uri = uri, alt = m.groupValues[1].trim())
}
private fun tryParseVideo(line: String): MdBlock.Video? {
val m = IMAGE_RE.matchAt(line, 0) ?: return null
val uri = m.groupValues[2].trim()
if (uri.isEmpty()) return null
val ext = uri.substringAfterLast('.', "").lowercase()
if (ext in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") || uri.contains("video/", ignoreCase = true)) {
return MdBlock.Video(uri = uri, alt = m.groupValues[1].trim())
}
return null
}
private fun fallthroughToParagraph(
lines: List<String>,
start: Int,
blocks: MutableList<MdBlock>,
): Int {
var i = start
val para = mutableListOf<String>()
while (
i < lines.size &&
lines[i].isNotBlank() &&
parseCodeFenceStart(lines[i]) == null &&
!isValidHeading(lines[i]) &&
!lines[i].startsWith(">") &&
!LIST_ITEM_LINE_RE.matches(lines[i]) &&
!isHorizontalRule(lines[i]) &&
!isTableStart(lines, i) &&
!isDefListStart(lines, i) &&
tryParseDisplayMath(lines, i) == null &&
FN_DEF_RE.matchAt(lines[i], 0) == null
) {
para.add(lines[i])
i++
}
if (para.isNotEmpty()) blocks.add(MdBlock.Paragraph(para.joinToString("\n")))
return i
}
@@ -0,0 +1,103 @@
package com.m57.hermescontrol.ui.chat.markdown
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.util.BidiUtils
private val TABLE_COL_WIDTH = 140.dp
@Composable
fun MarkdownTable(
block: MdBlock.Table,
textColor: Color,
modifier: Modifier = Modifier,
) {
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
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 = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
textAlign = tableTextAlign(alignments.getOrNull(idx)),
color = textColor,
style =
MaterialTheme.typography.bodySmall.copy(
fontWeight = FontWeight.Bold,
textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr,
),
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 ->
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 = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr,
),
modifier =
Modifier
.width(TABLE_COL_WIDTH)
.padding(6.dp),
)
}
}
HorizontalDivider(color = textColor.copy(alpha = 0.1f))
}
}
}
}
private fun tableTextAlign(align: TableAlign?): TextAlign? =
when (align) {
TableAlign.CENTER -> TextAlign.Center
TableAlign.RIGHT -> TextAlign.End
else -> null
}
@@ -0,0 +1,34 @@
package com.m57.hermescontrol.ui.chat.tool
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
/**
* One normalized tool invocation, as seen by the display engine.
*
* [args] and [result] are the payloads coerced to records (stringified JSON
* re-parsed, non-objects dropped); [rawArgs]/[rawResult] keep the original
* elements for renderers that need arrays, scalars, or wrapper unwrapping.
*/
internal data class ToolCall(
val name: String,
val args: JsonObject?,
val result: JsonObject?,
val rawArgs: JsonElement?,
val rawResult: JsonElement?,
) {
companion object {
fun of(
name: String,
rawArgs: JsonElement?,
rawResult: JsonElement?,
): ToolCall =
ToolCall(
name = name,
args = ToolJson.parseMaybeObject(rawArgs),
result = ToolJson.parseMaybeObject(rawResult),
rawArgs = rawArgs,
rawResult = rawResult,
)
}
}
@@ -0,0 +1,255 @@
package com.m57.hermescontrol.ui.chat.tool
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
/**
* Count-label extraction ("3 results", "2 files") for the collapsed row.
*
* Hunts for a count in this order: known count fields, known list arrays,
* `*_count`/`*_total` field names, then plain-text "Found N matches"
* phrasing in summary/message fields. web_search always counts its hits and
* always reports "results".
*/
internal object ToolCounts {
private val COUNT_FIELD_KEYS =
listOf(
"count",
"total",
"result_count",
"results_count",
"num_results",
"match_count",
"matches_count",
"file_count",
"files_count",
"item_count",
"items_count",
"search_count",
"searches_count",
"source_count",
"sources_count",
"document_count",
"documents_count",
"updated",
"added",
"removed",
"deleted",
"created",
"changed",
"processed",
"steps",
)
private val COUNT_ARRAY_KEYS = listOf("results", "items", "matches", "files", "documents", "sources", "rows")
private val COUNT_EXCLUDED_KEYS = setOf("duration_s", "exit_code", "status_code")
private val NOUN_BY_FIELD =
mapOf(
"result_count" to "result",
"results_count" to "result",
"num_results" to "result",
"match_count" to "match",
"matches_count" to "match",
"file_count" to "file",
"files_count" to "file",
"item_count" to "item",
"items_count" to "item",
"search_count" to "search",
"searches_count" to "search",
"source_count" to "source",
"sources_count" to "source",
"document_count" to "document",
"documents_count" to "document",
"updated" to "item",
"added" to "item",
"removed" to "item",
"deleted" to "item",
"created" to "item",
"changed" to "item",
"processed" to "item",
"steps" to "step",
)
private val NOUN_BY_ARRAY =
mapOf(
"documents" to "document",
"files" to "file",
"items" to "item",
"matches" to "match",
"results" to "result",
"rows" to "row",
"sources" to "source",
)
private fun countFromUnknown(value: JsonElement?): Int? {
if (value is JsonArray) {
return if (value.isNotEmpty()) value.size else null
}
val n = ToolJson.numberValue(value) ?: return null
if (n <= 0) {
return null
}
return Math.round(n).toInt()
}
private fun singularizeNoun(noun: String): String {
val normalized = noun.lowercase()
if (normalized.isEmpty()) {
return ""
}
if (normalized.endsWith("ies") && normalized.length > 3) {
return "${normalized.dropLast(3)}y"
}
if (Regex("(xes|zes|ches|shes|sses)$").containsMatchIn(normalized) && normalized.length > 3) {
return normalized.dropLast(2)
}
if (normalized.endsWith("s") && normalized.length > 2 && !normalized.endsWith("ss")) {
return normalized.dropLast(1)
}
return normalized
}
fun pluralizeNoun(
noun: String,
count: Int,
): String {
if (count == 1) {
return noun
}
if (noun == "search") {
return "searches"
}
if (noun.endsWith(
"y",
) && noun.length > 1 && !Regex("[aeiou]y$", RegexOption.IGNORE_CASE).containsMatchIn(noun)
) {
return "${noun.dropLast(1)}ies"
}
if (Regex("(s|x|z|ch|sh)$", RegexOption.IGNORE_CASE).containsMatchIn(noun)) {
return "${noun}es"
}
return "${noun}s"
}
private fun countMetric(
count: Int,
noun: String,
): Pair<Int, String> = count to singularizeNoun(noun).ifEmpty { "item" }
private fun countFromRecord(
record: JsonObject,
fallbackNoun: String,
): Pair<Int, String>? {
for (key in COUNT_FIELD_KEYS) {
val count = countFromUnknown(record[key]) ?: continue
return countMetric(count, NOUN_BY_FIELD[key] ?: fallbackNoun)
}
for (key in COUNT_ARRAY_KEYS) {
val count = countFromUnknown(record[key]) ?: continue
return countMetric(count, NOUN_BY_ARRAY[key] ?: fallbackNoun)
}
for ((key, value) in record) {
if (key in COUNT_EXCLUDED_KEYS) {
continue
}
if (!Regex("_count$|_total$").containsMatchIn(key)) {
continue
}
val count = countFromUnknown(value) ?: continue
val stripped = key.lowercase().replace(Regex("_(count|total)$"), "").replace(Regex("^num_"), "")
return countMetric(count, singularizeNoun(stripped).ifEmpty { fallbackNoun })
}
return null
}
private fun countFromText(
text: String,
fallbackNoun: String,
): Pair<Int, String>? {
val t = text.trim()
if (t.isEmpty()) {
return null
}
val unitMatch =
Regex(
"""\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""",
RegexOption.IGNORE_CASE,
).find(t)
?: Regex(
"""\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""",
RegexOption.IGNORE_CASE,
).find(t)
val n = unitMatch?.groupValues?.get(1)?.toIntOrNull() ?: return null
val noun = unitMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: fallbackNoun
return if (n > 0) countMetric(n, noun) else null
}
fun countLabelFor(call: ToolCall): Pair<Int, String>? {
val rawResult = call.rawResult ?: return null
val toolName = call.name
val result = call.result
val fallbackNoun =
when (toolName) {
"browser_snapshot", "todo" -> "item"
"list_files" -> "file"
"search_files", "session_search_recall", "web_search" -> "result"
"skills_list" -> "skill"
else -> "item"
}
if (toolName == "web_search") {
val hits = ToolJson.collectResultItems(rawResult)
if (hits.isNotEmpty()) {
return countMetric(hits.size, "result")
}
}
val direct = countFromRecord(result ?: JsonObject(emptyMap()), fallbackNoun)
if (direct != null) {
return if (toolName == "web_search") countMetric(direct.first, "result") else direct
}
val payload = ToolJson.unwrapToolPayload(rawResult)
if (payload !== rawResult && payload is JsonObject) {
val payloadCount = countFromRecord(payload, fallbackNoun)
if (payloadCount != null) {
return if (toolName == "web_search") countMetric(payloadCount.first, "result") else payloadCount
}
}
val summaryText =
ToolJson
.firstString(result, listOf("summary", "message", "detail"))
.ifEmpty { ToolJson.fallbackDetailText(call.args, rawResult) }
return countFromText(summaryText, fallbackNoun)
}
}
@@ -0,0 +1,317 @@
package com.m57.hermescontrol.ui.chat.tool
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Shared parsing/normalization primitives for the tool-display engine.
*
* Everything in here is payload-shape plumbing — no per-tool knowledge.
* Per-tool formatting lives in the renderers under [render][com.m57.hermescontrol.ui.chat.tool.render].
*/
internal object ToolJson {
private val URL_PATTERN = Regex("https?://[^\\s'\"<>)\\]]+", RegexOption.IGNORE_CASE)
const val MAX_TOOL_RENDER_CHARS = 20_000
fun clampForDisplay(
value: String,
max: Int = MAX_TOOL_RENDER_CHARS,
): String {
if (value.length <= max) return value
val omitted = value.length - max
return value.take(max) +
"\n… $omitted more characters truncated — use Copy for the full output."
}
/** First non-blank string value among [keys], trimmed. */
fun firstString(
record: JsonObject?,
keys: List<String>,
): String {
val r = record ?: return ""
for (k in keys) {
val v = r[k] ?: continue
if (v is JsonPrimitive && v.isString && v.content.trim().isNotEmpty()) {
return v.content.trim()
}
}
return ""
}
fun numberValue(value: JsonElement?): Double? {
val v = value ?: return null
if (v is JsonNull) {
return null
}
if (v !is JsonPrimitive) {
return null
}
// TS source does `Number(value)` — numeric STRINGS ("0.0", "12") are
// accepted, matching the desktop engine and the gateway's habit of
// shipping exit_code as a string or float.
return v.content.toDoubleOrNull()
}
fun intValue(value: JsonElement?): Int? {
val n = numberValue(value) ?: return null
return n.toInt()
}
/** True only for a literal JSON `true` (not the string "true"). */
fun boolTrue(value: JsonElement?): Boolean =
(value as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true
/** True only for a literal JSON `false` (not the string "false"). */
fun boolFalse(value: JsonElement?): Boolean =
(value as? JsonPrimitive)?.let { !it.isString && it.content == "false" } == true
fun compactPreview(
value: String,
max: Int = 72,
): String {
val line = value.replace(Regex("\\s+"), " ").trim()
return if (line.length > max) "${line.take(max - 1)}…" else line
}
fun compactPreview(
value: JsonElement?,
max: Int = 72,
): String {
val raw: String =
if (value is JsonPrimitive && value.isString) {
value.content
} else {
firstString(parseMaybeObject(value), listOf("context"))
}
return compactPreview(raw, max)
}
fun contextValue(value: JsonElement?): String {
val row = parseMaybeObject(value)
val context = firstString(row, listOf("context"))
val preview = firstString(row, listOf("preview"))
if (context.isNotEmpty()) {
return context
}
if (preview.isNotEmpty()) {
return preview
}
return (value as? JsonPrimitive)?.takeIf { it.isString }?.content ?: ""
}
/** Objects pass through; stringified JSON objects are re-parsed. */
fun parseMaybeObject(value: JsonElement?): JsonObject? =
when (value) {
is JsonObject -> {
value
}
is JsonPrimitive -> {
if (!value.isString || value.content.trim().isEmpty()) {
null
} else {
try {
Json.parseToJsonElement(value.content) as? JsonObject
} catch (_: Exception) {
null
}
}
}
else -> {
null
}
}
/** Peel one `data`/`result`/`output`/`response`/`payload` wrapper layer. */
fun unwrapToolPayload(value: JsonElement?): JsonElement? {
val record = parseMaybeObject(value) ?: return value
for (key in listOf("data", "result", "output", "response", "payload")) {
val payload = record[key] ?: continue
if (payload is JsonNull) {
continue
}
return payload
}
return value
}
fun findFirstUrl(vararg sources: JsonElement?): String {
for (src in sources) {
if (src is JsonPrimitive && src.isString) {
val m = URL_PATTERN.find(src.content)
if (m != null) {
return m.value
}
} else if (src is JsonObject) {
for (v in src.values) {
val found = findFirstUrl(v)
if (found.isNotEmpty()) {
return found
}
}
}
}
return ""
}
fun hostnameOf(value: String): String =
try {
val url = java.net.URI(value)
val path = url.path?.takeIf { it.isNotEmpty() && it != "/" } ?: ""
"${url.host ?: value}$path"
} catch (_: Exception) {
value
}
fun stripAnsi(value: String): String = value.replace(Regex("\u001B\\[[0-9;]*m"), "")
fun stripDividerLines(value: String): String =
value
.split("\n")
.filter { !Regex("^[-=]{3,}\\s*$").matches(it.trim()) }
.joinToString("\n")
.trim()
fun cleanVisibleText(text: String): String =
text
.replace(Regex("`{3,}"), "")
.replace(Regex("(?<=[\\p{L}\\p{N})\\].,!?:;\"'”’])\\[(?:\\d+(?:\\s*,\\s*\\d+)*)\\](?!\\()"), "")
.replace(Regex("\\[([^\\]]+)\\]\\(([^)\\s]+)\\)")) { m -> "${m.groupValues[1]} ${m.groupValues[2]}" }
/** Find the result-item array behind common list keys or wrappers. */
fun collectResultItems(value: JsonElement?): List<JsonElement> {
if (value is JsonArray) {
return value.toList()
}
val record = parseMaybeObject(value) ?: return emptyList()
val keys =
listOf(
"web",
"results",
"search_results",
"sources",
"web_sources",
"items",
"organic_results",
"organic",
"matches",
"documents",
)
for (key in keys) {
val candidate = record[key] ?: continue
if (candidate is JsonArray) {
return candidate.toList()
}
if (candidate is JsonObject) {
val nested = collectResultItems(candidate)
if (nested.isNotEmpty()) {
return nested
}
}
}
val payload = unwrapToolPayload(record)
return if (payload === record) emptyList() else collectResultItems(payload)
}
/** Humanized generic title: "fact_store" → "Fact Store", sans browser_/web_ prefix. */
fun titleForTool(name: String): String {
val normalized = name.replace(Regex("^browser_"), "").replace(Regex("^web_"), "")
return normalized
.split("_")
.filter { it.isNotEmpty() }
.joinToString(
" ",
) { part -> part.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } }
.ifEmpty { name }
}
/** Generic detail: prefer a fresh result context, then args, then the heuristic summary. */
fun fallbackDetailText(
args: JsonElement?,
result: JsonElement?,
): String {
val argContext = contextValue(args)
val resultContext = contextValue(result)
if (resultContext.isNotEmpty() && resultContext != argContext) {
return resultContext
}
if (argContext.isNotEmpty()) {
return argContext
}
if (result != null) {
return ToolResultSummary.formatToolResultSummary(result)
}
return ToolResultSummary.formatToolResultSummary(args)
}
fun formatDurationSeconds(seconds: Double): String {
if (!seconds.isFinite() || seconds < 0) {
return ""
}
if (seconds < 1) {
val ms = maxOf(1, Math.round(seconds * 1000))
return "${ms}ms"
}
if (seconds < 60) {
return if (seconds >= 10) "${seconds.toInt()}s" else "${"%.1f".format(seconds)}s"
}
val wholeSeconds = Math.round(seconds)
val minutes = wholeSeconds / 60
val remSeconds = wholeSeconds % 60
if (minutes < 60) {
return if (remSeconds != 0L) "${minutes}m ${remSeconds}s" else "${minutes}m"
}
val hours = minutes / 60
val remMinutes = minutes % 60
return if (remMinutes != 0L) "${hours}h ${remMinutes}m" else "${hours}h"
}
fun durationLabel(result: JsonObject?): String? {
val seconds =
numberValue(result?.get("duration_s"))
?: numberValue(result?.get("duration_seconds"))
?: numberValue(result?.get("total_duration_seconds"))
?: return null
return formatDurationSeconds(seconds)
}
}
@@ -0,0 +1,54 @@
package com.m57.hermescontrol.ui.chat.tool
/**
* Per-tool (or per-family) display strategy.
*
* Every hook is a *refinement*: return null to fall back to the generic
* treatment ([ToolViewBuilder] humanizes the tool name for titles and uses
* [ToolResultSummary]-based text for subtitle/detail). Returning an empty
* string is a real answer — "this tool intentionally shows nothing here" —
* and suppresses the generic fallback.
*/
internal interface ToolRenderer {
/** Title while the call is still running ("Running ls", "Searching …"). */
fun pendingTitle(call: ToolCall): String? = null
/** Title after successful completion ("Ran ls", "Opened example.com"). */
fun doneTitle(call: ToolCall): String? = null
/** Title when the call errored. */
fun errorTitle(call: ToolCall): String? = null
/** Secondary line naming what was acted on. Not used when an error is shown. */
fun subtitle(call: ToolCall): String? = null
/** Expanded body text. */
fun detail(call: ToolCall): String? = null
/** Structured extras (streams, diffs, search hits) for special row layouts. */
fun extras(
call: ToolCall,
status: ToolViewStatus,
): ToolViewExtras = ToolViewExtras.NONE
}
/**
* Structured, tool-specific fields a renderer can contribute to [ToolView]
* beyond the title/subtitle/detail text.
*/
internal data class ToolViewExtras(
val detailLabel: String? = null,
val stdout: String? = null,
val stderr: String? = null,
val exitCode: Int? = null,
val terminalCommand: String? = null,
val inlineDiff: String? = null,
val diffPath: String? = null,
val diffStats: DiffStats? = null,
val searchHits: List<SearchHit>? = null,
val searchQuery: String? = null,
) {
companion object {
val NONE = ToolViewExtras()
}
}
@@ -0,0 +1,107 @@
package com.m57.hermescontrol.ui.chat.tool
import com.m57.hermescontrol.ui.chat.tool.render.BrowserActionRenderer
import com.m57.hermescontrol.ui.chat.tool.render.BrowserClickRenderer
import com.m57.hermescontrol.ui.chat.tool.render.BrowserConsoleRenderer
import com.m57.hermescontrol.ui.chat.tool.render.BrowserImagesRenderer
import com.m57.hermescontrol.ui.chat.tool.render.BrowserNavigateRenderer
import com.m57.hermescontrol.ui.chat.tool.render.BrowserSnapshotRenderer
import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer
import com.m57.hermescontrol.ui.chat.tool.render.BrowserVisionRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ComputerUseRenderer
import com.m57.hermescontrol.ui.chat.tool.render.CronjobRenderer
import com.m57.hermescontrol.ui.chat.tool.render.DelegateTaskRenderer
import com.m57.hermescontrol.ui.chat.tool.render.FactStoreRenderer
import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ImageGenerateRenderer
import com.m57.hermescontrol.ui.chat.tool.render.MemoryRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ProcessRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ProjectListRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ProjectMutateRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ReadFileRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ReadTerminalRenderer
import com.m57.hermescontrol.ui.chat.tool.render.SearchFilesRenderer
import com.m57.hermescontrol.ui.chat.tool.render.SessionSearchRenderer
import com.m57.hermescontrol.ui.chat.tool.render.SkillManageRenderer
import com.m57.hermescontrol.ui.chat.tool.render.SkillViewRenderer
import com.m57.hermescontrol.ui.chat.tool.render.SkillsListRenderer
import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer
import com.m57.hermescontrol.ui.chat.tool.render.TextToSpeechRenderer
import com.m57.hermescontrol.ui.chat.tool.render.TodoRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer
import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer
import com.m57.hermescontrol.ui.chat.tool.render.WebExtractRenderer
import com.m57.hermescontrol.ui.chat.tool.render.WebSearchRenderer
import com.m57.hermescontrol.ui.chat.tool.render.XSearchRenderer
/**
* Maps tool names to their display strategy.
*
* Unknown tools get [GenericToolRenderer]: every hook returns null, so
* [ToolViewBuilder] falls back to the humanized tool name and the
* [ToolResultSummary] heuristics — never a raw JSON dump.
*/
internal object ToolRendererRegistry {
/** All-default renderer: defers everything to the generic fallbacks. */
private object GenericToolRenderer : ToolRenderer
private val renderers: Map<String, ToolRenderer> =
mapOf(
// Shell
"terminal" to TerminalRenderer,
"execute_code" to TerminalRenderer,
"read_terminal" to ReadTerminalRenderer,
// Delegation
"delegate_task" to DelegateTaskRenderer,
// Files
"read_file" to ReadFileRenderer,
"edit_file" to FileEditRenderer,
"patch" to FileEditRenderer,
"write_file" to FileEditRenderer,
"search_files" to SearchFilesRenderer,
// Web
"web_search" to WebSearchRenderer,
"web_extract" to WebExtractRenderer,
"x_search" to XSearchRenderer,
// Browser
"browser_navigate" to BrowserNavigateRenderer,
"browser_snapshot" to BrowserSnapshotRenderer,
"browser_click" to BrowserClickRenderer,
"browser_fill" to BrowserTypeRenderer,
"browser_type" to BrowserTypeRenderer,
"browser_scroll" to BrowserActionRenderer,
"browser_back" to BrowserActionRenderer,
"browser_press" to BrowserActionRenderer,
"browser_get_images" to BrowserImagesRenderer,
"browser_vision" to BrowserVisionRenderer,
"browser_console" to BrowserConsoleRenderer,
// Memory / facts
"memory" to MemoryRenderer,
"fact_store" to FactStoreRenderer,
// Task management
"cronjob" to CronjobRenderer,
"cronjob_manage" to CronjobRenderer,
"todo" to TodoRenderer,
"todo_list" to TodoRenderer,
"session_search" to SessionSearchRenderer,
"process" to ProcessRenderer,
"process_manage" to ProcessRenderer,
// Skills / tool discovery
"skills_list" to SkillsListRenderer,
"skill_view" to SkillViewRenderer,
"skill_manage" to SkillManageRenderer,
"tool_search" to ToolSearchRenderer,
// Media
"image_generate" to ImageGenerateRenderer,
"vision_analyze" to VisionAnalyzeRenderer,
"text_to_speech" to TextToSpeechRenderer,
// Projects
"project_list" to ProjectListRenderer,
"project_create" to ProjectMutateRenderer,
"project_switch" to ProjectMutateRenderer,
// Desktop automation
"computer_use" to ComputerUseRenderer,
)
fun rendererFor(toolName: String): ToolRenderer = renderers[toolName] ?: GenericToolRenderer
}
@@ -0,0 +1,105 @@
package com.m57.hermescontrol.ui.chat.tool
import kotlinx.serialization.json.JsonPrimitive
/**
* Status + error-text heuristics for a completed tool call, ported from the
* desktop engine. Key rules, in order:
*
* - running (or a missing result) is RUNNING
* - an explicit `success: true` / `ok: true` beats every error heuristic,
* including a stale isError envelope
* - a non-zero exit code alone is NOT an error: grep returns 1 on no-match,
* diff returns 1 on differences, piped commands surface the last stage's
* code — all routinely produce useful output. It only becomes an error
* when the command produced no real output to show.
* - a rejected memory write is a budget negotiation → WARNING, not ERROR
*/
internal object ToolStatusResolver {
fun errorText(
call: ToolCall,
isError: Boolean,
): String {
val result = call.rawResult
val resultRecord = call.result
if (isError) {
val extracted = ToolResultSummary.extractToolErrorMessage(result)
if (extracted.isNotEmpty()) {
return extracted
}
if (result is JsonPrimitive && result.isString && result.content.trim().isNotEmpty()) {
return result.content.trim()
}
return "Tool returned an error."
}
val error = ToolJson.firstString(resultRecord, listOf("error"))
if (error.isNotEmpty()) {
return error
}
val extracted = ToolResultSummary.extractToolErrorMessage(result)
if (extracted.isNotEmpty()) {
return extracted
}
if (resultRecord != null) {
if (ToolJson.boolFalse(resultRecord["success"]) || ToolJson.boolFalse(resultRecord["ok"])) {
return ToolJson
.firstString(
resultRecord,
listOf("message", "reason", "detail"),
).ifEmpty { "Tool returned success=false." }
}
val status = ToolJson.firstString(resultRecord, listOf("status"))
if (Regex("\\b(error|failed|failure)\\b", RegexOption.IGNORE_CASE).containsMatchIn(status)) {
return ToolJson
.firstString(
resultRecord,
listOf("message", "reason", "detail"),
).ifEmpty { "Tool returned status \"$status\"." }
}
val exit = ToolJson.numberValue(resultRecord["exit_code"])
if (exit != null && exit != 0.0) {
val hasOutput =
listOf("output", "stdout", "stderr", "output_preview")
.any { k -> ToolJson.firstString(resultRecord, listOf(k)).isNotEmpty() }
if (!hasOutput) {
return "Command failed with exit code ${exit.toInt()}."
}
}
}
return ""
}
fun status(
call: ToolCall,
isError: Boolean,
running: Boolean,
): ToolViewStatus {
if (running || call.rawResult == null) {
return ToolViewStatus.RUNNING
}
// Explicit success wins over isError / nested-error heuristics.
val resultRecord = call.result
if (resultRecord != null &&
(ToolJson.boolTrue(resultRecord["success"]) || ToolJson.boolTrue(resultRecord["ok"]))
) {
return ToolViewStatus.SUCCESS
}
if (errorText(call, isError).isEmpty()) {
return ToolViewStatus.SUCCESS
}
// A rejected memory write is a budget negotiation, not a failure.
return if (call.name == "memory") ToolViewStatus.WARNING else ToolViewStatus.ERROR
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,202 @@
package com.m57.hermescontrol.ui.chat.tool.render
import com.m57.hermescontrol.ui.chat.tool.ToolCall
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
import kotlinx.serialization.json.JsonArray
/** `browser_navigate`: hostname titles, including the failed-to-open form. */
internal object BrowserNavigateRenderer : ToolRenderer {
override fun pendingTitle(call: ToolCall): String = "Opening ${targetHostname(call.args, null)}"
override fun doneTitle(call: ToolCall): String = "Opened ${targetHostname(call.args, call.result)}"
override fun errorTitle(call: ToolCall): String? {
val url =
ToolJson
.findFirstUrl(call.result)
.ifEmpty { call.result?.get("url")?.toString() ?: "" }
return if (url.isNotEmpty()) "Failed to open ${ToolJson.hostnameOf(url)}" else null
}
override fun subtitle(call: ToolCall): String {
val url =
ToolJson
.firstString(call.args, listOf("url", "target"))
.ifEmpty { ToolJson.firstString(call.result, listOf("url")) }
.ifEmpty { ToolJson.findFirstUrl(call.args, call.result) }
return if (url.isNotEmpty()) ToolJson.hostnameOf(url) else "Navigated in browser"
}
}
/** `browser_snapshot`: control-count summary of the accessibility snapshot. */
internal object BrowserSnapshotRenderer : ToolRenderer {
private fun summarizeSnapshot(snapshot: String): String {
fun count(re: Regex): Int = re.findAll(snapshot).count()
val stats =
listOf(
"${count(Regex("""button\s+"[^"]+"""))} buttons",
"${count(Regex("""link\s+"[^"]+"""))} links",
"${count(Regex("""(?:textbox|combobox|searchbox)\s+"[^"]+"""))} inputs",
).joinToString(" · ")
val labels =
Regex("""(?:button|link|combobox|textbox)\s+"([^"]+)"""")
.findAll(snapshot)
.map { it.groupValues[1].trim() }
.filter { it.isNotEmpty() }
.take(4)
.toList()
return if (labels.isNotEmpty()) "$stats\nTop controls: ${labels.joinToString(", ")}" else stats
}
override fun subtitle(call: ToolCall): String {
val snapshot = ToolJson.firstString(call.result, listOf("snapshot"))
return if (snapshot.isNotEmpty()) {
summarizeSnapshot(snapshot)
} else {
"Captured a browser accessibility snapshot"
}
}
override fun detail(call: ToolCall): String {
val snapshot = ToolJson.firstString(call.result, listOf("snapshot"))
return if (snapshot.isNotEmpty()) {
summarizeSnapshot(snapshot)
} else {
ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
}
}
}
/** `browser_click`: what got clicked, with internal refs called out. */
internal object BrowserClickRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String {
val clicked =
ToolJson
.firstString(call.result, listOf("clicked"))
.ifEmpty { ToolJson.firstString(call.args, listOf("ref", "target")) }
return when {
clicked.isEmpty() -> "Clicked on page"
clicked.startsWith("@") -> "Clicked page element (internal ref $clicked)"
else -> "Clicked $clicked"
}
}
}
/** `browser_fill` / `browser_type`: which field got which value. */
internal object BrowserTypeRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String {
val field = ToolJson.firstString(call.args, listOf("label", "field", "ref", "target"))
val value = ToolJson.firstString(call.args, listOf("value", "text"))
return listOf(
field.takeIf { it.isNotEmpty() }?.let { "Field: $it" },
value.takeIf { it.isNotEmpty() }?.let { "Value: ${ToolJson.compactPreview(it, 42)}" },
).filterNotNull()
.joinToString(" · ")
.ifEmpty { "Filled page input" }
}
}
/** browser_scroll / browser_back / browser_press: one-line acks. */
internal object BrowserActionRenderer : ToolRenderer {
override fun doneTitle(call: ToolCall): String? =
when (call.name) {
"browser_scroll" -> {
"Scrolled ${ToolJson.firstString(call.result, listOf("scrolled"))
.ifEmpty { ToolJson.firstString(call.args, listOf("direction")) }.ifEmpty { "page" }}"
}
"browser_back" -> {
val url = ToolJson.firstString(call.result, listOf("url"))
if (url.isNotEmpty()) "Went back to ${ToolJson.hostnameOf(url)}" else "Went back"
}
"browser_press" -> {
"Pressed ${ToolJson.firstString(call.result, listOf("pressed"))
.ifEmpty { ToolJson.firstString(call.args, listOf("key")) }}"
}
else -> {
null
}
}
override fun subtitle(call: ToolCall): String = ""
override fun detail(call: ToolCall): String = ""
}
/** browser_get_images: image inventory of the current page. */
internal object BrowserImagesRenderer : ToolRenderer {
override fun doneTitle(call: ToolCall): String {
val n = ToolJson.intValue(call.result?.get("count")) ?: 0
return "Found $n image${if (n == 1) "" else "s"}"
}
override fun detail(call: ToolCall): String =
(call.result?.get("images") as? JsonArray)
?.take(12)
?.mapNotNull { el ->
val img = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
val src = ToolJson.firstString(img, listOf("src"))
val alt = ToolJson.firstString(img, listOf("alt"))
listOf(alt, src)
.filter { it.isNotEmpty() }
.joinToString(" — ")
.takeIf { it.isNotEmpty() }
?.let { "- $it" }
}?.joinToString("\n") ?: ""
}
/** browser_vision: screenshot Q&A — the analysis text is the payload. */
internal object BrowserVisionRenderer : ToolRenderer {
override fun pendingTitle(call: ToolCall): String = "Looking at page"
override fun doneTitle(call: ToolCall): String = "Analyzed page"
override fun subtitle(call: ToolCall): String =
ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("question")), 120)
override fun detail(call: ToolCall): String =
ToolJson
.firstString(call.result, listOf("analysis"))
.ifEmpty { ToolJson.firstString(call.result, listOf("note")) }
}
/** browser_console: message/error counts, or a JS eval result. */
internal object BrowserConsoleRenderer : ToolRenderer {
override fun doneTitle(call: ToolCall): String? {
val result = call.result ?: return null
if (result.containsKey("result")) return "Evaluated JS"
val msgs = ToolJson.intValue(result["total_messages"]) ?: 0
val errs = ToolJson.intValue(result["total_errors"]) ?: 0
return "Console: $msgs message${if (msgs == 1) "" else "s"}" +
if (errs > 0) ", $errs error${if (errs == 1) "" else "s"}" else ""
}
override fun detail(call: ToolCall): String {
val result = call.result ?: return ""
result["result"]?.let { return it.toString() }
val lines =
(result["console_messages"] as? JsonArray)?.take(30)?.mapNotNull { el ->
val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
"[${ToolJson.firstString(m, listOf("type")).ifEmpty { "log" }}] " +
ToolJson.compactPreview(ToolJson.firstString(m, listOf("text")), 200)
} ?: emptyList()
val errors =
(result["js_errors"] as? JsonArray)?.take(10)?.mapNotNull { el ->
val e = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
"[error] ${ToolJson.compactPreview(ToolJson.firstString(e, listOf("message")), 200)}"
} ?: emptyList()
return (errors + lines).joinToString("\n")
}
}
@@ -0,0 +1,156 @@
package com.m57.hermescontrol.ui.chat.tool.render
import com.m57.hermescontrol.ui.chat.tool.ToolCall
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
import kotlinx.serialization.json.JsonArray
/**
* `computer_use`: desktop-automation results — app rosters, interactable
* element inventories, multimodal text blocks, and plain action acks.
*/
internal object ComputerUseRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String {
val args = call.args
val result = call.result
val error = ToolJson.firstString(result, listOf("error"))
if (error.isNotEmpty()) {
return "❌ $error"
}
val apps = result?.get("apps") as? JsonArray
if (apps != null) {
return "${apps.size} apps"
}
val elements = result?.get("elements") as? JsonArray
if (elements != null) {
val mode = ToolJson.firstString(result, listOf("mode"))
val width = ToolJson.intValue(result["width"])
val height = ToolJson.intValue(result["height"])
val modePart = mode.takeIf { it.isNotEmpty() }?.let { " ($it)" } ?: ""
val sizePart = if (width != null && height != null) " ${width}x$height" else ""
return "${elements.size} elements$modePart$sizePart"
}
val action = ToolJson.firstString(args, listOf("action"))
if (action.isNotEmpty()) {
val ok = ToolJson.boolTrue(result?.get("ok"))
val fail = ToolJson.boolFalse(result?.get("ok"))
return "$action${when {
ok -> " ✓"
fail -> " ✗"
else -> ""
}}"
}
return "Computer use"
}
override fun detail(call: ToolCall): String {
val args = call.args
val result = call.result
val error = ToolJson.firstString(result, listOf("error"))
if (error.isNotEmpty()) {
return "❌ $error"
}
val isMultimodal = ToolJson.boolTrue(result?.get("_multimodal"))
val content = result?.get("content") as? JsonArray
val multimodalText =
if (isMultimodal && content != null) {
content
.mapNotNull { el ->
val e = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
if (ToolJson.firstString(e, listOf("type")) == "text") {
ToolJson.firstString(e, listOf("text"))
} else {
null
}
}.joinToString("\n")
} else {
""
}
val textSummary = ToolJson.firstString(result, listOf("summary"))
val visionAnalysis = ToolJson.firstString(result, listOf("vision_analysis"))
val apps = result?.get("apps") as? JsonArray
val elements = result?.get("elements") as? JsonArray
val mode = ToolJson.firstString(result, listOf("mode"))
val width = ToolJson.intValue(result?.get("width"))
val height = ToolJson.intValue(result?.get("height"))
val action = ToolJson.firstString(args, listOf("action"))
val ok = ToolJson.boolTrue(result?.get("ok"))
val fail = ToolJson.boolFalse(result?.get("ok"))
val message = ToolJson.firstString(result, listOf("message"))
val body =
buildString {
val textPart =
multimodalText
.ifEmpty {
ToolJson.firstString(
result,
listOf("text_summary"),
)
}.ifEmpty { textSummary }
if (textPart.isNotEmpty()) {
append(textPart)
if (textPart == multimodalText && textSummary.isNotEmpty() && multimodalText != textSummary) {
append("\n\n$textSummary")
}
}
if (apps != null) {
if (isNotEmpty()) append("\n\n")
apps.forEachIndexed { idx, el ->
val obj = ToolJson.parseMaybeObject(el)
val name = ToolJson.firstString(obj, listOf("name")).ifEmpty { "?" }
val pid = ToolJson.intValue(obj?.get("pid"))
append("${idx + 1}. $name")
if (pid != null) append(" (PID: $pid)")
append("\n")
}
} else if (elements != null) {
if (isNotEmpty()) append("\n\n")
append("🖥️ ${elements.size} interactable elements")
if (mode.isNotEmpty()) append(" (mode: $mode)")
if (width != null && height != null) append(" • ${width}x$height")
if (textSummary.isNotEmpty()) append("\n\n$textSummary")
val previewCount = minOf(elements.size, 5)
if (previewCount > 0) {
append("\n\n")
for (i in 0 until previewCount) {
val obj = ToolJson.parseMaybeObject(elements[i])
val idx = ToolJson.intValue(obj?.get("index"))
val role = ToolJson.firstString(obj, listOf("role"))
val label = ToolJson.firstString(obj, listOf("label"))
append(" #$idx ${role}${label.takeIf { it.isNotEmpty() }?.let { " '$it'" } ?: ""}\n")
}
if (elements.size > previewCount) {
append(" ... and ${elements.size - previewCount} more")
}
}
} else if (action.isNotEmpty() && multimodalText.isEmpty() &&
ToolJson
.firstString(
result,
listOf("text_summary"),
).isEmpty() && textSummary.isEmpty()
) {
append("$action:")
when {
ok -> append(" ✅ ok")
fail -> append(" ❌ failed")
}
if (message.isNotEmpty()) append("\n$message")
} else if (isEmpty()) {
append("Computer use action")
}
}
return if (visionAnalysis.isNotEmpty()) "$body\n\n━━━ Vision Analysis ━━━\n$visionAnalysis" else body
}
}
@@ -0,0 +1,139 @@
package com.m57.hermescontrol.ui.chat.tool.render
import com.m57.hermescontrol.ui.chat.tool.ToolCall
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
/**
* `delegate_task`: fan-out of subagent tasks.
* Covers joined results, background dispatch handles, and list/steer/stop acks.
*/
internal object DelegateTaskRenderer : ToolRenderer {
private fun goals(call: ToolCall): List<String> {
val tasks = call.args?.get("tasks") as? JsonArray
val fromTasks =
tasks?.mapIndexedNotNull { i, el ->
ToolJson
.parseMaybeObject(el)
?.let { ToolJson.firstString(it, listOf("goal")) }
?.ifEmpty { "Task ${i + 1}" }
} ?: emptyList()
if (fromTasks.isNotEmpty()) return fromTasks
return ToolJson
.firstString(call.args, listOf("goal"))
.takeIf { it.isNotEmpty() }
?.let { listOf(it) } ?: emptyList()
}
private fun action(call: ToolCall) = ToolJson.firstString(call.args, listOf("action")).ifEmpty { "spawn" }
override fun pendingTitle(call: ToolCall): String =
when (action(call)) {
"list" -> {
"Listing subagents"
}
"steer" -> {
"Steering subagent"
}
"stop" -> {
"Stopping subagent"
}
else -> {
val n = goals(call).size.coerceAtLeast(1)
"Delegating $n task${if (n == 1) "" else "s"}"
}
}
override fun doneTitle(call: ToolCall): String {
val result = call.result
return when {
action(call) == "list" -> {
val n = ToolJson.intValue(result?.get("count")) ?: 0
"$n subagent${if (n == 1) "" else "s"}"
}
action(call) == "steer" -> {
"Steer queued"
}
action(call) == "stop" -> {
"Stop requested"
}
ToolJson.firstString(result, listOf("status")) == "dispatched" -> {
val n = ToolJson.intValue(result?.get("count")) ?: goals(call).size
"Dispatched $n background task${if (n == 1) "" else "s"}"
}
else -> {
val results = result?.get("results") as? JsonArray
val n = results?.size ?: goals(call).size.coerceAtLeast(1)
val failed =
results?.count {
ToolJson.firstString(ToolJson.parseMaybeObject(it), listOf("status")) == "failed"
} ?: 0
if (failed > 0) {
"Delegated $n tasks · $failed failed"
} else {
"Delegated $n task${if (n == 1) "" else "s"}"
}
}
}
}
override fun subtitle(call: ToolCall): String = ToolJson.compactPreview(goals(call).firstOrNull() ?: "", 120)
override fun detail(call: ToolCall): String {
val result = call.result
// list action: one row per live subagent
(result?.get("subagents") as? JsonArray)?.let { subs ->
return subs
.mapNotNull { el ->
val s = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
val goal = ToolJson.compactPreview(ToolJson.firstString(s, listOf("goal")), 80)
val status = ToolJson.firstString(s, listOf("status"))
val secs = ToolJson.numberValue(s["running_seconds"])
val time = secs?.let { " · ${ToolJson.formatDurationSeconds(it)}" } ?: ""
"- [$status] $goal$time"
}.joinToString("\n")
}
// joined spawn: per-task result rows
val rows =
(result?.get("results") as? JsonArray)?.mapNotNull { el ->
val r = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
val idx = ToolJson.intValue(r["task_index"]) ?: 0
val ok = ToolJson.firstString(r, listOf("status")) != "failed"
val marker = if (ok) "[x]" else "[!]"
val goal = goals(call).getOrNull(idx) ?: "Task ${idx + 1}"
val model = ToolJson.firstString(r, listOf("model"))
val dur =
ToolJson
.numberValue(r["duration_seconds"])
?.let { ToolJson.formatDurationSeconds(it) } ?: ""
val meta = listOf(model, dur).filter { it.isNotEmpty() }.joinToString(" · ")
val summary =
ToolJson.compactPreview(
ToolJson.firstString(r, listOf("summary", "error")),
160,
)
buildString {
append("$marker ${ToolJson.compactPreview(goal, 80)}")
if (meta.isNotEmpty()) append(" ($meta)")
if (summary.isNotEmpty()) append("\n $summary")
}
} ?: emptyList()
if (rows.isNotEmpty()) return rows.joinToString("\n")
// dispatched handle / steer / stop acks
val note = ToolJson.firstString(result, listOf("note"))
val goalsArr =
(result?.get("goals") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }
?.joinToString("\n") { "- ${ToolJson.compactPreview(it, 100)}" } ?: ""
return listOf(goalsArr, note).filter { it.isNotEmpty() }.joinToString("\n\n")
}
}
@@ -0,0 +1,208 @@
package com.m57.hermescontrol.ui.chat.tool.render
import com.m57.hermescontrol.ui.chat.tool.DiffStats
import com.m57.hermescontrol.ui.chat.tool.ToolCall
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras
import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/** Path/diff plumbing shared by the file-oriented renderers. */
internal object FileEditSupport {
private val HTML_PATH_REGEX =
Regex(
"(?:^|\\s)(?:[ab]/)?([^\\s]+\\.html?)(?=\\s|$)",
RegexOption.IGNORE_CASE,
)
fun basename(path: String): String {
val normalized = path.replace('\\', '/').trim()
return normalized.split("/").filter { it.isNotEmpty() }.lastOrNull() ?: normalized
}
fun editPath(
args: JsonObject?,
result: JsonObject?,
): String {
val fromArgs = ToolJson.firstString(args, listOf("path", "file", "filepath"))
if (fromArgs.isNotEmpty()) {
return fromArgs
}
val fromResult = ToolJson.firstString(result, listOf("path", "file", "filepath", "resolved_path"))
if (fromResult.isNotEmpty()) {
return fromResult
}
return htmlPathFromInlineDiff(ToolJson.firstString(result, listOf("inline_diff", "diff")))
}
private fun stripInlineDiffChrome(value: String): String =
ToolJson
.stripAnsi(value)
.replace(Regex("^\\s*┊\\s*review diff\\s*\\n", RegexOption.IGNORE_CASE), "")
.trim()
fun inlineDiffFromResult(result: JsonElement?): String {
val record = ToolJson.parseMaybeObject(result) ?: return ""
for (key in listOf("inline_diff", "diff")) {
val value = record[key]
if (value is JsonPrimitive && value.isString && value.content.trim().isNotEmpty()) {
return stripInlineDiffChrome(value.content)
}
}
return ""
}
private fun htmlPathFromInlineDiff(value: String): String {
val cleaned = stripInlineDiffChrome(value)
for (match in HTML_PATH_REGEX.findAll(cleaned)) {
val candidate = match.groupValues[1].trim()
if (candidate.isNotEmpty()) {
return candidate
}
}
return ""
}
fun countDiffLineStats(diff: String): DiffStats {
var added = 0
var removed = 0
for (line in diff.split("\n")) {
when {
line.startsWith("+") && !line.startsWith("+++") -> added += 1
line.startsWith("-") && !line.startsWith("---") -> removed += 1
}
}
return DiffStats(added = added, removed = removed)
}
/** Subtitle shared by read_file and the edit family: the path, or a summary. */
fun pathSubtitle(
call: ToolCall,
path: String,
): String = path.ifEmpty { ToolJson.fallbackDetailText(call.args, call.rawResult) }
}
/** `read_file`: "Read name.kt L10-14" title with the file content as detail. */
internal object ReadFileRenderer : ToolRenderer {
private fun displayTarget(
args: JsonObject?,
result: JsonObject?,
): String {
val inherited = ToolJson.firstString(args, listOf("context", "preview"))
if (inherited.isNotEmpty()) {
return inherited
}
val path = ToolJson.firstString(args, listOf("path", "file", "filepath"))
if (path.isEmpty()) {
return ""
}
val offset = ToolJson.intValue(args?.get("offset"))
val limit = ToolJson.intValue(args?.get("limit"))
var lineLabel = ""
if (offset != null) {
lineLabel = if (limit == null || limit <= 1) "L$offset" else "L$offset-${offset + limit - 1}"
} else if (limit != null) {
val content = ToolJson.firstString(result, listOf("content"))
val lines =
content
.split("\n")
.mapNotNull { line ->
Regex("^(\\d+)\\|")
.find(line)
?.groupValues
?.get(1)
?.toIntOrNull()
}
if (lines.isNotEmpty()) {
lineLabel =
if (lines.first() == lines.last()) {
"L${lines.first()}"
} else {
"L${lines.first()}-${lines.last()}"
}
}
}
return listOf(FileEditSupport.basename(path), lineLabel).filter { it.isNotEmpty() }.joinToString(" ")
}
override fun pendingTitle(call: ToolCall): String = "Reading ${displayTarget(call.args, null)}"
override fun doneTitle(call: ToolCall): String = "Read ${displayTarget(call.args, call.result)}"
override fun subtitle(call: ToolCall): String =
FileEditSupport.pathSubtitle(call, ToolJson.firstString(call.args, listOf("path", "file", "filepath")))
override fun detail(call: ToolCall): String {
if (call.rawResult == null) {
return ""
}
return ToolJson.firstString(call.result, listOf("content", "text", "data", "body"))
}
}
/**
* `edit_file` / `patch` / `write_file`: basename title, path subtitle, and
* the inline diff (with +/− stats) as the primary payload. Text detail only
* appears when there is no diff to render.
*/
internal object FileEditRenderer : ToolRenderer {
override fun doneTitle(call: ToolCall): String? {
val path = FileEditSupport.editPath(call.args, call.result)
return if (path.isNotEmpty()) FileEditSupport.basename(path) else null
}
override fun subtitle(call: ToolCall): String =
FileEditSupport.pathSubtitle(call, FileEditSupport.editPath(call.args, call.result))
override fun detail(call: ToolCall): String {
if (FileEditSupport.inlineDiffFromResult(call.rawResult).isNotEmpty()) {
return ""
}
return ToolJson.firstString(call.result, listOf("message", "summary")).ifEmpty {
if (FileEditSupport.editPath(call.args, call.result).isNotEmpty()) {
""
} else {
ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
}
}
}
override fun extras(
call: ToolCall,
status: ToolViewStatus,
): ToolViewExtras {
val inlineDiff = FileEditSupport.inlineDiffFromResult(call.rawResult)
if (inlineDiff.isEmpty()) {
return ToolViewExtras.NONE
}
return ToolViewExtras(
inlineDiff = inlineDiff,
diffPath = FileEditSupport.editPath(call.args, call.result),
diffStats = FileEditSupport.countDiffLineStats(inlineDiff),
)
}
}
@@ -0,0 +1,86 @@
package com.m57.hermescontrol.ui.chat.tool.render
import com.m57.hermescontrol.ui.chat.tool.ToolCall
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
/** `image_generate`: generated-image URL with the prompt in the detail. */
internal object ImageGenerateRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String {
val imageUrl =
ToolJson
.firstString(call.result, listOf("image"))
.ifEmpty { ToolJson.firstString(call.args, listOf("image_url")) }
val modality = ToolJson.firstString(call.result, listOf("modality"))
return listOf(imageUrl.take(60), modality.takeIf { it.isNotEmpty() }?.let { "($it)" })
.filterNotNull()
.joinToString(" ")
}
override fun detail(call: ToolCall): String {
val error = ToolJson.firstString(call.result, listOf("error"))
val imageUrl = ToolJson.firstString(call.result, listOf("image"))
val prompt = ToolJson.firstString(call.args, listOf("prompt"))
return buildString {
if (error.isNotEmpty()) {
append("❌ $error")
if (imageUrl.isNotEmpty()) append("\n🔗 $imageUrl")
} else if (imageUrl.isNotEmpty()) {
append("🖼️ ")
if (prompt.isNotEmpty()) append("$prompt\n")
append("\n🔗 $imageUrl")
} else {
append("✅ Generated")
}
}
}
}
/** `vision_analyze`: the analyzed image URL and the model's description. */
internal object VisionAnalyzeRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String = ToolJson.firstString(call.args, listOf("image_url")).take(60)
override fun detail(call: ToolCall): String {
val error = ToolJson.firstString(call.result, listOf("error"))
val description = ToolJson.firstString(call.result, listOf("description"))
val content = ToolJson.firstString(call.result, listOf("content"))
return when {
error.isNotEmpty() -> "❌ $error"
description.isNotEmpty() -> description
content.isNotEmpty() -> content
else -> "No description available"
}
}
}
/** `text_to_speech`: audio synthesis. Success payload carries file_path(s), provider, chunk_count. */
internal object TextToSpeechRenderer : ToolRenderer {
override fun pendingTitle(call: ToolCall): String = "Generating speech"
override fun doneTitle(call: ToolCall): String = "Generated speech"
override fun subtitle(call: ToolCall): String {
val provider = ToolJson.firstString(call.result, listOf("provider"))
val chunks = ToolJson.intValue(call.result?.get("chunk_count"))
val parts =
listOfNotNull(
provider.takeIf { it.isNotEmpty() },
chunks?.takeIf { it > 1 }?.let { "$it chunks" },
)
return parts
.joinToString(" · ")
.ifEmpty { ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("text")), 120) }
}
override fun detail(call: ToolCall): String {
val text = ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("text")), 400)
val path = ToolJson.firstString(call.result, listOf("file_path", "output_path"))
return listOfNotNull(
text.takeIf { it.isNotEmpty() }?.let { "“$it”" },
path.takeIf { it.isNotEmpty() }?.let { "Saved to $it" },
).joinToString("\n\n")
}
}
@@ -0,0 +1,97 @@
package com.m57.hermescontrol.ui.chat.tool.render
import com.m57.hermescontrol.ui.chat.tool.ToolCall
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
import kotlinx.serialization.json.JsonArray
/** `memory`: action-verb titles that keep the write target visible. */
internal object MemoryRenderer : ToolRenderer {
override fun pendingTitle(call: ToolCall): String = "Saving"
override fun doneTitle(call: ToolCall): String {
val action = ToolJson.firstString(call.args, listOf("action")).lowercase()
val target = ToolJson.firstString(call.args, listOf("target"))
val base =
when (action) {
"replace", "update" -> "Memory Updated"
"remove", "delete" -> "Memory Removed"
"add" -> "Memory Saved"
else -> "Memory ${ToolJson.firstString(call.args, listOf("action"))}"
}
// Keep the target visible — "Memory Saved (user)" — like the
// pre-engine mobile summary did.
return if (target.isNotEmpty()) "$base ($target)" else base
}
override fun subtitle(call: ToolCall): String = ToolJson.firstString(call.result, listOf("message", "error"))
override fun detail(call: ToolCall): String = ToolJson.firstString(call.result, listOf("message", "error"))
}
/** `fact_store`: add/remove/update acks and fact listings with trust scores. */
internal object FactStoreRenderer : ToolRenderer {
private fun facts(call: ToolCall): JsonArray? =
(call.result?.get("results") as? JsonArray)
?: (call.result?.get("facts") as? JsonArray)
override fun subtitle(call: ToolCall): String {
val args = call.args
val result = call.result
val action =
ToolJson
.firstString(
args,
listOf("action"),
).ifEmpty { ToolJson.firstString(result, listOf("action")) }
val entity = ToolJson.firstString(args, listOf("entity"))
val query = ToolJson.firstString(args, listOf("query"))
val facts = facts(call)
val count = ToolJson.intValue(result?.get("count")) ?: facts?.size ?: 0
val status = ToolJson.firstString(result, listOf("status"))
val factId = ToolJson.intValue(result?.get("fact_id"))
val removed = ToolJson.boolTrue(result?.get("removed"))
val updated = ToolJson.boolTrue(result?.get("updated"))
val actionContext =
when {
entity.isNotEmpty() -> " ($action: $entity)"
query.isNotEmpty() -> " ($action: $query)"
else -> " ($action)"
}
return when {
status == "added" && factId != null -> "Fact added (ID: $factId)"
status == "added" -> "Fact added"
removed -> "Fact removed"
updated -> "Fact updated"
facts != null -> "$count facts$actionContext"
else -> action
}
}
override fun detail(call: ToolCall): String =
facts(call)
?.mapNotNull { el ->
val item = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
val fid = ToolJson.intValue(item["fact_id"]) ?: 0
val content = ToolJson.firstString(item, listOf("content"))
val category = ToolJson.firstString(item, listOf("category"))
val trust = ToolJson.numberValue(item["trust_score"])
val tags = ToolJson.firstString(item, listOf("tags"))
val metaParts = mutableListOf<String>()
if (category.isNotEmpty()) metaParts += "[$category]"
if (trust != null) metaParts += "trust: ${"%.2f".format(trust)}"
if (tags.isNotEmpty()) metaParts += "🏷️ $tags"
if (metaParts.isNotEmpty()) {
"#$fid $content\n ${metaParts.joinToString(" ")}"
} else {
"#$fid $content"
}
}?.joinToString("\n")
?.takeIf { it.isNotEmpty() }
?: ""
}
@@ -0,0 +1,68 @@
package com.m57.hermescontrol.ui.chat.tool.render
import com.m57.hermescontrol.ui.chat.tool.ToolCall
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
/** `project_list`: project roster with the active project starred. */
internal object ProjectListRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String {
val projects = (call.result?.get("projects") as? JsonArray)?.size
val active = ToolJson.firstString(call.result, listOf("active_id"))
return if (projects != null) {
"$projects projects${active.takeIf { it.isNotEmpty() }?.let { " (1 active)" } ?: ""}"
} else {
"Projects"
}
}
override fun detail(call: ToolCall): String =
(call.result?.get("projects") as? JsonArray)
?.mapNotNull { el ->
val p = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
val name = ToolJson.firstString(p, listOf("name"))
val slug = ToolJson.firstString(p, listOf("slug"))
val path = ToolJson.firstString(p, listOf("primary_path"))
val isActive =
(p["active"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true
val lines = mutableListOf("${if (isActive) "⭐ " else "📁 "}$name")
if (slug.isNotEmpty()) lines += " 🏷️ $slug"
if (path.isNotEmpty()) lines += " 📍 $path"
if (isActive) lines += " ✅ ACTIVE PROJECT"
lines.joinToString("\n")
}?.joinToString("\n\n")
?: ""
}
/** `project_create` / `project_switch`: mutation ack with name/slug/path. */
internal object ProjectMutateRenderer : ToolRenderer {
private fun actionLabel(call: ToolCall): String = if (call.name == "project_create") "Created" else "Switched to"
override fun subtitle(call: ToolCall): String {
val name = ToolJson.firstString(call.result, listOf("name"))
val label = actionLabel(call)
return if (name.isNotEmpty()) "$label $name" else label
}
override fun detail(call: ToolCall): String {
val error = ToolJson.firstString(call.result, listOf("error"))
val name = ToolJson.firstString(call.result, listOf("name"))
val slug = ToolJson.firstString(call.result, listOf("slug"))
val path = ToolJson.firstString(call.result, listOf("primary_path"))
return buildString {
if (error.isNotEmpty()) {
append("❌ $error")
} else {
append("✅ ${actionLabel(call)}")
if (name.isNotEmpty()) append(": $name")
if (slug.isNotEmpty()) append("\n 🏷️ $slug")
if (path.isNotEmpty()) append("\n 📍 $path")
}
}
}
}
@@ -0,0 +1,69 @@
package com.m57.hermescontrol.ui.chat.tool.render
import com.m57.hermescontrol.ui.chat.tool.ToolCall
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras
import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
/**
* `search_files`: ripgrep-backed content/file search.
* Handles both result shapes: dense path-grouped `matches_text` string (≥5 hits)
* and `matches:[{path,line,content}]` (<5 hits), plus `files:[path]`.
*/
internal object SearchFilesRenderer : ToolRenderer {
private fun pattern(call: ToolCall) = ToolJson.firstString(call.args, listOf("pattern"))
override fun pendingTitle(call: ToolCall): String? =
pattern(call).takeIf { it.isNotEmpty() }?.let { "Searching “${ToolJson.compactPreview(it, 48)}”" }
override fun doneTitle(call: ToolCall): String? {
val p = pattern(call).takeIf { it.isNotEmpty() } ?: return null
val total = ToolJson.intValue(call.result?.get("total_count"))
val suffix = total?.let { " · $it match${if (it == 1) "" else "es"}" } ?: ""
return "Searched “${ToolJson.compactPreview(p, 48)}”$suffix"
}
override fun subtitle(call: ToolCall): String {
val path = ToolJson.firstString(call.args, listOf("path")).takeIf { it != "." && it.isNotEmpty() } ?: ""
val glob = ToolJson.firstString(call.args, listOf("file_glob"))
return listOf(path, glob).filter { it.isNotEmpty() }.joinToString(" · ")
}
override fun detail(call: ToolCall): String {
val result = call.result ?: return ""
// Dense ≥5-match block: already ripgrep-grouped, use verbatim.
val dense = ToolJson.firstString(result, listOf("matches_text"))
val body =
if (dense.isNotEmpty()) {
dense
} else {
(result["matches"] as? JsonArray)
?.mapNotNull { el ->
val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
val path = ToolJson.firstString(m, listOf("path"))
val line = ToolJson.intValue(m["line"])
val content = ToolJson.firstString(m, listOf("content"))
"$path:${line ?: "?"}: ${content.trim()}"
}?.joinToString("\n")
?: (result["files"] as? JsonArray)
?.mapNotNull {
(it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content
}?.joinToString("\n") ?: ""
}
val hint =
if (ToolJson.boolTrue(result["truncated"])) {
ToolJson.firstString(result, listOf("_hint")).ifEmpty { "Results truncated" }
} else {
""
}
return listOf(body, hint).filter { it.isNotEmpty() }.joinToString("\n\n")
}
override fun extras(
call: ToolCall,
status: ToolViewStatus,
): ToolViewExtras = ToolViewExtras(detailLabel = "Matches")
}
@@ -0,0 +1,140 @@
package com.m57.hermescontrol.ui.chat.tool.render
import com.m57.hermescontrol.ui.chat.tool.ToolCall
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/** `skills_list`: count-with-category subtitle and a name/description list. */
internal object SkillsListRenderer : ToolRenderer {
private fun skills(call: ToolCall): JsonArray? =
(call.result?.get("skills") as? JsonArray)
?: (call.result?.get("results") as? JsonArray)
override fun subtitle(call: ToolCall): String {
val category = ToolJson.firstString(call.args, listOf("category"))
val count = skills(call)?.size ?: 0
return if (count > 0) {
"$count skills${category.takeIf { it.isNotEmpty() }?.let { " ($it)" } ?: ""}"
} else {
"No skills found"
}
}
override fun detail(call: ToolCall): String =
skills(call)
?.mapNotNull { el ->
val s = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
val name = ToolJson.firstString(s, listOf("name"))
val desc = ToolJson.firstString(s, listOf("description"))
val cat = ToolJson.firstString(s, listOf("category"))
val lines = mutableListOf("📌 $name")
if (desc.isNotEmpty()) lines += " $desc"
if (cat.isNotEmpty()) lines += " [$cat]"
lines.joinToString("\n")
}?.joinToString("\n\n")
?: "No skills found"
}
/** `skill_view`: content preview capped at 500 chars plus linked files. */
internal object SkillViewRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String = ToolJson.firstString(call.args, listOf("name"))
override fun detail(call: ToolCall): String {
val content = ToolJson.firstString(call.result, listOf("content"))
val linkedFiles = call.result?.get("linked_files") as? JsonObject
val contentPreview =
if (content.length > 500) {
"${content.take(
500,
)}\n... [${content.length - 500} more chars]"
} else {
content
}
val filesInfo =
linkedFiles
?.entries
?.joinToString("\n") { (k, v) ->
val paths =
when (v) {
is JsonArray -> {
v.joinToString(
", ",
) { (it as? JsonPrimitive)?.content ?: it.toString() }
}
is JsonPrimitive -> {
v.content
}
else -> {
v.toString()
}
}
" 📎 $k: $paths"
}
return buildString {
if (contentPreview.isNotEmpty()) append(contentPreview)
if (filesInfo != null) {
if (contentPreview.isNotEmpty()) append("\n\n")
append("━━━ Linked Files ━━━\n$filesInfo")
}
if (content.isEmpty() && linkedFiles == null) append("No content available")
}
}
}
/** `skill_manage`: action ack with the skill name. */
internal object SkillManageRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String {
val action = ToolJson.firstString(call.args, listOf("action"))
val name = ToolJson.firstString(call.args, listOf("name"))
return if (name.isNotEmpty()) "Skill $action: $name" else "Skill $action"
}
override fun detail(call: ToolCall): String {
val error = ToolJson.firstString(call.result, listOf("error"))
if (error.isNotEmpty()) {
return "❌ $error"
}
val msg = ToolJson.firstString(call.result, listOf("message"))
val failed = ToolJson.boolFalse(call.result?.get("success"))
// Pinned quirk from the original engine: the branch order is inverted
// (a non-false `success` renders the failure line). Preserved verbatim
// by the characterization suite; fix upstream deliberately if desired.
return when {
!failed -> "❌ Skill operation failed"
msg.isNotEmpty() -> "✅ $msg"
else -> "✅ Skill operation done"
}
}
}
/** `tool_search`: on-demand tool discovery matches. */
internal object ToolSearchRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String {
val query = ToolJson.firstString(call.args, listOf("query"))
val matches = (call.result?.get("matches") as? JsonArray)?.size
return if (matches != null) "$query ($matches matches)" else query
}
override fun detail(call: ToolCall): String =
(call.result?.get("matches") as? JsonArray)
?.mapNotNull { el ->
val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
val name = ToolJson.firstString(m, listOf("name"))
val desc = ToolJson.firstString(m, listOf("description"))
val lines = mutableListOf("🔧 $name")
if (desc.isNotEmpty()) lines += " $desc"
lines.joinToString("\n")
}?.joinToString("\n\n")
?: "No matching tools"
}
@@ -0,0 +1,265 @@
package com.m57.hermescontrol.ui.chat.tool.render
import com.m57.hermescontrol.ui.chat.tool.ToolCall
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/** `cronjob`: job listings with schedules, or the mutated job's key fields. */
internal object CronjobRenderer : ToolRenderer {
override fun doneTitle(call: ToolCall): String =
"Cron ${ToolJson.firstString(call.args, listOf("action")).ifEmpty { "manage" }}"
override fun subtitle(call: ToolCall): String {
val jobs = call.result?.get("jobs") as? JsonArray
if (jobs != null) {
return if (jobs.isNotEmpty()) {
"${jobs.size} cron ${if (jobs.size == 1) "job" else "jobs"}"
} else {
"No cron jobs"
}
}
val message = ToolJson.firstString(call.result, listOf("message"))
if (message.isNotEmpty()) {
return message
}
val action = ToolJson.firstString(call.args, listOf("action")).ifEmpty { "manage" }
val name =
ToolJson
.firstString(call.result, listOf("name"))
.ifEmpty { ToolJson.firstString(call.args, listOf("name", "job_id")) }
val label = action.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
return if (name.isNotEmpty()) "$label $name" else "Cron $action"
}
override fun detail(call: ToolCall): String {
val result = call.result
val jobs = result?.get("jobs") as? JsonArray
if (jobs != null) {
if (jobs.isEmpty()) {
return "No cron jobs scheduled"
}
return jobs
.take(20)
.mapNotNull { job ->
val row = ToolJson.parseMaybeObject(job) ?: return@mapNotNull null
val name = ToolJson.firstString(row, listOf("name", "id")).ifEmpty { "job" }
val sched = ToolJson.firstString(row, listOf("schedule_display", "schedule"))
if (sched.isNotEmpty()) "- $name · $sched" else "- $name"
}.joinToString("\n")
}
val rows =
listOf(
"Schedule" to ToolJson.firstString(result, listOf("schedule")),
"Repeat" to ToolJson.firstString(result, listOf("repeat")),
"Delivery" to ToolJson.firstString(result, listOf("deliver")),
"Next run" to ToolJson.firstString(result, listOf("next_run_at")),
).filter { it.second.isNotEmpty() }
return if (rows.isNotEmpty()) {
rows.joinToString(
"\n",
) { (k, v) -> "$k: $v" }
} else {
ToolJson.fallbackDetailText(call.args, result)
}
}
}
/** `todo`: status-count subtitle plus a checklist with [x]/[>]/[~] markers. */
internal object TodoRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String {
val summary = call.result?.get("summary") as? JsonObject ?: return ""
val total = ToolJson.intValue(summary["total"]) ?: 0
val pending = ToolJson.intValue(summary["pending"]) ?: 0
val inProgress = ToolJson.intValue(summary["in_progress"]) ?: 0
val completed = ToolJson.intValue(summary["completed"]) ?: 0
val cancelled = ToolJson.intValue(summary["cancelled"]) ?: 0
val parts =
listOf(
pending.takeIf { it > 0 }?.let { "$it pending" },
inProgress.takeIf { it > 0 }?.let { "$it in_progress" },
completed.takeIf { it > 0 }?.let { "$it completed" },
cancelled.takeIf { it > 0 }?.let { "$it cancelled" },
).filterNotNull()
val itemWord = if (total == 1) "item" else "items"
return if (parts.isEmpty()) "0 items" else "$total $itemWord (${parts.joinToString(", ")})"
}
override fun detail(call: ToolCall): String {
val todos = call.result?.get("todos") as? JsonArray ?: return ""
return todos
.mapNotNull { el ->
val item = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
val id = ToolJson.firstString(item, listOf("id"))
val content = ToolJson.firstString(item, listOf("content"))
val status = ToolJson.firstString(item, listOf("status")).ifEmpty { "pending" }
val parent = ToolJson.firstString(item, listOf("parent"))
val marker =
when (status) {
"completed" -> "[x]"
"in_progress" -> "[>]"
"cancelled" -> "[~]"
else -> "[ ]"
}
val indent = if (parent.isNotEmpty()) " ↳ " else ""
"$indent$marker $id. $content"
}.joinToString("\n")
}
}
/** `session_search`: mode-specific subtitles and rich session/message lists. */
internal object SessionSearchRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String {
val result = call.result
val mode = ToolJson.firstString(result, listOf("mode"))
val query = ToolJson.firstString(result, listOf("query"))
val count = ToolJson.intValue(result?.get("count"))
val msgCount = ToolJson.intValue(result?.get("message_count"))
val messages = result?.get("messages") as? JsonArray
val truncated = ToolJson.boolTrue(result?.get("truncated"))
return when (mode) {
"discover" -> "${count ?: 0} session${if (count == 1) "" else "s"}${query.takeIf { it.isNotEmpty() }?.let {
": ${it.take(
60,
)}"
} ?: ""}"
"scroll" -> "${messages?.size ?: 0} messages (scroll)"
"read" -> "${msgCount ?: messages?.size ?: 0} messages${if (truncated) " (truncated)" else ""}"
"browse" -> "${count ?: 0} recent sessions"
else -> "session_search"
}
}
override fun detail(call: ToolCall): String {
val result = call.result
val results = result?.get("results") as? JsonArray
val messages = result?.get("messages") as? JsonArray
val formattedResults =
results
?.mapIndexedNotNull { idx, el ->
val item = ToolJson.parseMaybeObject(el) ?: return@mapIndexedNotNull null
val whenField = ToolJson.firstString(item, listOf("when", "started_at"))
val source = ToolJson.firstString(item, listOf("source"))
val title = ToolJson.firstString(item, listOf("title"))
val snippet =
ToolJson
.firstString(item, listOf("snippet"))
.ifEmpty { ToolJson.firstString(item, listOf("preview")) }
val model = ToolJson.firstString(item, listOf("model"))
val matchedRole = ToolJson.firstString(item, listOf("matched_role"))
val sessionMsgCount = ToolJson.intValue(item["message_count"])
val header = whenField.takeIf { it.isNotEmpty() }?.let { "📅 $it" } ?: ""
val sourceTag = source.takeIf { it.isNotEmpty() }?.let { "[$it]" } ?: ""
val titleLine = title.takeIf { it.isNotEmpty() }?.let { "\n 📄 $it" } ?: ""
val modelLine = model.takeIf { it.isNotEmpty() }?.let { "\n 🤖 $it" } ?: ""
val matchedLine = matchedRole.takeIf { it.isNotEmpty() }?.let { "\n 🎯 matched: $it" } ?: ""
val countLine = sessionMsgCount?.let { "\n $it msgs" } ?: ""
val snippetLine =
snippet.takeIf { it.isNotEmpty() }?.let { "\n ┃ ${it.take(200).replace("\n", " ")}" } ?: ""
"━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine"
}?.joinToString("\n")
?.takeIf { it.isNotEmpty() }
val anchorId = ToolJson.intValue(result?.get("around_message_id"))
val formattedMessages =
messages
?.mapNotNull { el ->
val item = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
val msgId = ToolJson.intValue(item["id"])
val role = ToolJson.firstString(item, listOf("role")).ifEmpty { "?" }
val content = ToolJson.firstString(item, listOf("content"))
val toolName = ToolJson.firstString(item, listOf("tool_name"))
val roleEmoji =
when (role) {
"user" -> "👤"
"assistant" -> "🤖"
"tool" -> "🔧"
else -> "❓"
}
val namePart = if (toolName.isNotEmpty()) " ($toolName)" else ""
val anchor = if (anchorId != null && msgId == anchorId) " ⬅️" else ""
val cleanContent = content.take(300).replace("\n", " ")
"[$msgId] $roleEmoji $role$namePart$anchor\n $cleanContent"
}?.joinToString("\n")
?.takeIf { it.isNotEmpty() }
return formattedResults ?: formattedMessages ?: ""
}
}
/** `process`: background session listings and lifecycle acks. */
internal object ProcessRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String {
val action = ToolJson.firstString(call.args, listOf("action"))
val procId = ToolJson.firstString(call.args, listOf("session_id"))
return if (procId.isNotEmpty()) "$action: $procId" else action
}
override fun detail(call: ToolCall): String {
val result = call.result
val error = ToolJson.firstString(result, listOf("error"))
if (error.isNotEmpty()) {
return "❌ $error"
}
val processes = result?.get("processes") as? JsonArray
if (processes != null) {
return processes
.mapNotNull { el ->
val p = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
val pid =
ToolJson
.firstString(p, listOf("session_id"))
.ifEmpty { ToolJson.firstString(p, listOf("id")) }
val pStatus = ToolJson.firstString(p, listOf("status"))
val cmd = ToolJson.firstString(p, listOf("command"))
val running =
(p["running"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" }
val parts = mutableListOf("📌 $pid${cmd.takeIf { it.isNotEmpty() }?.let { ": $it" } ?: ""}")
if (pStatus.isNotEmpty()) parts += " Status: $pStatus"
if (running != null) parts += " Running: $running"
parts.joinToString("\n")
}.joinToString("\n\n")
}
val output = ToolJson.firstString(result, listOf("output"))
if (output.isNotEmpty()) {
val status = ToolJson.firstString(result, listOf("status"))
return "${status.takeIf { it.isNotEmpty() }?.let { "Status: $it\n" } ?: ""}$output"
}
val status = ToolJson.firstString(result, listOf("status"))
if (status.isNotEmpty()) {
return "✅ $status"
}
val action = ToolJson.firstString(call.args, listOf("action"))
return "✅ ${action.ifEmpty { "done" }} done"
}
}
@@ -0,0 +1,152 @@
package com.m57.hermescontrol.ui.chat.tool.render
import com.m57.hermescontrol.ui.chat.tool.CommandSummarizer
import com.m57.hermescontrol.ui.chat.tool.ToolCall
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras
import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* `terminal` and `execute_code`: shell-styled rows with a summarized command
* title, first output line as subtitle, and split (or merged) streams for
* the ANSI-aware expanded view. Exit code and the `$` command line are only
* meaningful for `terminal`.
*/
internal object TerminalRenderer : ToolRenderer {
private fun shellCommand(args: JsonObject?): String =
ToolJson
.firstString(args, listOf("command", "code"))
.ifEmpty { ToolJson.firstString(args, listOf("context", "preview")) }
.ifEmpty { ToolJson.contextValue(args) }
private fun shellOutput(result: JsonObject?): String {
val output = ToolJson.firstString(result, listOf("output", "stdout", "stderr"))
val lines =
(result?.get("lines") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }
?.joinToString("\n")
?: ""
return listOf(output, lines).filter { it.isNotEmpty() }.joinToString("\n")
}
private fun commandTitle(
call: ToolCall,
runningVerb: Boolean,
): String? {
val command = shellCommand(call.args)
val verb =
when {
call.name == "execute_code" && runningVerb -> "Running code"
call.name == "execute_code" -> "Ran code"
runningVerb -> "Running"
else -> "Ran"
}
if (command.isEmpty()) {
return null
}
return "$verb ${ToolJson.compactPreview(CommandSummarizer.summarizeShellCommand(command), 160)}"
}
override fun pendingTitle(call: ToolCall): String? = commandTitle(call, runningVerb = true)
override fun doneTitle(call: ToolCall): String? = commandTitle(call, runningVerb = false)
override fun subtitle(call: ToolCall): String {
val firstMeaningfulLine =
shellOutput(call.result)
.split("\n")
.map { it.trim() }
.firstOrNull { it.isNotEmpty() }
// A terminal row with no output shows its command in the title;
// nothing more to add.
return firstMeaningfulLine?.let { ToolJson.compactPreview(it, 160) } ?: ""
}
override fun detail(call: ToolCall): String {
val output = shellOutput(call.result)
return when {
output.isNotEmpty() -> output
call.name == "execute_code" -> ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
// A terminal row with no output already shows its command in the
// title; the generic fallback would repeat it.
else -> ""
}
}
override fun extras(
call: ToolCall,
status: ToolViewStatus,
): ToolViewExtras {
// The gateway's terminal_tool emits a single merged `output` string
// (stdout+stderr, ANSI-stripped); older payloads carried separate
// `stdout`/`stderr`. Prefer a real stream split, fall back to the
// merged output as the single renderable stream.
val stdout = ToolJson.firstString(call.result, listOf("stdout", "output"))
val stderr = ToolJson.firstString(call.result, listOf("stderr"))
val hasSplitStreams = stdout.isNotEmpty() || stderr.isNotEmpty()
return ToolViewExtras(
stdout = if (hasSplitStreams) stdout else null,
stderr = if (hasSplitStreams) stderr else null,
exitCode = if (call.name == "terminal") ToolJson.intValue(call.result?.get("exit_code")) else null,
terminalCommand = if (call.name == "terminal") shellCommand(call.args) else null,
)
}
}
/** `read_terminal`: a line-window over a live terminal session. */
internal object ReadTerminalRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String {
val result = call.result
val error = ToolJson.firstString(result, listOf("error"))
if (error.isNotEmpty()) {
return "❌ $error"
}
val total = ToolJson.intValue(result?.get("total_lines")) ?: 0
val start = ToolJson.intValue(result?.get("start")) ?: 0
val end = ToolJson.intValue(result?.get("end")) ?: 0
val text = ToolJson.firstString(result, listOf("text"))
if (text.isEmpty()) {
return "Terminal output"
}
val cursor = ToolJson.intValue(result?.get("cursor_row"))
return "Lines $start-$end of $total${cursor?.let { ", cursor at row $it" } ?: ""}"
}
override fun detail(call: ToolCall): String {
val result = call.result
val error = ToolJson.firstString(result, listOf("error"))
val text = ToolJson.firstString(result, listOf("text"))
val total = ToolJson.intValue(result?.get("total_lines")) ?: 0
val start = ToolJson.intValue(result?.get("start")) ?: 0
val end = ToolJson.intValue(result?.get("end")) ?: 0
val cursor = ToolJson.intValue(result?.get("cursor_row"))
return buildString {
if (error.isNotEmpty()) {
append("❌ $error")
} else if (text.isNotEmpty()) {
append("━━━ Terminal ━━━ ($start:$end / $total)")
if (cursor != null) append(" │ cursor row $cursor")
append("\n$text")
} else {
append("No terminal output")
}
}
}
}
@@ -0,0 +1,157 @@
package com.m57.hermescontrol.ui.chat.tool.render
import com.m57.hermescontrol.ui.chat.tool.SearchHit
import com.m57.hermescontrol.ui.chat.tool.ToolCall
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras
import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
/** Resolve the URL a web/browser tool targeted, as a display hostname+path. */
internal fun targetHostname(
args: JsonObject?,
result: JsonObject?,
): String {
val url =
ToolJson
.firstString(args, listOf("url", "target"))
.ifEmpty { ToolJson.firstString(result, listOf("url")) }
.ifEmpty { ToolJson.findFirstUrl(args, result) }
return if (url.isNotEmpty()) ToolJson.hostnameOf(url) else "page"
}
/**
* `web_search`: quoted-query title, parsed [SearchHit] rows for the
* structured result list, and a "Search results" section label.
*/
internal object WebSearchRenderer : ToolRenderer {
private fun searchQueryFor(args: JsonObject?): String =
ToolJson.firstString(args, listOf("search_term", "query")).ifEmpty { ToolJson.contextValue(args) }
private fun extractSearchResults(
result: JsonElement?,
limit: Int = 6,
): List<SearchHit> =
ToolJson
.collectResultItems(result)
.mapNotNull { item ->
val r = ToolJson.parseMaybeObject(item) ?: return@mapNotNull null
SearchHit(
title = ToolJson.cleanVisibleText(ToolJson.firstString(r, listOf("title", "name"))),
url = ToolJson.firstString(r, listOf("url", "href", "link")),
snippet =
ToolJson.cleanVisibleText(
ToolJson.firstString(r, listOf("snippet", "description", "body")),
),
)
}.filter { it.title.isNotEmpty() || it.url.isNotEmpty() }
.take(limit)
override fun pendingTitle(call: ToolCall): String =
"Searching \"${ToolJson.compactPreview(searchQueryFor(call.args), 48)}\""
override fun doneTitle(call: ToolCall): String =
"Searched \"${ToolJson.compactPreview(searchQueryFor(call.args), 48)}\""
override fun subtitle(call: ToolCall): String =
searchQueryFor(call.args).takeIf { it.isNotEmpty() }?.let { "Query: $it" } ?: "Queried web sources"
override fun extras(
call: ToolCall,
status: ToolViewStatus,
): ToolViewExtras {
val hits = if (status != ToolViewStatus.ERROR) extractSearchResults(call.rawResult) else emptyList()
return ToolViewExtras(
detailLabel = if (hits.isNotEmpty()) "Search results" else null,
searchHits = hits.ifEmpty { null },
searchQuery = searchQueryFor(call.args).ifEmpty { null },
)
}
}
/** `web_extract`: hostname title/subtitle with the extracted text as detail. */
internal object WebExtractRenderer : ToolRenderer {
override fun pendingTitle(call: ToolCall): String = "Reading ${targetHostname(call.args, null)}"
override fun doneTitle(call: ToolCall): String = "Read ${targetHostname(call.args, call.result)}"
override fun subtitle(call: ToolCall): String {
val url =
ToolJson
.firstString(call.args, listOf("url"))
.ifEmpty { ToolJson.firstString(call.result, listOf("url")) }
.ifEmpty { ToolJson.findFirstUrl(call.args, call.result) }
return if (url.isNotEmpty()) ToolJson.hostnameOf(url) else "Fetched webpage"
}
override fun detail(call: ToolCall): String {
val direct =
ToolJson.firstString(call.result, listOf("content", "text", "markdown", "body", "summary", "message"))
if (direct.isNotEmpty()) {
return direct.replace(Regex("\\s*in\\s+\\d+(?:\\.\\d+)?s\\s*$"), "").trim()
}
val results = call.result?.get("results") as? JsonArray
if (results != null) {
return results
.mapNotNull { item ->
val row = ToolJson.parseMaybeObject(item) ?: return@mapNotNull null
ToolJson.firstString(row, listOf("content", "text", "markdown", "body"))
}.filter { it.isNotEmpty() }
.joinToString("\n\n---\n\n")
}
return ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
}
}
/** `x_search`: query subtitle plus answer text with numbered citations. */
internal object XSearchRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String {
val query = ToolJson.firstString(call.args, listOf("query"))
val degraded = ToolJson.boolTrue(call.result?.get("degraded"))
return if (query.isNotEmpty()) "$query${if (degraded) " (no citations)" else ""}" else "Queried X"
}
override fun detail(call: ToolCall): String {
val result = call.result
val error = ToolJson.firstString(result, listOf("error"))
val answer = ToolJson.firstString(result, listOf("answer"))
val citations = result?.get("citations") as? JsonArray
val degraded = ToolJson.boolTrue(result?.get("degraded"))
return when {
error.isNotEmpty() -> {
"❌ $error"
}
answer.isNotEmpty() -> {
val lines = mutableListOf(answer)
if (citations != null && citations.isNotEmpty()) {
lines += "\n━━━ Citations ━━━"
citations.forEachIndexed { idx, cit ->
val c = ToolJson.parseMaybeObject(cit) ?: return@forEachIndexed
val url = ToolJson.firstString(c, listOf("url"))
val title = ToolJson.firstString(c, listOf("title"))
lines += "${idx + 1}. ${title.ifEmpty { url.ifEmpty { "source" } }}"
if (title.isNotEmpty() && url.isNotEmpty()) lines += " $url"
}
}
if (degraded) lines += "\n⚠️ No citations — answer based on model's knowledge"
lines.joinToString("\n")
}
else -> {
"No results"
}
}
}
}
@@ -0,0 +1,101 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
/**
* Standard confirmation dialog across the app.
* Supports destructive actions with error styling, optional title icons, and optional extra body content.
*/
@Composable
fun ConfirmDialog(
title: String,
message: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
confirmText: String = stringResource(R.string.action_confirm),
dismissText: String = stringResource(R.string.action_cancel),
isDestructive: Boolean = false,
icon: ImageVector? = null,
extraContent: (@Composable () -> Unit)? = null,
) {
AlertDialog(
onDismissRequest = onDismiss,
modifier = modifier,
title = {
if (icon != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon,
contentDescription = null,
tint =
if (isDestructive) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
},
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(title)
}
} else {
Text(title)
}
},
text = {
if (extraContent != null) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(message)
extraContent()
}
} else {
Text(message)
}
},
confirmButton = {
if (isDestructive) {
Button(
onClick = onConfirm,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) {
Text(confirmText)
}
} else {
Button(onClick = onConfirm) {
Text(confirmText)
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(dismissText)
}
},
)
}
@@ -5,6 +5,7 @@ import androidx.compose.ui.res.stringResource
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.Toolset
@@ -68,6 +69,26 @@ fun PluginInfo.toDetailRows(): List<DetailRow> =
DetailRow(stringResource(R.string.detail_dialog_description), description),
)
@Composable
fun PluginCatalogEntry.toDetailRows(): List<DetailRow> =
listOfNotNull(
tier?.let { DetailRow(stringResource(R.string.detail_dialog_tier), it) },
maintainer?.let { DetailRow(stringResource(R.string.detail_dialog_maintainer), it) },
category?.let { DetailRow(stringResource(R.string.detail_dialog_category), it) },
repo?.let { DetailRow(stringResource(R.string.detail_dialog_repository), it) },
shaShort?.let { DetailRow(stringResource(R.string.detail_dialog_commit), it) },
requiresHermes?.takeIf { it.isNotBlank() }?.let {
DetailRow(stringResource(R.string.detail_dialog_requires_hermes), it)
},
docsUrl?.takeIf { it.isNotBlank() }?.let {
DetailRow(stringResource(R.string.detail_dialog_docs), it)
},
capabilitySummary?.takeIf { it.isNotBlank() }?.let {
DetailRow(stringResource(R.string.detail_dialog_capabilities), it)
},
description?.let { DetailRow(stringResource(R.string.detail_dialog_description), it) },
)
@Composable
fun Toolset.toDetailRows(): List<DetailRow> =
listOf(
@@ -34,6 +34,33 @@ inline fun <T> ViewModel.safeLaunchLoad(
}
}
/**
* Executes a mutating API call inside viewModelScope with onStart, onSuccess, onError, and onComplete lifecycle callbacks.
* Like safeLaunchLoad, avoids Dispatchers.IO hop to protect tests from dispatcher poisoning.
*/
inline fun <T> ViewModel.safeLaunchAction(
currentJob: Job? = null,
crossinline onStart: () -> Unit = {},
crossinline apiCall: suspend () -> NetworkResult<T>,
crossinline onSuccess: (T) -> Unit = {},
crossinline onError: (String) -> Unit = {},
crossinline onComplete: () -> Unit = {},
): Job {
if (currentJob?.isActive == true) return currentJob
onStart()
return viewModelScope.launch {
try {
val result = apiCall()
when (result) {
is NetworkResult.Success -> onSuccess(result.data)
is NetworkResult.Failure -> onError(result.error.message)
}
} finally {
onComplete()
}
}
}
/**
* Collect a gateway change event ([com.m57.hermescontrol.data.ws.ChangeEvents])
* from [ChangeEventHub] and run a **silent** refresh: no loading spinner, no
@@ -0,0 +1,56 @@
package com.m57.hermescontrol.ui.mcp
import com.m57.hermescontrol.data.model.AddMcpServerRequest
object AddServerRequestBuilder {
/**
* Constructs a validated AddMcpServerRequest from the add-server form parameters.
*/
fun build(
name: String,
mode: AddServerMode,
url: String,
command: String,
args: String,
auth: String,
bearerToken: String,
): AddMcpServerRequest {
val isHttp = mode == AddServerMode.HTTP
val isStdio = mode == AddServerMode.Stdio
val resolvedUrl = if (isHttp) url.trim().ifBlank { null } else null
val resolvedCommand = if (isStdio) command.trim().ifBlank { null } else null
val resolvedArgs =
if (isStdio && args.isNotBlank()) {
args.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() }
} else {
null
}
val resolvedAuth = if (isHttp && auth != "none") auth else null
val resolvedBearerToken =
if (isHttp && auth == "header") bearerToken.trim().ifBlank { null } else null
return AddMcpServerRequest(
name = name.trim(),
url = resolvedUrl,
command = resolvedCommand,
args = resolvedArgs,
auth = resolvedAuth,
bearerToken = resolvedBearerToken,
)
}
/**
* Overload reading form parameters directly from McpServersUiState.
*/
fun build(state: McpServersUiState): AddMcpServerRequest =
build(
name = state.addServerName,
mode = state.addMode,
url = state.addServerUrl,
command = state.addServerCommand,
args = state.addServerArgs,
auth = state.addServerAuth,
bearerToken = state.addServerBearerToken,
)
}
Loaded 100 of 206 files, more files were not shown because too many files have changed in this diff. Show more