From f14440b75b52419a5aea6777d7292f36578d0401 Mon Sep 17 00:00:00 2001
From: kitakkun <48154936+kitakkun@users.noreply.github.com>
Date: Tue, 28 Jul 2026 21:28:31 +0900
Subject: [PATCH 01/13] feat(host): show MCP call history in the tool dialog
Keeps a bounded, newest-first record of completed MCP tool calls in
McpActivity so the MCP dialog can show what an AI agent actually did with
a plugin, not just what it could do.
- McpCallRecord captures tool name, plugin, session, success and the
epoch-millis completion time; McpActivity.recentCalls caps at 100
entries and drops the oldest.
- toolInvocationFinished now takes whether the handler failed, and
McpToolRegistrar reports a throwing handler as a failed call.
- The MCP dialog gains Tools/History tabs. History lists the calls
attributed to that plugin in the selected session, with a success or
failure marker and the wall-clock time, and an empty state before any
call is made. Search and the parameter pane keep their state across
tab switches.
---
.../composeResources/values-ja/strings.xml | 5 +
.../main/composeResources/values/strings.xml | 5 +
.../host/drawer/DrawerPluginItemUiState.kt | 3 +
.../host/drawer/ExpandedToolingDrawerView.kt | 3 +
.../host/drawer/PluginDrawerItemView.kt | 332 +++++++++++++-----
.../host/drawer/ToolingScaffoldPresenter.kt | 12 +-
.../server/DefaultMcpActivityRepository.kt | 22 +-
.../DefaultMcpActivityRepositoryTest.kt | 88 +++++
.../jetwhale/host/mcp/McpToolRegistrar.kt | 7 +-
.../host/mcp/DefaultMcpServerServiceTest.kt | 57 +++
.../host/mcp/FakeMcpActivityRepository.kt | 20 +-
.../jetwhale/host/model/McpActivity.kt | 24 ++
.../host/model/McpActivityRepository.kt | 7 +-
13 files changed, 489 insertions(+), 96 deletions(-)
create mode 100644 jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt
diff --git a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
index 5c359c7d..aecd36ff 100644
--- a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
+++ b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
@@ -38,4 +38,9 @@
一致するツールがありません
パラメータ
必須
+ ツール
+ 履歴
+ MCP tools の呼び出し履歴はありません
+ 成功
+ 失敗
diff --git a/jetwhale-host/app/src/main/composeResources/values/strings.xml b/jetwhale-host/app/src/main/composeResources/values/strings.xml
index f9328213..8911b48a 100644
--- a/jetwhale-host/app/src/main/composeResources/values/strings.xml
+++ b/jetwhale-host/app/src/main/composeResources/values/strings.xml
@@ -38,4 +38,9 @@
No matching tools
Parameters
required
+ Tools
+ History
+ No MCP tool calls yet
+ Succeeded
+ Failed
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt
index 43ecaa9b..26eada79 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt
@@ -1,5 +1,6 @@
package com.kitakkun.jetwhale.host.drawer
+import com.kitakkun.jetwhale.host.model.McpCallRecord
import com.kitakkun.jetwhale.host.model.McpToolSummary
import com.kitakkun.jetwhale.host.model.PluginAvailability
import com.kitakkun.jetwhale.host.model.PluginIconResource
@@ -15,6 +16,8 @@ data class DrawerPluginItemUiState(
val underAiControl: Boolean,
/** The MCP tools this plugin exposes for the selected session; empty when it publishes none. */
val mcpTools: ImmutableList,
+ /** Completed MCP tool calls attributed to this plugin, newest first. */
+ val mcpCallHistory: ImmutableList,
) {
val exposesMcpTools: Boolean get() = mcpTools.isNotEmpty()
}
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt
index 9d4dcd58..804d5980 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt
@@ -170,6 +170,7 @@ fun ExpandedToolingDrawerView(
selected = it.id == selectedPluginId,
underAiControl = it.underAiControl,
mcpTools = it.mcpTools,
+ mcpCallHistory = it.mcpCallHistory,
onClick = { onClickPlugin(it) },
popupMenuContent = { dismiss ->
DropdownMenuItem(
@@ -242,6 +243,7 @@ fun ExpandedToolingDrawerView(
selected = false,
underAiControl = it.underAiControl,
mcpTools = it.mcpTools,
+ mcpCallHistory = it.mcpCallHistory,
onClick = {
// do nothing
},
@@ -288,6 +290,7 @@ fun ExpandedToolingDrawerView(
selected = false,
underAiControl = it.underAiControl,
mcpTools = it.mcpTools,
+ mcpCallHistory = it.mcpCallHistory,
onClick = {
// do nothing
},
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
index b1934c29..c9da3e9c 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
@@ -22,6 +22,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DropdownMenu
@@ -31,6 +33,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
+import androidx.compose.material3.Tab
+import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.VerticalDivider
@@ -50,12 +54,18 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.kitakkun.jetwhale.host.Res
import com.kitakkun.jetwhale.host.close
+import com.kitakkun.jetwhale.host.mcp_history_empty
+import com.kitakkun.jetwhale.host.mcp_history_failed
+import com.kitakkun.jetwhale.host.mcp_history_succeeded
import com.kitakkun.jetwhale.host.mcp_tool_executing
import com.kitakkun.jetwhale.host.mcp_tools_available
import com.kitakkun.jetwhale.host.mcp_tools_no_match
import com.kitakkun.jetwhale.host.mcp_tools_parameters
import com.kitakkun.jetwhale.host.mcp_tools_required
import com.kitakkun.jetwhale.host.mcp_tools_search
+import com.kitakkun.jetwhale.host.mcp_tools_tab_history
+import com.kitakkun.jetwhale.host.mcp_tools_tab_tools
+import com.kitakkun.jetwhale.host.model.McpCallRecord
import com.kitakkun.jetwhale.host.model.McpToolParameterSummary
import com.kitakkun.jetwhale.host.model.McpToolSummary
import com.kitakkun.jetwhale.host.model.PluginIconResource
@@ -64,6 +74,9 @@ import com.kitakkun.jetwhale.host.puzzle_outlined
import kotlinx.collections.immutable.ImmutableList
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
@Composable
fun PluginDrawerItemView(
@@ -72,6 +85,7 @@ fun PluginDrawerItemView(
selected: Boolean,
underAiControl: Boolean,
mcpTools: ImmutableList,
+ mcpCallHistory: ImmutableList,
activeIconResource: PluginIconResource?,
inactiveIconResource: PluginIconResource?,
onClick: () -> Unit,
@@ -96,7 +110,12 @@ fun PluginDrawerItemView(
modifier = Modifier.weight(1f, fill = false),
)
if (mcpTools.isNotEmpty()) {
- McpBadge(operating = underAiControl, pluginName = name, tools = mcpTools)
+ McpBadge(
+ operating = underAiControl,
+ pluginName = name,
+ tools = mcpTools,
+ callHistory = mcpCallHistory,
+ )
}
}
},
@@ -161,6 +180,7 @@ private fun McpBadge(
operating: Boolean,
pluginName: String,
tools: ImmutableList,
+ callHistory: ImmutableList,
) {
var showDialog by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(4.dp)
@@ -197,16 +217,24 @@ private fun McpBadge(
operating = operating,
pluginName = pluginName,
tools = tools,
+ callHistory = callHistory,
onDismiss = { showDialog = false },
)
}
}
+/** The panes the MCP dialog can show: the tools a plugin publishes, or the calls already made. */
+private enum class McpDialogTab {
+ Tools,
+ History,
+}
+
@Composable
private fun McpToolsDialog(
operating: Boolean,
pluginName: String,
tools: ImmutableList,
+ callHistory: ImmutableList,
onDismiss: () -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
@@ -233,98 +261,40 @@ private fun McpToolsDialog(
}
Spacer(Modifier.size(12.dp))
+ var selectedTab by remember { mutableStateOf(McpDialogTab.Tools) }
+ TabRow(selectedTabIndex = selectedTab.ordinal) {
+ Tab(
+ selected = selectedTab == McpDialogTab.Tools,
+ onClick = { selectedTab = McpDialogTab.Tools },
+ text = { Text(stringResource(Res.string.mcp_tools_tab_tools)) },
+ )
+ Tab(
+ selected = selectedTab == McpDialogTab.History,
+ onClick = { selectedTab = McpDialogTab.History },
+ text = { Text(stringResource(Res.string.mcp_tools_tab_history)) },
+ )
+ }
+ Spacer(Modifier.size(12.dp))
+
+ // Hoisted out of the pane so switching tabs and coming back keeps the search and the
+ // selected tool where the user left them.
var query by remember { mutableStateOf("") }
var selectedName by remember { mutableStateOf(tools.firstOrNull()?.name) }
- val filtered = remember(query, tools) {
- if (query.isBlank()) {
- tools
- } else {
- tools.filter {
- it.name.contains(query, ignoreCase = true) ||
- it.description.contains(query, ignoreCase = true)
- }
- }
- }
- val selected = filtered.firstOrNull { it.name == selectedName } ?: filtered.firstOrNull()
- Row(modifier = Modifier.weight(1f)) {
- // Left pane: search + tool list.
- Column(modifier = Modifier.width(260.dp)) {
- OutlinedTextField(
- value = query,
- onValueChange = { query = it },
- singleLine = true,
- leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
- placeholder = { Text(stringResource(Res.string.mcp_tools_search)) },
- modifier = Modifier.fillMaxWidth(),
- )
- Spacer(Modifier.size(8.dp))
- LazyColumn(modifier = Modifier.fillMaxHeight()) {
- items(filtered, key = { it.name }) { tool ->
- val isSelected = tool.name == selected?.name
- Text(
- text = tool.name.substringAfterLast('.'),
- style = MaterialTheme.typography.bodyMedium,
- fontFamily = FontFamily.Monospace,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- color = if (isSelected) {
- MaterialTheme.colorScheme.onSecondaryContainer
- } else {
- MaterialTheme.colorScheme.onSurface
- },
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(6.dp))
- .background(
- if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
- )
- .clickable { selectedName = tool.name }
- .padding(horizontal = 10.dp, vertical = 8.dp),
- )
- }
- }
- }
- VerticalDivider(modifier = Modifier.padding(horizontal = 12.dp))
- // Right pane: the selected tool's detail.
- Column(
- modifier = Modifier
- .weight(1f)
- .fillMaxHeight()
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- if (selected == null) {
- Text(
- text = stringResource(Res.string.mcp_tools_no_match),
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- } else {
- Text(
- text = selected.name.substringAfterLast('.'),
- style = MaterialTheme.typography.titleMedium,
- fontFamily = FontFamily.Monospace,
- )
- Text(
- text = selected.name,
- style = MaterialTheme.typography.labelSmall,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Text(
- text = selected.description,
- style = MaterialTheme.typography.bodyMedium,
- )
- if (selected.parameters.isNotEmpty()) {
- Spacer(Modifier.size(4.dp))
- Text(
- text = stringResource(Res.string.mcp_tools_parameters),
- style = MaterialTheme.typography.labelLarge,
- )
- selected.parameters.forEach { param -> McpParameterRow(param) }
- }
- }
- }
+ when (selectedTab) {
+ McpDialogTab.Tools -> McpToolsPane(
+ tools = tools,
+ query = query,
+ onQueryChange = { query = it },
+ selectedToolName = selectedName,
+ onSelectTool = { selectedName = it },
+ modifier = Modifier.weight(1f),
+ )
+
+ McpDialogTab.History -> McpCallHistoryPane(
+ callHistory = callHistory,
+ modifier = Modifier.weight(1f),
+ )
}
Row(
@@ -340,6 +310,188 @@ private fun McpToolsDialog(
}
}
+/** Two-pane browser over the tools a plugin publishes: search + list on the left, detail right. */
+@Composable
+private fun McpToolsPane(
+ tools: ImmutableList,
+ query: String,
+ onQueryChange: (String) -> Unit,
+ selectedToolName: String?,
+ onSelectTool: (String) -> Unit,
+ modifier: Modifier,
+) {
+ val filtered = remember(query, tools) {
+ if (query.isBlank()) {
+ tools
+ } else {
+ tools.filter {
+ it.name.contains(query, ignoreCase = true) ||
+ it.description.contains(query, ignoreCase = true)
+ }
+ }
+ }
+ val selected = filtered.firstOrNull { it.name == selectedToolName } ?: filtered.firstOrNull()
+
+ Row(modifier = modifier) {
+ // Left pane: search + tool list.
+ Column(modifier = Modifier.width(260.dp)) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ singleLine = true,
+ leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
+ placeholder = { Text(stringResource(Res.string.mcp_tools_search)) },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.size(8.dp))
+ LazyColumn(modifier = Modifier.fillMaxHeight()) {
+ items(filtered, key = { it.name }) { tool ->
+ val isSelected = tool.name == selected?.name
+ Text(
+ text = tool.name.substringAfterLast('.'),
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = if (isSelected) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(6.dp))
+ .background(
+ if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
+ )
+ .clickable { onSelectTool(tool.name) }
+ .padding(horizontal = 10.dp, vertical = 8.dp),
+ )
+ }
+ }
+ }
+ VerticalDivider(modifier = Modifier.padding(horizontal = 12.dp))
+ // Right pane: the selected tool's detail.
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (selected == null) {
+ Text(
+ text = stringResource(Res.string.mcp_tools_no_match),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ Text(
+ text = selected.name.substringAfterLast('.'),
+ style = MaterialTheme.typography.titleMedium,
+ fontFamily = FontFamily.Monospace,
+ )
+ Text(
+ text = selected.name,
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = selected.description,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ if (selected.parameters.isNotEmpty()) {
+ Spacer(Modifier.size(4.dp))
+ Text(
+ text = stringResource(Res.string.mcp_tools_parameters),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ selected.parameters.forEach { param -> McpParameterRow(param) }
+ }
+ }
+ }
+ }
+}
+
+/** What an agent already did with this plugin, newest call first. */
+@Composable
+private fun McpCallHistoryPane(
+ callHistory: ImmutableList,
+ modifier: Modifier,
+) {
+ if (callHistory.isEmpty()) {
+ Box(
+ modifier = modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = stringResource(Res.string.mcp_history_empty),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ return
+ }
+ LazyColumn(
+ modifier = modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ items(callHistory, key = { it.id }) { record -> McpCallHistoryRow(record) }
+ }
+}
+
+@Composable
+private fun McpCallHistoryRow(record: McpCallRecord) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(6.dp))
+ .padding(horizontal = 10.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ val succeededLabel = stringResource(
+ if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed,
+ )
+ Icon(
+ imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline,
+ contentDescription = succeededLabel,
+ tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(16.dp),
+ )
+ Text(
+ text = record.toolName.substringAfterLast('.'),
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = succeededLabel,
+ style = MaterialTheme.typography.labelSmall,
+ color = if (record.succeeded) {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ } else {
+ MaterialTheme.colorScheme.error
+ },
+ )
+ Text(
+ text = formatCallTime(record.finishedAtEpochMillis),
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+/** Wall-clock time of day, which is what the user can line up against their own actions. */
+private val CallHistoryTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
+
+private fun formatCallTime(epochMillis: Long): String = Instant.ofEpochMilli(epochMillis)
+ .atZone(ZoneId.systemDefault())
+ .format(CallHistoryTimeFormatter)
+
@Composable
private fun McpParameterRow(param: McpToolParameterSummary) {
Column(
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt
index 3c76187e..e1b0430e 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt
@@ -72,7 +72,8 @@ fun toolingScaffoldPresenter(
val setPluginEnabledMutation = rememberMutation(presenterContext.setPluginEnabledMutationKey)
- val plugins by remember(loadedPlugins, selectedSession, enabledPluginIds, mcpCapablePlugins, activeInvocation) {
+ val recentCalls = mcpActivity.recentCalls
+ val plugins by remember(loadedPlugins, selectedSession, enabledPluginIds, mcpCapablePlugins, activeInvocation, recentCalls) {
derivedStateOf {
// Attribute the operation only when it targets the session the drawer is showing;
// highlighting a plugin for some other device would be misleading.
@@ -80,6 +81,12 @@ fun toolingScaffoldPresenter(
?.takeIf { it.sessionId != null && it.sessionId == selectedSession?.id }
?.pluginId
+ // Calls that named no session came from a tool that does not target one, so they belong
+ // to whichever session is on screen; the rest are shown only under their own session.
+ val callsForSelectedSession = recentCalls.filter {
+ it.sessionId == null || it.sessionId == selectedSession?.id
+ }
+
loadedPlugins.map { metaData ->
val isInstalledOnAgent = selectedSession?.installedPlugins?.any { installed -> installed.pluginId == metaData.id } == true
val isEnabledInSettings = enabledPluginIds.contains(metaData.id)
@@ -102,6 +109,9 @@ fun toolingScaffoldPresenter(
},
underAiControl = aiControlledPluginId == metaData.id,
mcpTools = mcpCapablePlugins.toolsFor(selectedSession?.id, metaData.id).toImmutableList(),
+ mcpCallHistory = callsForSelectedSession
+ .filter { it.pluginId == metaData.id }
+ .toImmutableList(),
)
}.toImmutableList()
}
diff --git a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt
index b9b1e08b..30268cec 100644
--- a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt
+++ b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt
@@ -2,6 +2,7 @@ package com.kitakkun.jetwhale.host.data.server
import com.kitakkun.jetwhale.host.model.McpActivity
import com.kitakkun.jetwhale.host.model.McpActivityRepository
+import com.kitakkun.jetwhale.host.model.McpCallRecord
import com.kitakkun.jetwhale.host.model.McpToolInvocation
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
@@ -51,12 +52,31 @@ class DefaultMcpActivityRepository : McpActivityRepository {
return invocationId
}
- override fun toolInvocationFinished(invocationId: Long) {
+ override fun toolInvocationFinished(invocationId: Long, failed: Boolean) {
+ // Sampled once outside the update block, which may re-run under contention.
+ val finishedAtEpochMillis = System.currentTimeMillis()
activityFlow.update { activity ->
+ val finished = activity.runningInvocations.firstOrNull { it.id == invocationId }
activity.copy(
runningInvocations = activity.runningInvocations
.filterNot { it.id == invocationId }
.toImmutableList(),
+ recentCalls = if (finished == null) {
+ // The invocation is unknown here, so there is nothing to describe in history.
+ activity.recentCalls
+ } else {
+ val record = McpCallRecord(
+ id = finished.id,
+ toolName = finished.toolName,
+ pluginId = finished.pluginId,
+ sessionId = finished.sessionId,
+ succeeded = !failed,
+ finishedAtEpochMillis = finishedAtEpochMillis,
+ )
+ (listOf(record) + activity.recentCalls)
+ .take(McpActivity.MAX_RECENT_CALLS)
+ .toImmutableList()
+ },
)
}
}
diff --git a/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt b/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt
new file mode 100644
index 00000000..ec6cc5bb
--- /dev/null
+++ b/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt
@@ -0,0 +1,88 @@
+package com.kitakkun.jetwhale.host.data.server
+
+import com.kitakkun.jetwhale.host.model.McpActivity
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class DefaultMcpActivityRepositoryTest {
+
+ private val repository = DefaultMcpActivityRepository()
+
+ @Test
+ fun `no calls are recorded before anything runs`() {
+ assertTrue(repository.activityFlow.value.recentCalls.isEmpty())
+ }
+
+ @Test
+ fun `a completed call is recorded with its attribution`() {
+ val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1")
+ repository.toolInvocationFinished(id, failed = false)
+
+ val record = repository.activityFlow.value.recentCalls.single()
+ assertEquals("plugin.click", record.toolName)
+ assertEquals("com.example.plugin", record.pluginId)
+ assertEquals("session-1", record.sessionId)
+ assertTrue(record.succeeded)
+ }
+
+ @Test
+ fun `a call is only recorded once it finishes`() {
+ repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1")
+
+ assertTrue(repository.activityFlow.value.recentCalls.isEmpty())
+ }
+
+ @Test
+ fun `a failed call is recorded as unsuccessful`() {
+ val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1")
+ repository.toolInvocationFinished(id, failed = true)
+
+ assertFalse(repository.activityFlow.value.recentCalls.single().succeeded)
+ }
+
+ @Test
+ fun `history is ordered newest first`() {
+ listOf("first", "second", "third").forEach { toolName ->
+ val id = repository.toolInvocationStarted(toolName, "com.example.plugin", "session-1")
+ repository.toolInvocationFinished(id, failed = false)
+ }
+
+ assertEquals(
+ listOf("third", "second", "first"),
+ repository.activityFlow.value.recentCalls.map { it.toolName },
+ )
+ }
+
+ @Test
+ fun `history drops the oldest calls once the cap is reached`() {
+ val overflow = McpActivity.MAX_RECENT_CALLS + 5
+ repeat(overflow) { index ->
+ val id = repository.toolInvocationStarted("tool-$index", "com.example.plugin", "session-1")
+ repository.toolInvocationFinished(id, failed = false)
+ }
+
+ val recentCalls = repository.activityFlow.value.recentCalls
+ assertEquals(McpActivity.MAX_RECENT_CALLS, recentCalls.size)
+ assertEquals("tool-${overflow - 1}", recentCalls.first().toolName)
+ assertEquals("tool-${overflow - McpActivity.MAX_RECENT_CALLS}", recentCalls.last().toolName)
+ }
+
+ @Test
+ fun `finishing an unknown invocation records nothing`() {
+ repository.toolInvocationFinished(invocationId = 404L, failed = false)
+
+ assertTrue(repository.activityFlow.value.recentCalls.isEmpty())
+ }
+
+ @Test
+ fun `clear drops the recorded history`() {
+ val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1")
+ repository.toolInvocationFinished(id, failed = false)
+
+ repository.clear()
+
+ assertTrue(repository.activityFlow.value.recentCalls.isEmpty())
+ }
+}
diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt
index 9ceb2169..23e27337 100644
--- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt
+++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt
@@ -79,10 +79,13 @@ class McpToolRegistrar(
pluginId = resolvePluginId(request),
sessionId = request.arguments?.get("sessionId")?.jsonContent,
)
+ // A thrown handler is the only failure signal available here, and it has to reach the
+ // repository before it propagates on to the MCP layer.
+ var failed = true
try {
- handler(request)
+ handler(request).also { failed = false }
} finally {
- activityRepository.toolInvocationFinished(invocationId)
+ activityRepository.toolInvocationFinished(invocationId, failed)
}
}
}
diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt
index 39a4f15f..6028d16f 100644
--- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt
+++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt
@@ -411,6 +411,63 @@ class DefaultMcpServerServiceTest {
assertTrue(mcpActivityRepository.activityFlow.value.runningInvocations.isEmpty())
}
+ @Test
+ fun `a completed tool call is added to the recent-call history`() = runBlocking {
+ val serviceWithTool = DefaultMcpServerService(
+ pluginInstanceService = pluginInstanceService,
+ mcpActivityRepository = mcpActivityRepository,
+ builtInTools = setOf(FakeMcpTool("fake.recorded")),
+ )
+ val recordedPort = java.net.ServerSocket(0).use { it.localPort }
+ serviceWithTool.start(host, recordedPort)
+ // Stopping the server clears recorded activity, so the history has to be read while it runs.
+ val record = try {
+ val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$recordedPort/sse")
+ try {
+ client.callTool(
+ "fake.recorded",
+ mapOf("pluginId" to "com.example.plugin", "sessionId" to "session-1"),
+ )
+ } finally {
+ client.close()
+ }
+ mcpActivityRepository.activityFlow.value.recentCalls.single()
+ } finally {
+ serviceWithTool.stop()
+ }
+
+ assertEquals("fake.recorded", record.toolName)
+ assertEquals("com.example.plugin", record.pluginId)
+ assertEquals("session-1", record.sessionId)
+ assertTrue(record.succeeded)
+ }
+
+ @Test
+ fun `a throwing tool call is recorded in history as a failure`() = runBlocking {
+ val serviceWithTool = DefaultMcpServerService(
+ pluginInstanceService = pluginInstanceService,
+ mcpActivityRepository = mcpActivityRepository,
+ builtInTools = setOf(FailingMcpTool("fake.failing")),
+ )
+ val failingPort = java.net.ServerSocket(0).use { it.localPort }
+ serviceWithTool.start(host, failingPort)
+ // Stopping the server clears recorded activity, so the history has to be read while it runs.
+ val record = try {
+ val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$failingPort/sse")
+ try {
+ runCatching { client.callTool("fake.failing", emptyMap()) }
+ } finally {
+ client.close()
+ }
+ mcpActivityRepository.activityFlow.value.recentCalls.single()
+ } finally {
+ serviceWithTool.stop()
+ }
+
+ assertEquals("fake.failing", record.toolName)
+ assertFalse(record.succeeded)
+ }
+
@OptIn(ExperimentalJetWhaleApi::class)
@Test
fun `a plugin tool call is attributed to the plugin that owns it`() = runBlocking {
diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt
index d152da36..4567080b 100644
--- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt
+++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt
@@ -2,6 +2,7 @@ package com.kitakkun.jetwhale.host.mcp
import com.kitakkun.jetwhale.host.model.McpActivity
import com.kitakkun.jetwhale.host.model.McpActivityRepository
+import com.kitakkun.jetwhale.host.model.McpCallRecord
import com.kitakkun.jetwhale.host.model.McpToolInvocation
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
@@ -51,12 +52,29 @@ class FakeMcpActivityRepository : McpActivityRepository {
return invocation.id
}
- override fun toolInvocationFinished(invocationId: Long) {
+ override fun toolInvocationFinished(invocationId: Long, failed: Boolean) {
+ val finishedAtEpochMillis = System.currentTimeMillis()
activityFlow.update { activity ->
+ val finished = activity.runningInvocations.firstOrNull { it.id == invocationId }
activity.copy(
runningInvocations = activity.runningInvocations
.filterNot { it.id == invocationId }
.toImmutableList(),
+ recentCalls = if (finished == null) {
+ activity.recentCalls
+ } else {
+ val record = McpCallRecord(
+ id = finished.id,
+ toolName = finished.toolName,
+ pluginId = finished.pluginId,
+ sessionId = finished.sessionId,
+ succeeded = !failed,
+ finishedAtEpochMillis = finishedAtEpochMillis,
+ )
+ (listOf(record) + activity.recentCalls)
+ .take(McpActivity.MAX_RECENT_CALLS)
+ .toImmutableList()
+ },
)
}
}
diff --git a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt
index dc9f575c..e4087db0 100644
--- a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt
+++ b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt
@@ -17,6 +17,20 @@ data class McpToolInvocation(
val sessionId: String?,
)
+/**
+ * A finished MCP tool call, kept so the UI can show what an agent already did.
+ *
+ * [finishedAtEpochMillis] is wall-clock time in epoch milliseconds, taken when the call completed.
+ */
+data class McpCallRecord(
+ val id: Long,
+ val toolName: String,
+ val pluginId: String?,
+ val sessionId: String?,
+ val succeeded: Boolean,
+ val finishedAtEpochMillis: Long,
+)
+
/**
* Live view of what AI agents are doing through the MCP server, so the UI can tell the user when
* something other than themselves is driving the debugger.
@@ -33,15 +47,25 @@ data class McpActivity(
val startedCount: Long,
/** The most recently started tool call, kept so the UI can name and attribute it. */
val lastStartedInvocation: McpToolInvocation?,
+ /**
+ * Tool calls that have already completed, newest first, capped at [MAX_RECENT_CALLS]. This is a
+ * live troubleshooting aid rather than an audit log, so the oldest entries are dropped once the
+ * cap is reached.
+ */
+ val recentCalls: ImmutableList,
) {
val hasConnectedClient: Boolean get() = connectedClientCount > 0
companion object {
+ /** How many completed calls [recentCalls] keeps before dropping the oldest. */
+ const val MAX_RECENT_CALLS = 100
+
val Idle = McpActivity(
connectedClientCount = 0,
runningInvocations = persistentListOf(),
startedCount = 0,
lastStartedInvocation = null,
+ recentCalls = persistentListOf(),
)
}
}
diff --git a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt
index bc257c8d..a2b02ce1 100644
--- a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt
+++ b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt
@@ -23,7 +23,12 @@ interface McpActivityRepository {
*/
fun toolInvocationStarted(toolName: String, pluginId: String?, sessionId: String?): Long
- fun toolInvocationFinished(invocationId: Long)
+ /**
+ * Records the completion of a tool call and appends it to the recent-call history.
+ *
+ * @param failed true when the handler threw instead of returning a result.
+ */
+ fun toolInvocationFinished(invocationId: Long, failed: Boolean)
/** Drops all recorded activity, so a server restart does not inherit stale connection counts. */
fun clear()
From ff7c43eaafcf68fc96c3109750b22dc4268221fc Mon Sep 17 00:00:00 2001
From: kitakkun <48154936+kitakkun@users.noreply.github.com>
Date: Tue, 28 Jul 2026 21:48:02 +0900
Subject: [PATCH 02/13] feat(host): size the MCP dialog to the window and mark
tool activity
Grow the dialog with the window up to a readable maximum instead of a fixed
760x500, so tool descriptions and parameter lists fit without scrolling.
In the tool list, show how many recorded calls each tool has and a pulsing
accent dot on the tool an agent is running right now. Counts come from the
retained history, so they describe the same calls the History tab lists.
---
.../host/drawer/DrawerPluginItemUiState.kt | 2 +
.../host/drawer/ExpandedToolingDrawerView.kt | 3 +
.../host/drawer/PluginDrawerItemView.kt | 86 +++++++++++++++----
.../host/drawer/ToolingScaffoldPresenter.kt | 3 +
4 files changed, 78 insertions(+), 16 deletions(-)
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt
index 26eada79..d4eae91e 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt
@@ -18,6 +18,8 @@ data class DrawerPluginItemUiState(
val mcpTools: ImmutableList,
/** Completed MCP tool calls attributed to this plugin, newest first. */
val mcpCallHistory: ImmutableList,
+ /** The tool an agent is running on this plugin right now, or null when none is in flight. */
+ val runningMcpToolName: String?,
) {
val exposesMcpTools: Boolean get() = mcpTools.isNotEmpty()
}
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt
index 804d5980..53a63c8a 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt
@@ -171,6 +171,7 @@ fun ExpandedToolingDrawerView(
underAiControl = it.underAiControl,
mcpTools = it.mcpTools,
mcpCallHistory = it.mcpCallHistory,
+ runningMcpToolName = it.runningMcpToolName,
onClick = { onClickPlugin(it) },
popupMenuContent = { dismiss ->
DropdownMenuItem(
@@ -244,6 +245,7 @@ fun ExpandedToolingDrawerView(
underAiControl = it.underAiControl,
mcpTools = it.mcpTools,
mcpCallHistory = it.mcpCallHistory,
+ runningMcpToolName = it.runningMcpToolName,
onClick = {
// do nothing
},
@@ -291,6 +293,7 @@ fun ExpandedToolingDrawerView(
underAiControl = it.underAiControl,
mcpTools = it.mcpTools,
mcpCallHistory = it.mcpCallHistory,
+ runningMcpToolName = it.runningMcpToolName,
onClick = {
// do nothing
},
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
index c9da3e9c..8e2a24f2 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
@@ -10,14 +10,17 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
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.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -52,6 +55,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
import com.kitakkun.jetwhale.host.Res
import com.kitakkun.jetwhale.host.close
import com.kitakkun.jetwhale.host.mcp_history_empty
@@ -86,6 +90,7 @@ fun PluginDrawerItemView(
underAiControl: Boolean,
mcpTools: ImmutableList,
mcpCallHistory: ImmutableList,
+ runningMcpToolName: String?,
activeIconResource: PluginIconResource?,
inactiveIconResource: PluginIconResource?,
onClick: () -> Unit,
@@ -115,6 +120,7 @@ fun PluginDrawerItemView(
pluginName = name,
tools = mcpTools,
callHistory = mcpCallHistory,
+ runningToolName = runningMcpToolName,
)
}
}
@@ -181,6 +187,7 @@ private fun McpBadge(
pluginName: String,
tools: ImmutableList,
callHistory: ImmutableList,
+ runningToolName: String?,
) {
var showDialog by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(4.dp)
@@ -218,6 +225,7 @@ private fun McpBadge(
pluginName = pluginName,
tools = tools,
callHistory = callHistory,
+ runningToolName = runningToolName,
onDismiss = { showDialog = false },
)
}
@@ -235,14 +243,27 @@ private fun McpToolsDialog(
pluginName: String,
tools: ImmutableList,
callHistory: ImmutableList,
+ runningToolName: String?,
onDismiss: () -> Unit,
) {
- Dialog(onDismissRequest = onDismiss) {
+ Dialog(
+ onDismissRequest = onDismiss,
+ // The platform default constrains the dialog to its content's size; tool descriptions and
+ // history need the room to grow with the window instead.
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ ) {
Surface(shape = MaterialTheme.shapes.large) {
Column(
modifier = Modifier
- .width(760.dp)
- .height(500.dp)
+ // Take most of the window so long descriptions and history are readable, but
+ // stop growing past a comfortable reading width on a large display.
+ .fillMaxSize(MCP_TOOLS_DIALOG_WINDOW_FRACTION)
+ .sizeIn(
+ minWidth = 640.dp,
+ minHeight = 440.dp,
+ maxWidth = 1200.dp,
+ maxHeight = 860.dp,
+ )
.padding(20.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -288,6 +309,10 @@ private fun McpToolsDialog(
onQueryChange = { query = it },
selectedToolName = selectedName,
onSelectTool = { selectedName = it },
+ runningToolName = runningToolName,
+ // Counts come from the retained history, so they cover the calls the
+ // dialog can actually show rather than all time.
+ callCounts = remember(callHistory) { callHistory.groupingBy { it.toolName }.eachCount() },
modifier = Modifier.weight(1f),
)
@@ -318,6 +343,8 @@ private fun McpToolsPane(
onQueryChange: (String) -> Unit,
selectedToolName: String?,
onSelectTool: (String) -> Unit,
+ runningToolName: String?,
+ callCounts: Map,
modifier: Modifier,
) {
val filtered = remember(query, tools) {
@@ -334,7 +361,7 @@ private fun McpToolsPane(
Row(modifier = modifier) {
// Left pane: search + tool list.
- Column(modifier = Modifier.width(260.dp)) {
+ Column(modifier = Modifier.width(300.dp)) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
@@ -347,17 +374,11 @@ private fun McpToolsPane(
LazyColumn(modifier = Modifier.fillMaxHeight()) {
items(filtered, key = { it.name }) { tool ->
val isSelected = tool.name == selected?.name
- Text(
- text = tool.name.substringAfterLast('.'),
- style = MaterialTheme.typography.bodyMedium,
- fontFamily = FontFamily.Monospace,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- color = if (isSelected) {
- MaterialTheme.colorScheme.onSecondaryContainer
- } else {
- MaterialTheme.colorScheme.onSurface
- },
+ val isRunning = tool.name == runningToolName
+ val callCount = callCounts[tool.name] ?: 0
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
@@ -366,7 +387,38 @@ private fun McpToolsPane(
)
.clickable { onSelectTool(tool.name) }
.padding(horizontal = 10.dp, vertical = 8.dp),
- )
+ ) {
+ Text(
+ text = tool.name.substringAfterLast('.'),
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = if (isSelected) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ modifier = Modifier.weight(1f, fill = false),
+ )
+ if (callCount > 0) {
+ Text(
+ text = callCount.toString(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (isRunning) {
+ // Same pulsing accent dot the drawer uses, so a tool being called right
+ // now is recognisable here too.
+ Box(
+ modifier = Modifier
+ .size(8.dp)
+ .alpha(aiActivityPulseAlpha(operating = true))
+ .background(AiOperatingAccentColor, CircleShape),
+ )
+ }
+ }
}
}
}
@@ -486,6 +538,8 @@ private fun McpCallHistoryRow(record: McpCallRecord) {
}
/** Wall-clock time of day, which is what the user can line up against their own actions. */
+private const val MCP_TOOLS_DIALOG_WINDOW_FRACTION = 0.8f
+
private val CallHistoryTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
private fun formatCallTime(epochMillis: Long): String = Instant.ofEpochMilli(epochMillis)
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt
index e1b0430e..79dfc892 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt
@@ -112,6 +112,9 @@ fun toolingScaffoldPresenter(
mcpCallHistory = callsForSelectedSession
.filter { it.pluginId == metaData.id }
.toImmutableList(),
+ runningMcpToolName = activeInvocation
+ ?.takeIf { it.pluginId == metaData.id }
+ ?.toolName,
)
}.toImmutableList()
}
From 7d530ecfc24905d3fab9285a86e60b18a2d027a4 Mon Sep 17 00:00:00 2001
From: kitakkun <48154936+kitakkun@users.noreply.github.com>
Date: Tue, 28 Jul 2026 21:56:37 +0900
Subject: [PATCH 03/13] fix(host): give call counts a badge and show the
history total
Right-align the per-tool call count as a badge instead of a bare number, so
it reads clearly against the tool name, and take the accent fill and
rotating ring while that tool is running. Repeat the badge next to the
History tab so the number of recorded calls is visible without switching.
---
.../host/drawer/PluginDrawerItemView.kt | 64 +++++++++++++------
1 file changed, 46 insertions(+), 18 deletions(-)
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
index 8e2a24f2..9a071749 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
@@ -292,7 +292,19 @@ private fun McpToolsDialog(
Tab(
selected = selectedTab == McpDialogTab.History,
onClick = { selectedTab = McpDialogTab.History },
- text = { Text(stringResource(Res.string.mcp_tools_tab_history)) },
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text(stringResource(Res.string.mcp_tools_tab_history))
+ if (callHistory.isNotEmpty()) {
+ // How many calls the history holds, so the count is visible
+ // without opening the tab.
+ McpToolCallCountBadge(count = callHistory.size, running = false)
+ }
+ }
+ },
)
}
Spacer(Modifier.size(12.dp))
@@ -335,6 +347,35 @@ private fun McpToolsDialog(
}
}
+/**
+ * Trailing badge on a tool row: the number of recorded calls, shown quietly. While an agent is
+ * running the tool it takes the accent fill and the same rotating ring the drawer item uses, so
+ * "being called right now" reads the same way everywhere.
+ */
+@Composable
+private fun McpToolCallCountBadge(count: Int, running: Boolean) {
+ val shape = RoundedCornerShape(6.dp)
+ Box(
+ modifier = Modifier
+ .clip(shape)
+ .background(
+ if (running) AiOperatingAccentColor else MaterialTheme.colorScheme.surfaceContainerHighest,
+ shape,
+ )
+ .then(
+ if (running) Modifier.aiOperatingBorder(color = AiOperatingAccentColor, width = 2.dp) else Modifier,
+ )
+ .padding(horizontal = 7.dp, vertical = 2.dp),
+ ) {
+ Text(
+ text = count.toString(),
+ style = MaterialTheme.typography.labelMedium,
+ fontFamily = FontFamily.Monospace,
+ color = if (running) Color.Black else MaterialTheme.colorScheme.onSurface,
+ )
+ }
+}
+
/** Two-pane browser over the tools a plugin publishes: search + list on the left, detail right. */
@Composable
private fun McpToolsPane(
@@ -399,24 +440,11 @@ private fun McpToolsPane(
} else {
MaterialTheme.colorScheme.onSurface
},
- modifier = Modifier.weight(1f, fill = false),
+ // Takes the free space so the count and dot sit against the right edge.
+ modifier = Modifier.weight(1f),
)
- if (callCount > 0) {
- Text(
- text = callCount.toString(),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- if (isRunning) {
- // Same pulsing accent dot the drawer uses, so a tool being called right
- // now is recognisable here too.
- Box(
- modifier = Modifier
- .size(8.dp)
- .alpha(aiActivityPulseAlpha(operating = true))
- .background(AiOperatingAccentColor, CircleShape),
- )
+ if (callCount > 0 || isRunning) {
+ McpToolCallCountBadge(count = callCount, running = isRunning)
}
}
}
From d0aaca0b5ee9a1ee93c700161d7646de940d0fb7 Mon Sep 17 00:00:00 2001
From: kitakkun <48154936+kitakkun@users.noreply.github.com>
Date: Tue, 28 Jul 2026 21:59:38 +0900
Subject: [PATCH 04/13] feat(host): record and reveal MCP call arguments
Every MCP tool call now carries the arguments it was made with into the
call history, so the History tab can explain what an agent actually did
rather than only which tool it reached for.
Arguments are rendered to strings and shortened to
`McpCallArgument.MAX_VALUE_LENGTH` characters at record time, so a call
carrying a large body does not keep that body alive for as long as it
stays in history. History rows expand on click to list the recorded
`name = value` pairs, and a right-click context menu copies the full tool
name, the arguments, or the whole call.
---
.../composeResources/values-ja/strings.xml | 4 +
.../main/composeResources/values/strings.xml | 4 +
.../host/drawer/PluginDrawerItemView.kt | 219 ++++++++++++++----
.../server/DefaultMcpActivityRepository.kt | 12 +-
.../DefaultMcpActivityRepositoryTest.kt | 73 +++++-
.../jetwhale/host/mcp/McpToolRegistrar.kt | 7 +
.../host/mcp/FakeMcpActivityRepository.kt | 12 +-
.../jetwhale/host/model/McpActivity.kt | 34 +++
.../host/model/McpActivityRepository.kt | 10 +-
9 files changed, 325 insertions(+), 50 deletions(-)
diff --git a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
index aecd36ff..2e716662 100644
--- a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
+++ b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
@@ -43,4 +43,8 @@
MCP tools の呼び出し履歴はありません
成功
失敗
+ 引数の表示を切り替え
+ ツール名をコピー
+ 引数をコピー
+ 詳細をコピー
diff --git a/jetwhale-host/app/src/main/composeResources/values/strings.xml b/jetwhale-host/app/src/main/composeResources/values/strings.xml
index 8911b48a..e9360c49 100644
--- a/jetwhale-host/app/src/main/composeResources/values/strings.xml
+++ b/jetwhale-host/app/src/main/composeResources/values/strings.xml
@@ -43,4 +43,8 @@
No MCP tool calls yet
Succeeded
Failed
+ Show or hide arguments
+ Copy tool name
+ Copy arguments
+ Copy details
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
index 9a071749..0f82af6c 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
@@ -1,5 +1,8 @@
package com.kitakkun.jetwhale.host.drawer
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.ContextMenuArea
+import androidx.compose.foundation.ContextMenuItem
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -27,6 +30,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ErrorOutline
+import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DropdownMenu
@@ -50,7 +54,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -58,9 +66,13 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.kitakkun.jetwhale.host.Res
import com.kitakkun.jetwhale.host.close
+import com.kitakkun.jetwhale.host.mcp_history_copy_arguments
+import com.kitakkun.jetwhale.host.mcp_history_copy_details
+import com.kitakkun.jetwhale.host.mcp_history_copy_tool_name
import com.kitakkun.jetwhale.host.mcp_history_empty
import com.kitakkun.jetwhale.host.mcp_history_failed
import com.kitakkun.jetwhale.host.mcp_history_succeeded
+import com.kitakkun.jetwhale.host.mcp_history_toggle_arguments
import com.kitakkun.jetwhale.host.mcp_tool_executing
import com.kitakkun.jetwhale.host.mcp_tools_available
import com.kitakkun.jetwhale.host.mcp_tools_no_match
@@ -512,56 +524,181 @@ private fun McpCallHistoryPane(
}
return
}
+ // Expansion lives above the lazy list so a row keeps its state while scrolled out of view.
+ var expandedCallIds by remember { mutableStateOf(emptySet()) }
LazyColumn(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
- items(callHistory, key = { it.id }) { record -> McpCallHistoryRow(record) }
+ items(callHistory, key = { it.id }) { record ->
+ McpCallHistoryRow(
+ record = record,
+ expanded = record.id in expandedCallIds,
+ onToggleExpanded = {
+ expandedCallIds = if (record.id in expandedCallIds) {
+ expandedCallIds - record.id
+ } else {
+ expandedCallIds + record.id
+ }
+ },
+ )
+ }
}
}
@Composable
-private fun McpCallHistoryRow(record: McpCallRecord) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(6.dp))
- .padding(horizontal = 10.dp, vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(10.dp),
+private fun McpCallHistoryRow(
+ record: McpCallRecord,
+ expanded: Boolean,
+ onToggleExpanded: () -> Unit,
+) {
+ val succeededLabel = stringResource(
+ if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed,
+ )
+ val toggleArgumentsLabel = stringResource(Res.string.mcp_history_toggle_arguments)
+ val finishedAt = formatCallTime(record.finishedAtEpochMillis)
+ // Only calls that carry arguments have anything to reveal, so the rest stay plain rows.
+ val hasArguments = record.arguments.isNotEmpty()
+ val renderedArguments = record.arguments.joinToString(separator = "\n") { "${it.name} = ${it.value}" }
+ val chevronRotation by animateFloatAsState(if (expanded) 90f else 0f)
+
+ val clipboardManager = LocalClipboardManager.current
+ val copyToolNameLabel = stringResource(Res.string.mcp_history_copy_tool_name)
+ val copyArgumentsLabel = stringResource(Res.string.mcp_history_copy_arguments)
+ val copyDetailsLabel = stringResource(Res.string.mcp_history_copy_details)
+
+ ContextMenuArea(
+ items = {
+ buildList {
+ add(
+ ContextMenuItem(copyToolNameLabel) {
+ clipboardManager.setText(AnnotatedString(record.toolName))
+ },
+ )
+ if (hasArguments) {
+ add(
+ ContextMenuItem(copyArgumentsLabel) {
+ clipboardManager.setText(AnnotatedString(renderedArguments))
+ },
+ )
+ }
+ add(
+ ContextMenuItem(copyDetailsLabel) {
+ clipboardManager.setText(
+ AnnotatedString(
+ buildCallDetails(
+ toolName = record.toolName,
+ statusLabel = succeededLabel,
+ finishedAt = finishedAt,
+ renderedArguments = renderedArguments,
+ ),
+ ),
+ )
+ },
+ )
+ }
+ },
) {
- val succeededLabel = stringResource(
- if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed,
- )
- Icon(
- imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline,
- contentDescription = succeededLabel,
- tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error,
- modifier = Modifier.size(16.dp),
- )
- Text(
- text = record.toolName.substringAfterLast('.'),
- style = MaterialTheme.typography.bodyMedium,
- fontFamily = FontFamily.Monospace,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(1f),
- )
- Text(
- text = succeededLabel,
- style = MaterialTheme.typography.labelSmall,
- color = if (record.succeeded) {
- MaterialTheme.colorScheme.onSurfaceVariant
- } else {
- MaterialTheme.colorScheme.error
- },
- )
- Text(
- text = formatCallTime(record.finishedAtEpochMillis),
- style = MaterialTheme.typography.labelSmall,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(6.dp)),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .then(
+ if (hasArguments) {
+ Modifier.clickable(
+ onClickLabel = toggleArgumentsLabel,
+ role = Role.Button,
+ onClick = onToggleExpanded,
+ )
+ } else {
+ Modifier
+ },
+ )
+ .padding(horizontal = 10.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ if (hasArguments) {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowRight,
+ contentDescription = toggleArgumentsLabel,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .size(16.dp)
+ .rotate(chevronRotation),
+ )
+ } else {
+ // Keeps rows without arguments aligned with the ones that show a chevron.
+ Spacer(Modifier.size(16.dp))
+ }
+ Icon(
+ imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline,
+ contentDescription = succeededLabel,
+ tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(16.dp),
+ )
+ Text(
+ text = record.toolName.substringAfterLast('.'),
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = succeededLabel,
+ style = MaterialTheme.typography.labelSmall,
+ color = if (record.succeeded) {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ } else {
+ MaterialTheme.colorScheme.error
+ },
+ )
+ Text(
+ text = finishedAt,
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (hasArguments && expanded) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 36.dp, end = 10.dp, bottom = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ record.arguments.forEach { argument ->
+ Text(
+ text = "${argument.name} = ${argument.value}",
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+/** Everything known about one call, in the order the row shows it. */
+private fun buildCallDetails(
+ toolName: String,
+ statusLabel: String,
+ finishedAt: String,
+ renderedArguments: String,
+): String = buildString {
+ appendLine(toolName)
+ appendLine(statusLabel)
+ append(finishedAt)
+ if (renderedArguments.isNotEmpty()) {
+ appendLine()
+ append(renderedArguments)
}
}
diff --git a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt
index 30268cec..1da37880 100644
--- a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt
+++ b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt
@@ -2,6 +2,7 @@ package com.kitakkun.jetwhale.host.data.server
import com.kitakkun.jetwhale.host.model.McpActivity
import com.kitakkun.jetwhale.host.model.McpActivityRepository
+import com.kitakkun.jetwhale.host.model.McpCallArgument
import com.kitakkun.jetwhale.host.model.McpCallRecord
import com.kitakkun.jetwhale.host.model.McpToolInvocation
import dev.zacsweers.metro.AppScope
@@ -34,13 +35,21 @@ class DefaultMcpActivityRepository : McpActivityRepository {
}
}
- override fun toolInvocationStarted(toolName: String, pluginId: String?, sessionId: String?): Long {
+ override fun toolInvocationStarted(
+ toolName: String,
+ pluginId: String?,
+ sessionId: String?,
+ arguments: Map,
+ ): Long {
val invocationId = nextInvocationId.incrementAndGet()
val invocation = McpToolInvocation(
id = invocationId,
toolName = toolName,
pluginId = pluginId,
sessionId = sessionId,
+ arguments = arguments
+ .map { (name, value) -> McpCallArgument.truncating(name, value) }
+ .toImmutableList(),
)
activityFlow.update {
it.copy(
@@ -72,6 +81,7 @@ class DefaultMcpActivityRepository : McpActivityRepository {
sessionId = finished.sessionId,
succeeded = !failed,
finishedAtEpochMillis = finishedAtEpochMillis,
+ arguments = finished.arguments,
)
(listOf(record) + activity.recentCalls)
.take(McpActivity.MAX_RECENT_CALLS)
diff --git a/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt b/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt
index ec6cc5bb..662026f4 100644
--- a/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt
+++ b/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt
@@ -1,6 +1,7 @@
package com.kitakkun.jetwhale.host.data.server
import com.kitakkun.jetwhale.host.model.McpActivity
+import com.kitakkun.jetwhale.host.model.McpCallArgument
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -17,7 +18,7 @@ class DefaultMcpActivityRepositoryTest {
@Test
fun `a completed call is recorded with its attribution`() {
- val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1")
+ val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap())
repository.toolInvocationFinished(id, failed = false)
val record = repository.activityFlow.value.recentCalls.single()
@@ -29,14 +30,14 @@ class DefaultMcpActivityRepositoryTest {
@Test
fun `a call is only recorded once it finishes`() {
- repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1")
+ repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap())
assertTrue(repository.activityFlow.value.recentCalls.isEmpty())
}
@Test
fun `a failed call is recorded as unsuccessful`() {
- val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1")
+ val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap())
repository.toolInvocationFinished(id, failed = true)
assertFalse(repository.activityFlow.value.recentCalls.single().succeeded)
@@ -45,7 +46,7 @@ class DefaultMcpActivityRepositoryTest {
@Test
fun `history is ordered newest first`() {
listOf("first", "second", "third").forEach { toolName ->
- val id = repository.toolInvocationStarted(toolName, "com.example.plugin", "session-1")
+ val id = repository.toolInvocationStarted(toolName, "com.example.plugin", "session-1", emptyMap())
repository.toolInvocationFinished(id, failed = false)
}
@@ -59,7 +60,7 @@ class DefaultMcpActivityRepositoryTest {
fun `history drops the oldest calls once the cap is reached`() {
val overflow = McpActivity.MAX_RECENT_CALLS + 5
repeat(overflow) { index ->
- val id = repository.toolInvocationStarted("tool-$index", "com.example.plugin", "session-1")
+ val id = repository.toolInvocationStarted("tool-$index", "com.example.plugin", "session-1", emptyMap())
repository.toolInvocationFinished(id, failed = false)
}
@@ -76,9 +77,69 @@ class DefaultMcpActivityRepositoryTest {
assertTrue(repository.activityFlow.value.recentCalls.isEmpty())
}
+ @Test
+ fun `a completed call is recorded with the arguments it was made with`() {
+ val id = repository.toolInvocationStarted(
+ "plugin.click",
+ "com.example.plugin",
+ "session-1",
+ mapOf("sessionId" to "session-1", "x" to "100"),
+ )
+ repository.toolInvocationFinished(id, failed = false)
+
+ val record = repository.activityFlow.value.recentCalls.single()
+ assertEquals(
+ listOf(
+ McpCallArgument("sessionId", "session-1"),
+ McpCallArgument("x", "100"),
+ ),
+ record.arguments,
+ )
+ }
+
+ @Test
+ fun `a long argument value is truncated with an ellipsis`() {
+ val value = "a".repeat(McpCallArgument.MAX_VALUE_LENGTH + 20)
+ val id = repository.toolInvocationStarted(
+ "plugin.type",
+ "com.example.plugin",
+ "session-1",
+ mapOf("text" to value),
+ )
+ repository.toolInvocationFinished(id, failed = false)
+
+ val recorded = repository.activityFlow.value.recentCalls.single().arguments.single()
+ assertEquals(
+ "a".repeat(McpCallArgument.MAX_VALUE_LENGTH) + McpCallArgument.TRUNCATION_MARKER,
+ recorded.value,
+ )
+ }
+
+ @Test
+ fun `an argument value at the cap is kept whole`() {
+ val value = "a".repeat(McpCallArgument.MAX_VALUE_LENGTH)
+ val id = repository.toolInvocationStarted(
+ "plugin.type",
+ "com.example.plugin",
+ "session-1",
+ mapOf("text" to value),
+ )
+ repository.toolInvocationFinished(id, failed = false)
+
+ assertEquals(value, repository.activityFlow.value.recentCalls.single().arguments.single().value)
+ }
+
+ @Test
+ fun `a call without arguments records an empty argument list`() {
+ val id = repository.toolInvocationStarted("plugin.screenshot", "com.example.plugin", "session-1", emptyMap())
+ repository.toolInvocationFinished(id, failed = false)
+
+ assertTrue(repository.activityFlow.value.recentCalls.single().arguments.isEmpty())
+ }
+
@Test
fun `clear drops the recorded history`() {
- val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1")
+ val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap())
repository.toolInvocationFinished(id, failed = false)
repository.clear()
diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt
index 23e27337..6073f3e0 100644
--- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt
+++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt
@@ -78,6 +78,13 @@ class McpToolRegistrar(
toolName = name,
pluginId = resolvePluginId(request),
sessionId = request.arguments?.get("sessionId")?.jsonContent,
+ // Every argument key is reported, including the ones the UI already shows as
+ // attribution, so history describes the call exactly as the agent made it.
+ arguments = request.arguments.orEmpty().mapValues { (_, value) ->
+ // Primitives render without the surrounding JSON quoting; objects and arrays
+ // fall back to their JSON form.
+ value.jsonContent ?: value.toString()
+ },
)
// A thrown handler is the only failure signal available here, and it has to reach the
// repository before it propagates on to the MCP layer.
diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt
index 4567080b..f2192082 100644
--- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt
+++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt
@@ -2,6 +2,7 @@ package com.kitakkun.jetwhale.host.mcp
import com.kitakkun.jetwhale.host.model.McpActivity
import com.kitakkun.jetwhale.host.model.McpActivityRepository
+import com.kitakkun.jetwhale.host.model.McpCallArgument
import com.kitakkun.jetwhale.host.model.McpCallRecord
import com.kitakkun.jetwhale.host.model.McpToolInvocation
import kotlinx.collections.immutable.toImmutableList
@@ -34,12 +35,20 @@ class FakeMcpActivityRepository : McpActivityRepository {
}
}
- override fun toolInvocationStarted(toolName: String, pluginId: String?, sessionId: String?): Long {
+ override fun toolInvocationStarted(
+ toolName: String,
+ pluginId: String?,
+ sessionId: String?,
+ arguments: Map,
+ ): Long {
val invocation = McpToolInvocation(
id = nextInvocationId.incrementAndGet(),
toolName = toolName,
pluginId = pluginId,
sessionId = sessionId,
+ arguments = arguments
+ .map { (name, value) -> McpCallArgument.truncating(name, value) }
+ .toImmutableList(),
)
_recordedInvocations += invocation
activityFlow.update {
@@ -70,6 +79,7 @@ class FakeMcpActivityRepository : McpActivityRepository {
sessionId = finished.sessionId,
succeeded = !failed,
finishedAtEpochMillis = finishedAtEpochMillis,
+ arguments = finished.arguments,
)
(listOf(record) + activity.recentCalls)
.take(McpActivity.MAX_RECENT_CALLS)
diff --git a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt
index e4087db0..8aa86327 100644
--- a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt
+++ b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt
@@ -3,6 +3,36 @@ package com.kitakkun.jetwhale.host.model
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
+/**
+ * One argument a tool call was made with, already rendered for display.
+ *
+ * [value] is the argument rendered to a string and shortened to [MAX_VALUE_LENGTH] characters, so a
+ * call carrying a large body (a screenshot, an accessibility tree, ...) does not keep that body
+ * alive for as long as the call stays in history.
+ */
+data class McpCallArgument(
+ val name: String,
+ val value: String,
+) {
+ companion object {
+ /** How many characters of an argument value are kept. */
+ const val MAX_VALUE_LENGTH = 80
+
+ /** Appended to a [value] that was cut, so the UI does not have to mark it itself. */
+ const val TRUNCATION_MARKER = "…"
+
+ /** Builds an argument whose [value] is shortened to [MAX_VALUE_LENGTH] characters. */
+ fun truncating(name: String, value: String): McpCallArgument = McpCallArgument(
+ name = name,
+ value = if (value.length <= MAX_VALUE_LENGTH) {
+ value
+ } else {
+ value.take(MAX_VALUE_LENGTH) + TRUNCATION_MARKER
+ },
+ )
+ }
+}
+
/**
* A single MCP tool call that is currently being executed on behalf of an AI agent.
*
@@ -15,6 +45,8 @@ data class McpToolInvocation(
val toolName: String,
val pluginId: String?,
val sessionId: String?,
+ /** Every argument the call was made with, including `pluginId` and `sessionId`. */
+ val arguments: ImmutableList,
)
/**
@@ -29,6 +61,8 @@ data class McpCallRecord(
val sessionId: String?,
val succeeded: Boolean,
val finishedAtEpochMillis: Long,
+ /** Every argument the call was made with, including `pluginId` and `sessionId`. */
+ val arguments: ImmutableList,
)
/**
diff --git a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt
index a2b02ce1..a0eae452 100644
--- a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt
+++ b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt
@@ -19,9 +19,17 @@ interface McpActivityRepository {
/**
* Records the start of a tool call.
*
+ * @param arguments the arguments the call was made with, already rendered to strings. Values are
+ * shortened to [McpCallArgument.MAX_VALUE_LENGTH] here, so nothing large is retained for as long
+ * as the call stays in history.
* @return the invocation id to hand back to [toolInvocationFinished] once the call completes.
*/
- fun toolInvocationStarted(toolName: String, pluginId: String?, sessionId: String?): Long
+ fun toolInvocationStarted(
+ toolName: String,
+ pluginId: String?,
+ sessionId: String?,
+ arguments: Map,
+ ): Long
/**
* Records the completion of a tool call and appends it to the recent-call history.
From 39e33faf9802493de7227f34a56f0f02b13ad7aa Mon Sep 17 00:00:00 2001
From: kitakkun <48154936+kitakkun@users.noreply.github.com>
Date: Tue, 28 Jul 2026 22:16:35 +0900
Subject: [PATCH 05/13] refactor(host): browse call history in two panes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Match the Tools tab: a selectable list of calls on the left, and the
selected call's full detail on the right — tool name, status, time, and
each argument on its own labelled line. Replaces the inline expansion,
which cramped long argument values into the row. The right pane also
carries copy buttons alongside the existing row context menu.
---
.../composeResources/values-ja/strings.xml | 2 +-
.../main/composeResources/values/strings.xml | 2 +-
.../host/drawer/PluginDrawerItemView.kt | 281 +++++++++++-------
3 files changed, 177 insertions(+), 108 deletions(-)
diff --git a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
index 2e716662..8feac7ca 100644
--- a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
+++ b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
@@ -43,8 +43,8 @@
MCP tools の呼び出し履歴はありません
成功
失敗
- 引数の表示を切り替え
ツール名をコピー
引数をコピー
詳細をコピー
+ 引数なし
diff --git a/jetwhale-host/app/src/main/composeResources/values/strings.xml b/jetwhale-host/app/src/main/composeResources/values/strings.xml
index e9360c49..0e641462 100644
--- a/jetwhale-host/app/src/main/composeResources/values/strings.xml
+++ b/jetwhale-host/app/src/main/composeResources/values/strings.xml
@@ -43,8 +43,8 @@
No MCP tool calls yet
Succeeded
Failed
- Show or hide arguments
Copy tool name
Copy arguments
Copy details
+ No arguments
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
index 0f82af6c..095f6608 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
@@ -71,8 +71,8 @@ import com.kitakkun.jetwhale.host.mcp_history_copy_details
import com.kitakkun.jetwhale.host.mcp_history_copy_tool_name
import com.kitakkun.jetwhale.host.mcp_history_empty
import com.kitakkun.jetwhale.host.mcp_history_failed
+import com.kitakkun.jetwhale.host.mcp_history_no_arguments
import com.kitakkun.jetwhale.host.mcp_history_succeeded
-import com.kitakkun.jetwhale.host.mcp_history_toggle_arguments
import com.kitakkun.jetwhale.host.mcp_tool_executing
import com.kitakkun.jetwhale.host.mcp_tools_available
import com.kitakkun.jetwhale.host.mcp_tools_no_match
@@ -524,24 +524,147 @@ private fun McpCallHistoryPane(
}
return
}
- // Expansion lives above the lazy list so a row keeps its state while scrolled out of view.
- var expandedCallIds by remember { mutableStateOf(emptySet()) }
- LazyColumn(
- modifier = modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(2.dp),
+ // Selection lives above the lazy list so it survives the row scrolling out of view.
+ var selectedCallId by remember { mutableStateOf(callHistory.first().id) }
+ val selected = callHistory.firstOrNull { it.id == selectedCallId } ?: callHistory.first()
+
+ Row(modifier = modifier) {
+ LazyColumn(
+ modifier = Modifier.width(300.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ items(callHistory, key = { it.id }) { record ->
+ McpCallHistoryRow(
+ record = record,
+ selected = record.id == selected.id,
+ onSelect = { selectedCallId = record.id },
+ )
+ }
+ }
+ VerticalDivider(modifier = Modifier.padding(horizontal = 12.dp))
+ McpCallDetailPane(
+ record = selected,
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight(),
+ )
+ }
+}
+
+/** Right pane: everything recorded about the selected call, with copy actions. */
+@Composable
+private fun McpCallDetailPane(
+ record: McpCallRecord,
+ modifier: Modifier,
+) {
+ val statusLabel = stringResource(
+ if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed,
+ )
+ val finishedAt = formatCallTime(record.finishedAtEpochMillis)
+ val renderedArguments = record.arguments.joinToString(separator = "\n") { "${it.name} = ${it.value}" }
+ val clipboardManager = LocalClipboardManager.current
+
+ Column(
+ modifier = modifier.verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
) {
- items(callHistory, key = { it.id }) { record ->
- McpCallHistoryRow(
- record = record,
- expanded = record.id in expandedCallIds,
- onToggleExpanded = {
- expandedCallIds = if (record.id in expandedCallIds) {
- expandedCallIds - record.id
- } else {
- expandedCallIds + record.id
- }
+ Text(
+ text = record.toolName.substringAfterLast('.'),
+ style = MaterialTheme.typography.titleMedium,
+ fontFamily = FontFamily.Monospace,
+ )
+ Text(
+ text = record.toolName,
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Icon(
+ imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline,
+ contentDescription = null,
+ tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(16.dp),
+ )
+ Text(
+ text = statusLabel,
+ style = MaterialTheme.typography.labelMedium,
+ color = if (record.succeeded) {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ } else {
+ MaterialTheme.colorScheme.error
},
)
+ Text(
+ text = finishedAt,
+ style = MaterialTheme.typography.labelMedium,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ Spacer(Modifier.size(4.dp))
+ Text(
+ text = stringResource(Res.string.mcp_tools_parameters),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ if (record.arguments.isEmpty()) {
+ Text(
+ text = stringResource(Res.string.mcp_history_no_arguments),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ record.arguments.forEach { argument ->
+ Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
+ Text(
+ text = argument.name,
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ )
+ Text(
+ text = argument.value,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+
+ Spacer(Modifier.size(4.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ TextButton(
+ onClick = { clipboardManager.setText(AnnotatedString(record.toolName)) },
+ ) {
+ Text(stringResource(Res.string.mcp_history_copy_tool_name))
+ }
+ if (record.arguments.isNotEmpty()) {
+ TextButton(
+ onClick = { clipboardManager.setText(AnnotatedString(renderedArguments)) },
+ ) {
+ Text(stringResource(Res.string.mcp_history_copy_arguments))
+ }
+ }
+ TextButton(
+ onClick = {
+ clipboardManager.setText(
+ AnnotatedString(
+ buildCallDetails(
+ toolName = record.toolName,
+ statusLabel = statusLabel,
+ finishedAt = finishedAt,
+ renderedArguments = renderedArguments,
+ ),
+ ),
+ )
+ },
+ ) {
+ Text(stringResource(Res.string.mcp_history_copy_details))
+ }
}
}
}
@@ -549,18 +672,14 @@ private fun McpCallHistoryPane(
@Composable
private fun McpCallHistoryRow(
record: McpCallRecord,
- expanded: Boolean,
- onToggleExpanded: () -> Unit,
+ selected: Boolean,
+ onSelect: () -> Unit,
) {
- val succeededLabel = stringResource(
+ val statusLabel = stringResource(
if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed,
)
- val toggleArgumentsLabel = stringResource(Res.string.mcp_history_toggle_arguments)
val finishedAt = formatCallTime(record.finishedAtEpochMillis)
- // Only calls that carry arguments have anything to reveal, so the rest stay plain rows.
- val hasArguments = record.arguments.isNotEmpty()
val renderedArguments = record.arguments.joinToString(separator = "\n") { "${it.name} = ${it.value}" }
- val chevronRotation by animateFloatAsState(if (expanded) 90f else 0f)
val clipboardManager = LocalClipboardManager.current
val copyToolNameLabel = stringResource(Res.string.mcp_history_copy_tool_name)
@@ -575,7 +694,7 @@ private fun McpCallHistoryRow(
clipboardManager.setText(AnnotatedString(record.toolName))
},
)
- if (hasArguments) {
+ if (record.arguments.isNotEmpty()) {
add(
ContextMenuItem(copyArgumentsLabel) {
clipboardManager.setText(AnnotatedString(renderedArguments))
@@ -588,7 +707,7 @@ private fun McpCallHistoryRow(
AnnotatedString(
buildCallDetails(
toolName = record.toolName,
- statusLabel = succeededLabel,
+ statusLabel = statusLabel,
finishedAt = finishedAt,
renderedArguments = renderedArguments,
),
@@ -599,94 +718,44 @@ private fun McpCallHistoryRow(
}
},
) {
- Column(
+ Row(
modifier = Modifier
.fillMaxWidth()
- .clip(RoundedCornerShape(6.dp)),
+ .clip(RoundedCornerShape(6.dp))
+ .background(if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent)
+ .clickable(role = Role.Button, onClick = onSelect)
+ .padding(horizontal = 10.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .then(
- if (hasArguments) {
- Modifier.clickable(
- onClickLabel = toggleArgumentsLabel,
- role = Role.Button,
- onClick = onToggleExpanded,
- )
- } else {
- Modifier
- },
- )
- .padding(horizontal = 10.dp, vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- if (hasArguments) {
- Icon(
- imageVector = Icons.Default.KeyboardArrowRight,
- contentDescription = toggleArgumentsLabel,
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier
- .size(16.dp)
- .rotate(chevronRotation),
- )
+ Icon(
+ imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline,
+ contentDescription = statusLabel,
+ tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(16.dp),
+ )
+ Text(
+ text = record.toolName.substringAfterLast('.'),
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = if (selected) {
+ MaterialTheme.colorScheme.onSecondaryContainer
} else {
- // Keeps rows without arguments aligned with the ones that show a chevron.
- Spacer(Modifier.size(16.dp))
- }
- Icon(
- imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline,
- contentDescription = succeededLabel,
- tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error,
- modifier = Modifier.size(16.dp),
- )
- Text(
- text = record.toolName.substringAfterLast('.'),
- style = MaterialTheme.typography.bodyMedium,
- fontFamily = FontFamily.Monospace,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(1f),
- )
- Text(
- text = succeededLabel,
- style = MaterialTheme.typography.labelSmall,
- color = if (record.succeeded) {
- MaterialTheme.colorScheme.onSurfaceVariant
- } else {
- MaterialTheme.colorScheme.error
- },
- )
- Text(
- text = finishedAt,
- style = MaterialTheme.typography.labelSmall,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- if (hasArguments && expanded) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 36.dp, end = 10.dp, bottom = 8.dp),
- verticalArrangement = Arrangement.spacedBy(2.dp),
- ) {
- record.arguments.forEach { argument ->
- Text(
- text = "${argument.name} = ${argument.value}",
- style = MaterialTheme.typography.labelSmall,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- }
+ MaterialTheme.colorScheme.onSurface
+ },
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = finishedAt,
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
}
}
}
-
-/** Everything known about one call, in the order the row shows it. */
private fun buildCallDetails(
toolName: String,
statusLabel: String,
From eb18026ebc97ac8d383e6acfe5b434f6b875575a Mon Sep 17 00:00:00 2001
From: kitakkun <48154936+kitakkun@users.noreply.github.com>
Date: Tue, 28 Jul 2026 22:20:18 +0900
Subject: [PATCH 06/13] refactor(host): move the MCP tools dialog to its own
file
PluginDrawerItemView had grown to hold the drawer item, the MCP badge, the
tool browser, the call history, and their context menus. Move everything
from the dialog down into McpToolsDialog.kt so each file covers one screen
concern. Code is unchanged apart from the visibility needed across the file
boundary and the import split.
---
.../jetwhale/host/drawer/McpToolsDialog.kt | 656 ++++++++++++++++++
.../host/drawer/PluginDrawerItemView.kt | 631 -----------------
2 files changed, 656 insertions(+), 631 deletions(-)
create mode 100644 jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt
new file mode 100644
index 00000000..03845d23
--- /dev/null
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt
@@ -0,0 +1,656 @@
+package com.kitakkun.jetwhale.host.drawer
+
+import androidx.compose.foundation.ContextMenuArea
+import androidx.compose.foundation.ContextMenuItem
+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.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.ErrorOutline
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Tab
+import androidx.compose.material3.TabRow
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.VerticalDivider
+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.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import com.kitakkun.jetwhale.host.Res
+import com.kitakkun.jetwhale.host.close
+import com.kitakkun.jetwhale.host.mcp_history_copy_arguments
+import com.kitakkun.jetwhale.host.mcp_history_copy_details
+import com.kitakkun.jetwhale.host.mcp_history_copy_tool_name
+import com.kitakkun.jetwhale.host.mcp_history_empty
+import com.kitakkun.jetwhale.host.mcp_history_failed
+import com.kitakkun.jetwhale.host.mcp_history_no_arguments
+import com.kitakkun.jetwhale.host.mcp_history_succeeded
+import com.kitakkun.jetwhale.host.mcp_tool_executing
+import com.kitakkun.jetwhale.host.mcp_tools_available
+import com.kitakkun.jetwhale.host.mcp_tools_no_match
+import com.kitakkun.jetwhale.host.mcp_tools_parameters
+import com.kitakkun.jetwhale.host.mcp_tools_required
+import com.kitakkun.jetwhale.host.mcp_tools_search
+import com.kitakkun.jetwhale.host.mcp_tools_tab_history
+import com.kitakkun.jetwhale.host.mcp_tools_tab_tools
+import com.kitakkun.jetwhale.host.model.McpCallRecord
+import com.kitakkun.jetwhale.host.model.McpToolParameterSummary
+import com.kitakkun.jetwhale.host.model.McpToolSummary
+import kotlinx.collections.immutable.ImmutableList
+import org.jetbrains.compose.resources.stringResource
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+
+/** The panes the MCP dialog can show: the tools a plugin publishes, or the calls already made. */
+internal enum class McpDialogTab {
+ Tools,
+ History,
+}
+
+@Composable
+internal fun McpToolsDialog(
+ operating: Boolean,
+ pluginName: String,
+ tools: ImmutableList,
+ callHistory: ImmutableList,
+ runningToolName: String?,
+ onDismiss: () -> Unit,
+) {
+ Dialog(
+ onDismissRequest = onDismiss,
+ // The platform default constrains the dialog to its content's size; tool descriptions and
+ // history need the room to grow with the window instead.
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ ) {
+ Surface(shape = MaterialTheme.shapes.large) {
+ Column(
+ modifier = Modifier
+ // Take most of the window so long descriptions and history are readable, but
+ // stop growing past a comfortable reading width on a large display.
+ .fillMaxSize(MCP_TOOLS_DIALOG_WINDOW_FRACTION)
+ .sizeIn(
+ minWidth = 640.dp,
+ minHeight = 440.dp,
+ maxWidth = 1200.dp,
+ maxHeight = 860.dp,
+ )
+ .padding(20.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = pluginName,
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = stringResource(
+ if (operating) Res.string.mcp_tool_executing else Res.string.mcp_tools_available,
+ ),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Spacer(Modifier.size(12.dp))
+
+ var selectedTab by remember { mutableStateOf(McpDialogTab.Tools) }
+ TabRow(selectedTabIndex = selectedTab.ordinal) {
+ Tab(
+ selected = selectedTab == McpDialogTab.Tools,
+ onClick = { selectedTab = McpDialogTab.Tools },
+ text = { Text(stringResource(Res.string.mcp_tools_tab_tools)) },
+ )
+ Tab(
+ selected = selectedTab == McpDialogTab.History,
+ onClick = { selectedTab = McpDialogTab.History },
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text(stringResource(Res.string.mcp_tools_tab_history))
+ if (callHistory.isNotEmpty()) {
+ // How many calls the history holds, so the count is visible
+ // without opening the tab.
+ McpToolCallCountBadge(count = callHistory.size, running = false)
+ }
+ }
+ },
+ )
+ }
+ Spacer(Modifier.size(12.dp))
+
+ // Hoisted out of the pane so switching tabs and coming back keeps the search and the
+ // selected tool where the user left them.
+ var query by remember { mutableStateOf("") }
+ var selectedName by remember { mutableStateOf(tools.firstOrNull()?.name) }
+
+ when (selectedTab) {
+ McpDialogTab.Tools -> McpToolsPane(
+ tools = tools,
+ query = query,
+ onQueryChange = { query = it },
+ selectedToolName = selectedName,
+ onSelectTool = { selectedName = it },
+ runningToolName = runningToolName,
+ // Counts come from the retained history, so they cover the calls the
+ // dialog can actually show rather than all time.
+ callCounts = remember(callHistory) { callHistory.groupingBy { it.toolName }.eachCount() },
+ modifier = Modifier.weight(1f),
+ )
+
+ McpDialogTab.History -> McpCallHistoryPane(
+ callHistory = callHistory,
+ modifier = Modifier.weight(1f),
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(Res.string.close))
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Trailing badge on a tool row: the number of recorded calls, shown quietly. While an agent is
+ * running the tool it takes the accent fill and the same rotating ring the drawer item uses, so
+ * "being called right now" reads the same way everywhere.
+ */
+@Composable
+internal fun McpToolCallCountBadge(count: Int, running: Boolean) {
+ val shape = RoundedCornerShape(6.dp)
+ Box(
+ modifier = Modifier
+ .clip(shape)
+ .background(
+ if (running) AiOperatingAccentColor else MaterialTheme.colorScheme.surfaceContainerHighest,
+ shape,
+ )
+ .then(
+ if (running) Modifier.aiOperatingBorder(color = AiOperatingAccentColor, width = 2.dp) else Modifier,
+ )
+ .padding(horizontal = 7.dp, vertical = 2.dp),
+ ) {
+ Text(
+ text = count.toString(),
+ style = MaterialTheme.typography.labelMedium,
+ fontFamily = FontFamily.Monospace,
+ color = if (running) Color.Black else MaterialTheme.colorScheme.onSurface,
+ )
+ }
+}
+
+/** Two-pane browser over the tools a plugin publishes: search + list on the left, detail right. */
+@Composable
+private fun McpToolsPane(
+ tools: ImmutableList,
+ query: String,
+ onQueryChange: (String) -> Unit,
+ selectedToolName: String?,
+ onSelectTool: (String) -> Unit,
+ runningToolName: String?,
+ callCounts: Map,
+ modifier: Modifier,
+) {
+ val filtered = remember(query, tools) {
+ if (query.isBlank()) {
+ tools
+ } else {
+ tools.filter {
+ it.name.contains(query, ignoreCase = true) ||
+ it.description.contains(query, ignoreCase = true)
+ }
+ }
+ }
+ val selected = filtered.firstOrNull { it.name == selectedToolName } ?: filtered.firstOrNull()
+
+ Row(modifier = modifier) {
+ // Left pane: search + tool list.
+ Column(modifier = Modifier.width(300.dp)) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ singleLine = true,
+ leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
+ placeholder = { Text(stringResource(Res.string.mcp_tools_search)) },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.size(8.dp))
+ LazyColumn(modifier = Modifier.fillMaxHeight()) {
+ items(filtered, key = { it.name }) { tool ->
+ val isSelected = tool.name == selected?.name
+ val isRunning = tool.name == runningToolName
+ val callCount = callCounts[tool.name] ?: 0
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(6.dp))
+ .background(
+ if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
+ )
+ .clickable { onSelectTool(tool.name) }
+ .padding(horizontal = 10.dp, vertical = 8.dp),
+ ) {
+ Text(
+ text = tool.name.substringAfterLast('.'),
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = if (isSelected) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ // Takes the free space so the count and dot sit against the right edge.
+ modifier = Modifier.weight(1f),
+ )
+ if (callCount > 0 || isRunning) {
+ McpToolCallCountBadge(count = callCount, running = isRunning)
+ }
+ }
+ }
+ }
+ }
+ VerticalDivider(modifier = Modifier.padding(horizontal = 12.dp))
+ // Right pane: the selected tool's detail.
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (selected == null) {
+ Text(
+ text = stringResource(Res.string.mcp_tools_no_match),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ Text(
+ text = selected.name.substringAfterLast('.'),
+ style = MaterialTheme.typography.titleMedium,
+ fontFamily = FontFamily.Monospace,
+ )
+ Text(
+ text = selected.name,
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = selected.description,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ if (selected.parameters.isNotEmpty()) {
+ Spacer(Modifier.size(4.dp))
+ Text(
+ text = stringResource(Res.string.mcp_tools_parameters),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ selected.parameters.forEach { param -> McpParameterRow(param) }
+ }
+ }
+ }
+ }
+}
+
+/** What an agent already did with this plugin, newest call first. */
+@Composable
+private fun McpCallHistoryPane(
+ callHistory: ImmutableList,
+ modifier: Modifier,
+) {
+ if (callHistory.isEmpty()) {
+ Box(
+ modifier = modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = stringResource(Res.string.mcp_history_empty),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ return
+ }
+ // Selection lives above the lazy list so it survives the row scrolling out of view.
+ var selectedCallId by remember { mutableStateOf(callHistory.first().id) }
+ val selected = callHistory.firstOrNull { it.id == selectedCallId } ?: callHistory.first()
+
+ Row(modifier = modifier) {
+ LazyColumn(
+ modifier = Modifier.width(300.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ items(callHistory, key = { it.id }) { record ->
+ McpCallHistoryRow(
+ record = record,
+ selected = record.id == selected.id,
+ onSelect = { selectedCallId = record.id },
+ )
+ }
+ }
+ VerticalDivider(modifier = Modifier.padding(horizontal = 12.dp))
+ McpCallDetailPane(
+ record = selected,
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight(),
+ )
+ }
+}
+
+/** Right pane: everything recorded about the selected call, with copy actions. */
+@Composable
+private fun McpCallDetailPane(
+ record: McpCallRecord,
+ modifier: Modifier,
+) {
+ val statusLabel = stringResource(
+ if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed,
+ )
+ val finishedAt = formatCallTime(record.finishedAtEpochMillis)
+ val renderedArguments = record.arguments.joinToString(separator = "\n") { "${it.name} = ${it.value}" }
+ val clipboardManager = LocalClipboardManager.current
+
+ Column(
+ modifier = modifier.verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = record.toolName.substringAfterLast('.'),
+ style = MaterialTheme.typography.titleMedium,
+ fontFamily = FontFamily.Monospace,
+ )
+ Text(
+ text = record.toolName,
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Icon(
+ imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline,
+ contentDescription = null,
+ tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(16.dp),
+ )
+ Text(
+ text = statusLabel,
+ style = MaterialTheme.typography.labelMedium,
+ color = if (record.succeeded) {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ } else {
+ MaterialTheme.colorScheme.error
+ },
+ )
+ Text(
+ text = finishedAt,
+ style = MaterialTheme.typography.labelMedium,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ Spacer(Modifier.size(4.dp))
+ Text(
+ text = stringResource(Res.string.mcp_tools_parameters),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ if (record.arguments.isEmpty()) {
+ Text(
+ text = stringResource(Res.string.mcp_history_no_arguments),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ record.arguments.forEach { argument ->
+ Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
+ Text(
+ text = argument.name,
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ )
+ Text(
+ text = argument.value,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+
+ Spacer(Modifier.size(4.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ TextButton(
+ onClick = { clipboardManager.setText(AnnotatedString(record.toolName)) },
+ ) {
+ Text(stringResource(Res.string.mcp_history_copy_tool_name))
+ }
+ if (record.arguments.isNotEmpty()) {
+ TextButton(
+ onClick = { clipboardManager.setText(AnnotatedString(renderedArguments)) },
+ ) {
+ Text(stringResource(Res.string.mcp_history_copy_arguments))
+ }
+ }
+ TextButton(
+ onClick = {
+ clipboardManager.setText(
+ AnnotatedString(
+ buildCallDetails(
+ toolName = record.toolName,
+ statusLabel = statusLabel,
+ finishedAt = finishedAt,
+ renderedArguments = renderedArguments,
+ ),
+ ),
+ )
+ },
+ ) {
+ Text(stringResource(Res.string.mcp_history_copy_details))
+ }
+ }
+ }
+}
+
+@Composable
+private fun McpCallHistoryRow(
+ record: McpCallRecord,
+ selected: Boolean,
+ onSelect: () -> Unit,
+) {
+ val statusLabel = stringResource(
+ if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed,
+ )
+ val finishedAt = formatCallTime(record.finishedAtEpochMillis)
+ val renderedArguments = record.arguments.joinToString(separator = "\n") { "${it.name} = ${it.value}" }
+
+ val clipboardManager = LocalClipboardManager.current
+ val copyToolNameLabel = stringResource(Res.string.mcp_history_copy_tool_name)
+ val copyArgumentsLabel = stringResource(Res.string.mcp_history_copy_arguments)
+ val copyDetailsLabel = stringResource(Res.string.mcp_history_copy_details)
+
+ ContextMenuArea(
+ items = {
+ buildList {
+ add(
+ ContextMenuItem(copyToolNameLabel) {
+ clipboardManager.setText(AnnotatedString(record.toolName))
+ },
+ )
+ if (record.arguments.isNotEmpty()) {
+ add(
+ ContextMenuItem(copyArgumentsLabel) {
+ clipboardManager.setText(AnnotatedString(renderedArguments))
+ },
+ )
+ }
+ add(
+ ContextMenuItem(copyDetailsLabel) {
+ clipboardManager.setText(
+ AnnotatedString(
+ buildCallDetails(
+ toolName = record.toolName,
+ statusLabel = statusLabel,
+ finishedAt = finishedAt,
+ renderedArguments = renderedArguments,
+ ),
+ ),
+ )
+ },
+ )
+ }
+ },
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(6.dp))
+ .background(if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent)
+ .clickable(role = Role.Button, onClick = onSelect)
+ .padding(horizontal = 10.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline,
+ contentDescription = statusLabel,
+ tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(16.dp),
+ )
+ Text(
+ text = record.toolName.substringAfterLast('.'),
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = if (selected) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = finishedAt,
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+private fun buildCallDetails(
+ toolName: String,
+ statusLabel: String,
+ finishedAt: String,
+ renderedArguments: String,
+): String = buildString {
+ appendLine(toolName)
+ appendLine(statusLabel)
+ append(finishedAt)
+ if (renderedArguments.isNotEmpty()) {
+ appendLine()
+ append(renderedArguments)
+ }
+}
+
+/** Wall-clock time of day, which is what the user can line up against their own actions. */
+private const val MCP_TOOLS_DIALOG_WINDOW_FRACTION = 0.8f
+
+private val CallHistoryTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
+
+private fun formatCallTime(epochMillis: Long): String = Instant.ofEpochMilli(epochMillis)
+ .atZone(ZoneId.systemDefault())
+ .format(CallHistoryTimeFormatter)
+
+@Composable
+private fun McpParameterRow(param: McpToolParameterSummary) {
+ Column(
+ modifier = Modifier.padding(top = 6.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text(
+ text = param.name,
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ )
+ if (param.type.isNotEmpty()) {
+ Text(
+ text = param.type,
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (param.required) {
+ Text(
+ text = stringResource(Res.string.mcp_tools_required),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ if (param.description.isNotEmpty()) {
+ Text(
+ text = param.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
index 095f6608..6c198165 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
@@ -1,50 +1,25 @@
package com.kitakkun.jetwhale.host.drawer
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.foundation.ContextMenuArea
-import androidx.compose.foundation.ContextMenuItem
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.ColumnScope
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxSize
-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.sizeIn
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
-import androidx.compose.material.icons.filled.CheckCircle
-import androidx.compose.material.icons.filled.ErrorOutline
-import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationDrawerItem
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Tab
-import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -54,45 +29,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalClipboardManager
-import androidx.compose.ui.semantics.Role
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.Dialog
-import androidx.compose.ui.window.DialogProperties
import com.kitakkun.jetwhale.host.Res
-import com.kitakkun.jetwhale.host.close
-import com.kitakkun.jetwhale.host.mcp_history_copy_arguments
-import com.kitakkun.jetwhale.host.mcp_history_copy_details
-import com.kitakkun.jetwhale.host.mcp_history_copy_tool_name
-import com.kitakkun.jetwhale.host.mcp_history_empty
-import com.kitakkun.jetwhale.host.mcp_history_failed
-import com.kitakkun.jetwhale.host.mcp_history_no_arguments
-import com.kitakkun.jetwhale.host.mcp_history_succeeded
-import com.kitakkun.jetwhale.host.mcp_tool_executing
-import com.kitakkun.jetwhale.host.mcp_tools_available
-import com.kitakkun.jetwhale.host.mcp_tools_no_match
-import com.kitakkun.jetwhale.host.mcp_tools_parameters
-import com.kitakkun.jetwhale.host.mcp_tools_required
-import com.kitakkun.jetwhale.host.mcp_tools_search
-import com.kitakkun.jetwhale.host.mcp_tools_tab_history
-import com.kitakkun.jetwhale.host.mcp_tools_tab_tools
import com.kitakkun.jetwhale.host.model.McpCallRecord
-import com.kitakkun.jetwhale.host.model.McpToolParameterSummary
import com.kitakkun.jetwhale.host.model.McpToolSummary
import com.kitakkun.jetwhale.host.model.PluginIconResource
import com.kitakkun.jetwhale.host.puzzle_filled
import com.kitakkun.jetwhale.host.puzzle_outlined
import kotlinx.collections.immutable.ImmutableList
import org.jetbrains.compose.resources.painterResource
-import org.jetbrains.compose.resources.stringResource
-import java.time.Instant
-import java.time.ZoneId
-import java.time.format.DateTimeFormatter
@Composable
fun PluginDrawerItemView(
@@ -242,581 +189,3 @@ private fun McpBadge(
)
}
}
-
-/** The panes the MCP dialog can show: the tools a plugin publishes, or the calls already made. */
-private enum class McpDialogTab {
- Tools,
- History,
-}
-
-@Composable
-private fun McpToolsDialog(
- operating: Boolean,
- pluginName: String,
- tools: ImmutableList,
- callHistory: ImmutableList,
- runningToolName: String?,
- onDismiss: () -> Unit,
-) {
- Dialog(
- onDismissRequest = onDismiss,
- // The platform default constrains the dialog to its content's size; tool descriptions and
- // history need the room to grow with the window instead.
- properties = DialogProperties(usePlatformDefaultWidth = false),
- ) {
- Surface(shape = MaterialTheme.shapes.large) {
- Column(
- modifier = Modifier
- // Take most of the window so long descriptions and history are readable, but
- // stop growing past a comfortable reading width on a large display.
- .fillMaxSize(MCP_TOOLS_DIALOG_WINDOW_FRACTION)
- .sizeIn(
- minWidth = 640.dp,
- minHeight = 440.dp,
- maxWidth = 1200.dp,
- maxHeight = 860.dp,
- )
- .padding(20.dp),
- ) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(
- text = pluginName,
- style = MaterialTheme.typography.titleLarge,
- modifier = Modifier.weight(1f),
- )
- Text(
- text = stringResource(
- if (operating) Res.string.mcp_tool_executing else Res.string.mcp_tools_available,
- ),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- Spacer(Modifier.size(12.dp))
-
- var selectedTab by remember { mutableStateOf(McpDialogTab.Tools) }
- TabRow(selectedTabIndex = selectedTab.ordinal) {
- Tab(
- selected = selectedTab == McpDialogTab.Tools,
- onClick = { selectedTab = McpDialogTab.Tools },
- text = { Text(stringResource(Res.string.mcp_tools_tab_tools)) },
- )
- Tab(
- selected = selectedTab == McpDialogTab.History,
- onClick = { selectedTab = McpDialogTab.History },
- text = {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- ) {
- Text(stringResource(Res.string.mcp_tools_tab_history))
- if (callHistory.isNotEmpty()) {
- // How many calls the history holds, so the count is visible
- // without opening the tab.
- McpToolCallCountBadge(count = callHistory.size, running = false)
- }
- }
- },
- )
- }
- Spacer(Modifier.size(12.dp))
-
- // Hoisted out of the pane so switching tabs and coming back keeps the search and the
- // selected tool where the user left them.
- var query by remember { mutableStateOf("") }
- var selectedName by remember { mutableStateOf(tools.firstOrNull()?.name) }
-
- when (selectedTab) {
- McpDialogTab.Tools -> McpToolsPane(
- tools = tools,
- query = query,
- onQueryChange = { query = it },
- selectedToolName = selectedName,
- onSelectTool = { selectedName = it },
- runningToolName = runningToolName,
- // Counts come from the retained history, so they cover the calls the
- // dialog can actually show rather than all time.
- callCounts = remember(callHistory) { callHistory.groupingBy { it.toolName }.eachCount() },
- modifier = Modifier.weight(1f),
- )
-
- McpDialogTab.History -> McpCallHistoryPane(
- callHistory = callHistory,
- modifier = Modifier.weight(1f),
- )
- }
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.End,
- ) {
- TextButton(onClick = onDismiss) {
- Text(stringResource(Res.string.close))
- }
- }
- }
- }
- }
-}
-
-/**
- * Trailing badge on a tool row: the number of recorded calls, shown quietly. While an agent is
- * running the tool it takes the accent fill and the same rotating ring the drawer item uses, so
- * "being called right now" reads the same way everywhere.
- */
-@Composable
-private fun McpToolCallCountBadge(count: Int, running: Boolean) {
- val shape = RoundedCornerShape(6.dp)
- Box(
- modifier = Modifier
- .clip(shape)
- .background(
- if (running) AiOperatingAccentColor else MaterialTheme.colorScheme.surfaceContainerHighest,
- shape,
- )
- .then(
- if (running) Modifier.aiOperatingBorder(color = AiOperatingAccentColor, width = 2.dp) else Modifier,
- )
- .padding(horizontal = 7.dp, vertical = 2.dp),
- ) {
- Text(
- text = count.toString(),
- style = MaterialTheme.typography.labelMedium,
- fontFamily = FontFamily.Monospace,
- color = if (running) Color.Black else MaterialTheme.colorScheme.onSurface,
- )
- }
-}
-
-/** Two-pane browser over the tools a plugin publishes: search + list on the left, detail right. */
-@Composable
-private fun McpToolsPane(
- tools: ImmutableList,
- query: String,
- onQueryChange: (String) -> Unit,
- selectedToolName: String?,
- onSelectTool: (String) -> Unit,
- runningToolName: String?,
- callCounts: Map,
- modifier: Modifier,
-) {
- val filtered = remember(query, tools) {
- if (query.isBlank()) {
- tools
- } else {
- tools.filter {
- it.name.contains(query, ignoreCase = true) ||
- it.description.contains(query, ignoreCase = true)
- }
- }
- }
- val selected = filtered.firstOrNull { it.name == selectedToolName } ?: filtered.firstOrNull()
-
- Row(modifier = modifier) {
- // Left pane: search + tool list.
- Column(modifier = Modifier.width(300.dp)) {
- OutlinedTextField(
- value = query,
- onValueChange = onQueryChange,
- singleLine = true,
- leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
- placeholder = { Text(stringResource(Res.string.mcp_tools_search)) },
- modifier = Modifier.fillMaxWidth(),
- )
- Spacer(Modifier.size(8.dp))
- LazyColumn(modifier = Modifier.fillMaxHeight()) {
- items(filtered, key = { it.name }) { tool ->
- val isSelected = tool.name == selected?.name
- val isRunning = tool.name == runningToolName
- val callCount = callCounts[tool.name] ?: 0
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(6.dp))
- .background(
- if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
- )
- .clickable { onSelectTool(tool.name) }
- .padding(horizontal = 10.dp, vertical = 8.dp),
- ) {
- Text(
- text = tool.name.substringAfterLast('.'),
- style = MaterialTheme.typography.bodyMedium,
- fontFamily = FontFamily.Monospace,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- color = if (isSelected) {
- MaterialTheme.colorScheme.onSecondaryContainer
- } else {
- MaterialTheme.colorScheme.onSurface
- },
- // Takes the free space so the count and dot sit against the right edge.
- modifier = Modifier.weight(1f),
- )
- if (callCount > 0 || isRunning) {
- McpToolCallCountBadge(count = callCount, running = isRunning)
- }
- }
- }
- }
- }
- VerticalDivider(modifier = Modifier.padding(horizontal = 12.dp))
- // Right pane: the selected tool's detail.
- Column(
- modifier = Modifier
- .weight(1f)
- .fillMaxHeight()
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- if (selected == null) {
- Text(
- text = stringResource(Res.string.mcp_tools_no_match),
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- } else {
- Text(
- text = selected.name.substringAfterLast('.'),
- style = MaterialTheme.typography.titleMedium,
- fontFamily = FontFamily.Monospace,
- )
- Text(
- text = selected.name,
- style = MaterialTheme.typography.labelSmall,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Text(
- text = selected.description,
- style = MaterialTheme.typography.bodyMedium,
- )
- if (selected.parameters.isNotEmpty()) {
- Spacer(Modifier.size(4.dp))
- Text(
- text = stringResource(Res.string.mcp_tools_parameters),
- style = MaterialTheme.typography.labelLarge,
- )
- selected.parameters.forEach { param -> McpParameterRow(param) }
- }
- }
- }
- }
-}
-
-/** What an agent already did with this plugin, newest call first. */
-@Composable
-private fun McpCallHistoryPane(
- callHistory: ImmutableList,
- modifier: Modifier,
-) {
- if (callHistory.isEmpty()) {
- Box(
- modifier = modifier.fillMaxWidth(),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- text = stringResource(Res.string.mcp_history_empty),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- return
- }
- // Selection lives above the lazy list so it survives the row scrolling out of view.
- var selectedCallId by remember { mutableStateOf(callHistory.first().id) }
- val selected = callHistory.firstOrNull { it.id == selectedCallId } ?: callHistory.first()
-
- Row(modifier = modifier) {
- LazyColumn(
- modifier = Modifier.width(300.dp),
- verticalArrangement = Arrangement.spacedBy(2.dp),
- ) {
- items(callHistory, key = { it.id }) { record ->
- McpCallHistoryRow(
- record = record,
- selected = record.id == selected.id,
- onSelect = { selectedCallId = record.id },
- )
- }
- }
- VerticalDivider(modifier = Modifier.padding(horizontal = 12.dp))
- McpCallDetailPane(
- record = selected,
- modifier = Modifier
- .weight(1f)
- .fillMaxHeight(),
- )
- }
-}
-
-/** Right pane: everything recorded about the selected call, with copy actions. */
-@Composable
-private fun McpCallDetailPane(
- record: McpCallRecord,
- modifier: Modifier,
-) {
- val statusLabel = stringResource(
- if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed,
- )
- val finishedAt = formatCallTime(record.finishedAtEpochMillis)
- val renderedArguments = record.arguments.joinToString(separator = "\n") { "${it.name} = ${it.value}" }
- val clipboardManager = LocalClipboardManager.current
-
- Column(
- modifier = modifier.verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- Text(
- text = record.toolName.substringAfterLast('.'),
- style = MaterialTheme.typography.titleMedium,
- fontFamily = FontFamily.Monospace,
- )
- Text(
- text = record.toolName,
- style = MaterialTheme.typography.labelSmall,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- ) {
- Icon(
- imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline,
- contentDescription = null,
- tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error,
- modifier = Modifier.size(16.dp),
- )
- Text(
- text = statusLabel,
- style = MaterialTheme.typography.labelMedium,
- color = if (record.succeeded) {
- MaterialTheme.colorScheme.onSurfaceVariant
- } else {
- MaterialTheme.colorScheme.error
- },
- )
- Text(
- text = finishedAt,
- style = MaterialTheme.typography.labelMedium,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
-
- Spacer(Modifier.size(4.dp))
- Text(
- text = stringResource(Res.string.mcp_tools_parameters),
- style = MaterialTheme.typography.labelLarge,
- )
- if (record.arguments.isEmpty()) {
- Text(
- text = stringResource(Res.string.mcp_history_no_arguments),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- } else {
- record.arguments.forEach { argument ->
- Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
- Text(
- text = argument.name,
- style = MaterialTheme.typography.bodyMedium,
- fontFamily = FontFamily.Monospace,
- )
- Text(
- text = argument.value,
- style = MaterialTheme.typography.bodySmall,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- }
-
- Spacer(Modifier.size(4.dp))
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- TextButton(
- onClick = { clipboardManager.setText(AnnotatedString(record.toolName)) },
- ) {
- Text(stringResource(Res.string.mcp_history_copy_tool_name))
- }
- if (record.arguments.isNotEmpty()) {
- TextButton(
- onClick = { clipboardManager.setText(AnnotatedString(renderedArguments)) },
- ) {
- Text(stringResource(Res.string.mcp_history_copy_arguments))
- }
- }
- TextButton(
- onClick = {
- clipboardManager.setText(
- AnnotatedString(
- buildCallDetails(
- toolName = record.toolName,
- statusLabel = statusLabel,
- finishedAt = finishedAt,
- renderedArguments = renderedArguments,
- ),
- ),
- )
- },
- ) {
- Text(stringResource(Res.string.mcp_history_copy_details))
- }
- }
- }
-}
-
-@Composable
-private fun McpCallHistoryRow(
- record: McpCallRecord,
- selected: Boolean,
- onSelect: () -> Unit,
-) {
- val statusLabel = stringResource(
- if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed,
- )
- val finishedAt = formatCallTime(record.finishedAtEpochMillis)
- val renderedArguments = record.arguments.joinToString(separator = "\n") { "${it.name} = ${it.value}" }
-
- val clipboardManager = LocalClipboardManager.current
- val copyToolNameLabel = stringResource(Res.string.mcp_history_copy_tool_name)
- val copyArgumentsLabel = stringResource(Res.string.mcp_history_copy_arguments)
- val copyDetailsLabel = stringResource(Res.string.mcp_history_copy_details)
-
- ContextMenuArea(
- items = {
- buildList {
- add(
- ContextMenuItem(copyToolNameLabel) {
- clipboardManager.setText(AnnotatedString(record.toolName))
- },
- )
- if (record.arguments.isNotEmpty()) {
- add(
- ContextMenuItem(copyArgumentsLabel) {
- clipboardManager.setText(AnnotatedString(renderedArguments))
- },
- )
- }
- add(
- ContextMenuItem(copyDetailsLabel) {
- clipboardManager.setText(
- AnnotatedString(
- buildCallDetails(
- toolName = record.toolName,
- statusLabel = statusLabel,
- finishedAt = finishedAt,
- renderedArguments = renderedArguments,
- ),
- ),
- )
- },
- )
- }
- },
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(6.dp))
- .background(if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent)
- .clickable(role = Role.Button, onClick = onSelect)
- .padding(horizontal = 10.dp, vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- Icon(
- imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline,
- contentDescription = statusLabel,
- tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error,
- modifier = Modifier.size(16.dp),
- )
- Text(
- text = record.toolName.substringAfterLast('.'),
- style = MaterialTheme.typography.bodyMedium,
- fontFamily = FontFamily.Monospace,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- color = if (selected) {
- MaterialTheme.colorScheme.onSecondaryContainer
- } else {
- MaterialTheme.colorScheme.onSurface
- },
- modifier = Modifier.weight(1f),
- )
- Text(
- text = finishedAt,
- style = MaterialTheme.typography.labelSmall,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
-}
-private fun buildCallDetails(
- toolName: String,
- statusLabel: String,
- finishedAt: String,
- renderedArguments: String,
-): String = buildString {
- appendLine(toolName)
- appendLine(statusLabel)
- append(finishedAt)
- if (renderedArguments.isNotEmpty()) {
- appendLine()
- append(renderedArguments)
- }
-}
-
-/** Wall-clock time of day, which is what the user can line up against their own actions. */
-private const val MCP_TOOLS_DIALOG_WINDOW_FRACTION = 0.8f
-
-private val CallHistoryTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
-
-private fun formatCallTime(epochMillis: Long): String = Instant.ofEpochMilli(epochMillis)
- .atZone(ZoneId.systemDefault())
- .format(CallHistoryTimeFormatter)
-
-@Composable
-private fun McpParameterRow(param: McpToolParameterSummary) {
- Column(
- modifier = Modifier.padding(top = 6.dp),
- verticalArrangement = Arrangement.spacedBy(2.dp),
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- ) {
- Text(
- text = param.name,
- style = MaterialTheme.typography.bodyMedium,
- fontFamily = FontFamily.Monospace,
- )
- if (param.type.isNotEmpty()) {
- Text(
- text = param.type,
- style = MaterialTheme.typography.labelSmall,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- if (param.required) {
- Text(
- text = stringResource(Res.string.mcp_tools_required),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.error,
- )
- }
- }
- if (param.description.isNotEmpty()) {
- Text(
- text = param.description,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
-}
From aae35376d01b9cce9603dc975b2065696b61d192 Mon Sep 17 00:00:00 2001
From: kitakkun <48154936+kitakkun@users.noreply.github.com>
Date: Tue, 28 Jul 2026 22:28:22 +0900
Subject: [PATCH 07/13] feat(host): record and reveal MCP call responses
Call history showed what an agent asked for but not what it got back. The
tool registrar now renders every CallToolResult to text -- text blocks
verbatim, binary blocks (image, audio, resource) named rather than inlined so
history does not fill with base64 -- and hands it to the repository along with
the failure flag. A throwing handler records its message instead, so a failed
call is still explainable.
Responses are truncated at record time like arguments, but against their own
McpCallRecord.MAX_RESPONSE_LENGTH: the response is the payload the user opens
history to read, whereas an argument only has to be recognisable.
The detail pane gains a Response section rendered in monospace inside a
bounded, self-scrolling box, so a long response stays readable without pushing
the copy actions out of the pane. Copy response joins the button row and the
row context menu, and Copy details carries the response too.
---
.../composeResources/values-ja/strings.xml | 3 +
.../main/composeResources/values/strings.xml | 3 +
.../jetwhale/host/drawer/McpToolsDialog.kt | 61 +++++++++++++++++
.../server/DefaultMcpActivityRepository.kt | 4 +-
.../DefaultMcpActivityRepositoryTest.kt | 68 ++++++++++++++++---
.../jetwhale/host/mcp/McpToolRegistrar.kt | 27 +++++++-
.../host/mcp/DefaultMcpServerServiceTest.kt | 42 ++++++++++++
.../host/mcp/FakeMcpActivityRepository.kt | 4 +-
.../jetwhale/host/model/McpActivity.kt | 26 ++++++-
.../host/model/McpActivityRepository.kt | 5 +-
10 files changed, 227 insertions(+), 16 deletions(-)
diff --git a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
index 8feac7ca..aa769822 100644
--- a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
+++ b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
@@ -46,5 +46,8 @@
ツール名をコピー
引数をコピー
詳細をコピー
+ レスポンスをコピー
引数なし
+ レスポンス
+ レスポンスなし
diff --git a/jetwhale-host/app/src/main/composeResources/values/strings.xml b/jetwhale-host/app/src/main/composeResources/values/strings.xml
index 0e641462..f1c2bf4e 100644
--- a/jetwhale-host/app/src/main/composeResources/values/strings.xml
+++ b/jetwhale-host/app/src/main/composeResources/values/strings.xml
@@ -46,5 +46,8 @@
Copy tool name
Copy arguments
Copy details
+ Copy response
No arguments
+ Response
+ No response
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt
index 03845d23..a2f58089 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
@@ -55,10 +56,13 @@ import com.kitakkun.jetwhale.host.Res
import com.kitakkun.jetwhale.host.close
import com.kitakkun.jetwhale.host.mcp_history_copy_arguments
import com.kitakkun.jetwhale.host.mcp_history_copy_details
+import com.kitakkun.jetwhale.host.mcp_history_copy_response
import com.kitakkun.jetwhale.host.mcp_history_copy_tool_name
import com.kitakkun.jetwhale.host.mcp_history_empty
import com.kitakkun.jetwhale.host.mcp_history_failed
import com.kitakkun.jetwhale.host.mcp_history_no_arguments
+import com.kitakkun.jetwhale.host.mcp_history_no_response
+import com.kitakkun.jetwhale.host.mcp_history_response
import com.kitakkun.jetwhale.host.mcp_history_succeeded
import com.kitakkun.jetwhale.host.mcp_tool_executing
import com.kitakkun.jetwhale.host.mcp_tools_available
@@ -469,6 +473,38 @@ private fun McpCallDetailPane(
}
}
+ Spacer(Modifier.size(4.dp))
+ Text(
+ text = stringResource(Res.string.mcp_history_response),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ if (record.response.isEmpty()) {
+ Text(
+ text = stringResource(Res.string.mcp_history_no_response),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ // Bounded and scrolled on its own so a long response stays readable instead of
+ // pushing the copy actions out of the pane.
+ .heightIn(max = 240.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainerHighest)
+ .verticalScroll(rememberScrollState())
+ .padding(8.dp),
+ ) {
+ Text(
+ text = record.response,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
Spacer(Modifier.size(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
@@ -483,6 +519,13 @@ private fun McpCallDetailPane(
Text(stringResource(Res.string.mcp_history_copy_arguments))
}
}
+ if (record.response.isNotEmpty()) {
+ TextButton(
+ onClick = { clipboardManager.setText(AnnotatedString(record.response)) },
+ ) {
+ Text(stringResource(Res.string.mcp_history_copy_response))
+ }
+ }
TextButton(
onClick = {
clipboardManager.setText(
@@ -492,6 +535,7 @@ private fun McpCallDetailPane(
statusLabel = statusLabel,
finishedAt = finishedAt,
renderedArguments = renderedArguments,
+ response = record.response,
),
),
)
@@ -518,6 +562,7 @@ private fun McpCallHistoryRow(
val clipboardManager = LocalClipboardManager.current
val copyToolNameLabel = stringResource(Res.string.mcp_history_copy_tool_name)
val copyArgumentsLabel = stringResource(Res.string.mcp_history_copy_arguments)
+ val copyResponseLabel = stringResource(Res.string.mcp_history_copy_response)
val copyDetailsLabel = stringResource(Res.string.mcp_history_copy_details)
ContextMenuArea(
@@ -535,6 +580,13 @@ private fun McpCallHistoryRow(
},
)
}
+ if (record.response.isNotEmpty()) {
+ add(
+ ContextMenuItem(copyResponseLabel) {
+ clipboardManager.setText(AnnotatedString(record.response))
+ },
+ )
+ }
add(
ContextMenuItem(copyDetailsLabel) {
clipboardManager.setText(
@@ -544,6 +596,7 @@ private fun McpCallHistoryRow(
statusLabel = statusLabel,
finishedAt = finishedAt,
renderedArguments = renderedArguments,
+ response = record.response,
),
),
)
@@ -595,6 +648,7 @@ private fun buildCallDetails(
statusLabel: String,
finishedAt: String,
renderedArguments: String,
+ response: String,
): String = buildString {
appendLine(toolName)
appendLine(statusLabel)
@@ -603,6 +657,13 @@ private fun buildCallDetails(
appendLine()
append(renderedArguments)
}
+ if (response.isNotEmpty()) {
+ // A blank line keeps the response apart from the arguments above it, which are otherwise
+ // laid out the same way.
+ appendLine()
+ appendLine()
+ append(response)
+ }
}
/** Wall-clock time of day, which is what the user can line up against their own actions. */
diff --git a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt
index 1da37880..d7ec4c4a 100644
--- a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt
+++ b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt
@@ -61,9 +61,10 @@ class DefaultMcpActivityRepository : McpActivityRepository {
return invocationId
}
- override fun toolInvocationFinished(invocationId: Long, failed: Boolean) {
+ override fun toolInvocationFinished(invocationId: Long, failed: Boolean, response: String) {
// Sampled once outside the update block, which may re-run under contention.
val finishedAtEpochMillis = System.currentTimeMillis()
+ val truncatedResponse = McpCallRecord.truncateResponse(response)
activityFlow.update { activity ->
val finished = activity.runningInvocations.firstOrNull { it.id == invocationId }
activity.copy(
@@ -82,6 +83,7 @@ class DefaultMcpActivityRepository : McpActivityRepository {
succeeded = !failed,
finishedAtEpochMillis = finishedAtEpochMillis,
arguments = finished.arguments,
+ response = truncatedResponse,
)
(listOf(record) + activity.recentCalls)
.take(McpActivity.MAX_RECENT_CALLS)
diff --git a/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt b/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt
index 662026f4..2ea14ebb 100644
--- a/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt
+++ b/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt
@@ -2,6 +2,7 @@ package com.kitakkun.jetwhale.host.data.server
import com.kitakkun.jetwhale.host.model.McpActivity
import com.kitakkun.jetwhale.host.model.McpCallArgument
+import com.kitakkun.jetwhale.host.model.McpCallRecord
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -19,7 +20,7 @@ class DefaultMcpActivityRepositoryTest {
@Test
fun `a completed call is recorded with its attribution`() {
val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap())
- repository.toolInvocationFinished(id, failed = false)
+ repository.toolInvocationFinished(id, failed = false, response = "")
val record = repository.activityFlow.value.recentCalls.single()
assertEquals("plugin.click", record.toolName)
@@ -38,7 +39,7 @@ class DefaultMcpActivityRepositoryTest {
@Test
fun `a failed call is recorded as unsuccessful`() {
val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap())
- repository.toolInvocationFinished(id, failed = true)
+ repository.toolInvocationFinished(id, failed = true, response = "")
assertFalse(repository.activityFlow.value.recentCalls.single().succeeded)
}
@@ -47,7 +48,7 @@ class DefaultMcpActivityRepositoryTest {
fun `history is ordered newest first`() {
listOf("first", "second", "third").forEach { toolName ->
val id = repository.toolInvocationStarted(toolName, "com.example.plugin", "session-1", emptyMap())
- repository.toolInvocationFinished(id, failed = false)
+ repository.toolInvocationFinished(id, failed = false, response = "")
}
assertEquals(
@@ -61,7 +62,7 @@ class DefaultMcpActivityRepositoryTest {
val overflow = McpActivity.MAX_RECENT_CALLS + 5
repeat(overflow) { index ->
val id = repository.toolInvocationStarted("tool-$index", "com.example.plugin", "session-1", emptyMap())
- repository.toolInvocationFinished(id, failed = false)
+ repository.toolInvocationFinished(id, failed = false, response = "")
}
val recentCalls = repository.activityFlow.value.recentCalls
@@ -72,7 +73,7 @@ class DefaultMcpActivityRepositoryTest {
@Test
fun `finishing an unknown invocation records nothing`() {
- repository.toolInvocationFinished(invocationId = 404L, failed = false)
+ repository.toolInvocationFinished(invocationId = 404L, failed = false, response = "")
assertTrue(repository.activityFlow.value.recentCalls.isEmpty())
}
@@ -85,7 +86,7 @@ class DefaultMcpActivityRepositoryTest {
"session-1",
mapOf("sessionId" to "session-1", "x" to "100"),
)
- repository.toolInvocationFinished(id, failed = false)
+ repository.toolInvocationFinished(id, failed = false, response = "")
val record = repository.activityFlow.value.recentCalls.single()
assertEquals(
@@ -106,7 +107,7 @@ class DefaultMcpActivityRepositoryTest {
"session-1",
mapOf("text" to value),
)
- repository.toolInvocationFinished(id, failed = false)
+ repository.toolInvocationFinished(id, failed = false, response = "")
val recorded = repository.activityFlow.value.recentCalls.single().arguments.single()
assertEquals(
@@ -124,7 +125,7 @@ class DefaultMcpActivityRepositoryTest {
"session-1",
mapOf("text" to value),
)
- repository.toolInvocationFinished(id, failed = false)
+ repository.toolInvocationFinished(id, failed = false, response = "")
assertEquals(value, repository.activityFlow.value.recentCalls.single().arguments.single().value)
}
@@ -132,15 +133,62 @@ class DefaultMcpActivityRepositoryTest {
@Test
fun `a call without arguments records an empty argument list`() {
val id = repository.toolInvocationStarted("plugin.screenshot", "com.example.plugin", "session-1", emptyMap())
- repository.toolInvocationFinished(id, failed = false)
+ repository.toolInvocationFinished(id, failed = false, response = "")
assertTrue(repository.activityFlow.value.recentCalls.single().arguments.isEmpty())
}
+ @Test
+ fun `a completed call is recorded with the response it produced`() {
+ val id = repository.toolInvocationStarted("plugin.screenshot", "com.example.plugin", "session-1", emptyMap())
+ repository.toolInvocationFinished(id, failed = false, response = "")
+
+ assertEquals("", repository.activityFlow.value.recentCalls.single().response)
+ }
+
+ @Test
+ fun `a long response is truncated with an ellipsis`() {
+ val response = "a".repeat(McpCallRecord.MAX_RESPONSE_LENGTH + 20)
+ val id = repository.toolInvocationStarted("plugin.tree", "com.example.plugin", "session-1", emptyMap())
+ repository.toolInvocationFinished(id, failed = false, response = response)
+
+ assertEquals(
+ "a".repeat(McpCallRecord.MAX_RESPONSE_LENGTH) + McpCallArgument.TRUNCATION_MARKER,
+ repository.activityFlow.value.recentCalls.single().response,
+ )
+ }
+
+ @Test
+ fun `a response at the cap is kept whole`() {
+ val response = "a".repeat(McpCallRecord.MAX_RESPONSE_LENGTH)
+ val id = repository.toolInvocationStarted("plugin.tree", "com.example.plugin", "session-1", emptyMap())
+ repository.toolInvocationFinished(id, failed = false, response = response)
+
+ assertEquals(response, repository.activityFlow.value.recentCalls.single().response)
+ }
+
+ @Test
+ fun `a call without a response records an empty response`() {
+ val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap())
+ repository.toolInvocationFinished(id, failed = false, response = "")
+
+ assertEquals("", repository.activityFlow.value.recentCalls.single().response)
+ }
+
+ @Test
+ fun `a failed call is recorded with the failure message as its response`() {
+ val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap())
+ repository.toolInvocationFinished(id, failed = true, response = "boom")
+
+ val record = repository.activityFlow.value.recentCalls.single()
+ assertFalse(record.succeeded)
+ assertEquals("boom", record.response)
+ }
+
@Test
fun `clear drops the recorded history`() {
val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap())
- repository.toolInvocationFinished(id, failed = false)
+ repository.toolInvocationFinished(id, failed = false, response = "")
repository.clear()
diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt
index 6073f3e0..bb3bfe3f 100644
--- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt
+++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt
@@ -5,6 +5,7 @@ import io.modelcontextprotocol.kotlin.sdk.server.ClientConnection
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
+import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
/**
@@ -89,11 +90,33 @@ class McpToolRegistrar(
// A thrown handler is the only failure signal available here, and it has to reach the
// repository before it propagates on to the MCP layer.
var failed = true
+ var response = ""
try {
- handler(request).also { failed = false }
+ handler(request).also {
+ failed = false
+ response = it.renderForHistory()
+ }
+ } catch (throwable: Throwable) {
+ // A failure is only explainable in history if it says what went wrong.
+ response = throwable.message.orEmpty()
+ throw throwable
} finally {
- activityRepository.toolInvocationFinished(invocationId, failed)
+ activityRepository.toolInvocationFinished(invocationId, failed, response)
}
}
}
}
+
+/**
+ * Renders a tool result to the text kept in call history.
+ *
+ * Only text blocks carry something a reader can use; every other block type is a binary payload (a
+ * screenshot, audio, an embedded resource) that is named rather than inlined, so history does not
+ * fill up with base64.
+ */
+private fun CallToolResult.renderForHistory(): String = content.joinToString(separator = "\n") { block ->
+ when (block) {
+ is TextContent -> block.text
+ else -> "<${block.type.value}>"
+ }
+}
diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt
index 6028d16f..0d30b96e 100644
--- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt
+++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt
@@ -19,6 +19,7 @@ import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.sse.SSE
import io.modelcontextprotocol.kotlin.sdk.client.mcpSse
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
+import io.modelcontextprotocol.kotlin.sdk.types.ImageContent
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
import kotlinx.coroutines.TimeoutCancellationException
@@ -440,6 +441,32 @@ class DefaultMcpServerServiceTest {
assertEquals("com.example.plugin", record.pluginId)
assertEquals("session-1", record.sessionId)
assertTrue(record.succeeded)
+ assertEquals("ok", record.response)
+ }
+
+ @Test
+ fun `a non-text response block is recorded as a placeholder instead of its payload`() = runBlocking {
+ val serviceWithTool = DefaultMcpServerService(
+ pluginInstanceService = pluginInstanceService,
+ mcpActivityRepository = mcpActivityRepository,
+ builtInTools = setOf(MediaMcpTool("fake.captured")),
+ )
+ val capturedPort = java.net.ServerSocket(0).use { it.localPort }
+ serviceWithTool.start(host, capturedPort)
+ // Stopping the server clears recorded activity, so the history has to be read while it runs.
+ val record = try {
+ val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$capturedPort/sse")
+ try {
+ client.callTool("fake.captured", emptyMap())
+ } finally {
+ client.close()
+ }
+ mcpActivityRepository.activityFlow.value.recentCalls.single()
+ } finally {
+ serviceWithTool.stop()
+ }
+
+ assertEquals("captured\n", record.response)
}
@Test
@@ -466,6 +493,7 @@ class DefaultMcpServerServiceTest {
assertEquals("fake.failing", record.toolName)
assertFalse(record.succeeded)
+ assertEquals("boom", record.response)
}
@OptIn(ExperimentalJetWhaleApi::class)
@@ -542,6 +570,20 @@ private class FakeMcpTool(
}
}
+/** Returns a text block alongside a binary one, which history must name rather than inline. */
+private class MediaMcpTool(private val name: String) : JetWhaleMcpTool {
+ override fun register(registrar: McpToolRegistrar) {
+ registrar.addTool(name = name, description = "Returns text and an image", inputSchema = ToolSchema()) { _ ->
+ CallToolResult(
+ content = listOf(
+ TextContent("captured"),
+ ImageContent(data = "AAAA", mimeType = "image/png"),
+ ),
+ )
+ }
+ }
+}
+
private class FailingMcpTool(private val name: String) : JetWhaleMcpTool {
override fun register(registrar: McpToolRegistrar) {
registrar.addTool(name = name, description = "Always throws", inputSchema = ToolSchema()) { _ ->
diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt
index f2192082..d1a92ddb 100644
--- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt
+++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt
@@ -61,8 +61,9 @@ class FakeMcpActivityRepository : McpActivityRepository {
return invocation.id
}
- override fun toolInvocationFinished(invocationId: Long, failed: Boolean) {
+ override fun toolInvocationFinished(invocationId: Long, failed: Boolean, response: String) {
val finishedAtEpochMillis = System.currentTimeMillis()
+ val truncatedResponse = McpCallRecord.truncateResponse(response)
activityFlow.update { activity ->
val finished = activity.runningInvocations.firstOrNull { it.id == invocationId }
activity.copy(
@@ -80,6 +81,7 @@ class FakeMcpActivityRepository : McpActivityRepository {
succeeded = !failed,
finishedAtEpochMillis = finishedAtEpochMillis,
arguments = finished.arguments,
+ response = truncatedResponse,
)
(listOf(record) + activity.recentCalls)
.take(McpActivity.MAX_RECENT_CALLS)
diff --git a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt
index 8aa86327..9a5f7b2c 100644
--- a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt
+++ b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt
@@ -63,7 +63,31 @@ data class McpCallRecord(
val finishedAtEpochMillis: Long,
/** Every argument the call was made with, including `pluginId` and `sessionId`. */
val arguments: ImmutableList,
-)
+ /**
+ * What the call produced, rendered to a string and shortened to [MAX_RESPONSE_LENGTH]. Carries
+ * the failure message when the call failed, and is empty when there is nothing to show.
+ */
+ val response: String,
+) {
+ companion object {
+ /**
+ * How many characters of a [response] are kept. Far more generous than
+ * [McpCallArgument.MAX_VALUE_LENGTH]: the response is the payload the user opens history to
+ * read, whereas an argument only has to be recognisable.
+ */
+ const val MAX_RESPONSE_LENGTH = 2000
+
+ /**
+ * Shortens a response to [MAX_RESPONSE_LENGTH] characters, marking the cut the same way
+ * argument values are marked.
+ */
+ fun truncateResponse(response: String): String = if (response.length <= MAX_RESPONSE_LENGTH) {
+ response
+ } else {
+ response.take(MAX_RESPONSE_LENGTH) + McpCallArgument.TRUNCATION_MARKER
+ }
+ }
+}
/**
* Live view of what AI agents are doing through the MCP server, so the UI can tell the user when
diff --git a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt
index a0eae452..fc77fab2 100644
--- a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt
+++ b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt
@@ -35,8 +35,11 @@ interface McpActivityRepository {
* Records the completion of a tool call and appends it to the recent-call history.
*
* @param failed true when the handler threw instead of returning a result.
+ * @param response what the call produced, already rendered to a string: the tool's result, or
+ * the failure message when it threw. Shortened to [McpCallRecord.MAX_RESPONSE_LENGTH] here. Pass
+ * an empty string when the call produced nothing to show.
*/
- fun toolInvocationFinished(invocationId: Long, failed: Boolean)
+ fun toolInvocationFinished(invocationId: Long, failed: Boolean, response: String)
/** Drops all recorded activity, so a server restart does not inherit stale connection counts. */
fun clear()
From debd75b1f847c12e3c5a2cc0e8ba48358f02b4db Mon Sep 17 00:00:00 2001
From: kitakkun <48154936+kitakkun@users.noreply.github.com>
Date: Tue, 28 Jul 2026 22:32:12 +0900
Subject: [PATCH 08/13] feat(host): add the nav key and screen context for an
MCP tools screen
Groundwork for opening the MCP browser as a screen rather than a dialog
scoped to one plugin: a nav key whose null plugin/session mean "all", and
a screen context that subscribes to the tools, activity, sessions, and
plugin metadata itself.
---
.../jetwhale/host/di/JetWhaleAppGraph.kt | 2 ++
.../host/drawer/McpToolsScreenContext.kt | 20 +++++++++++++++++++
.../host/navigation/JetWhaleNavKeys.kt | 11 ++++++++++
3 files changed, 33 insertions(+)
create mode 100644 jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenContext.kt
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/di/JetWhaleAppGraph.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/di/JetWhaleAppGraph.kt
index 6f1abd96..773e39f0 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/di/JetWhaleAppGraph.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/di/JetWhaleAppGraph.kt
@@ -3,6 +3,7 @@ package com.kitakkun.jetwhale.host.di
import com.kitakkun.jetwhale.host.ApplicationLifecycleOwner
import com.kitakkun.jetwhale.host.BuildConfig
import com.kitakkun.jetwhale.host.architecture.ScreenContext
+import com.kitakkun.jetwhale.host.drawer.McpToolsScreenContext
import com.kitakkun.jetwhale.host.drawer.ToolingScaffoldScreenContext
import com.kitakkun.jetwhale.host.mcp.McpServerService
import com.kitakkun.jetwhale.host.model.AppearanceSettingsSubscriptionKey
@@ -38,6 +39,7 @@ interface JetWhaleAppGraph : ScreenContext {
val swrClient: SwrClientPlus
val toolingScaffoldScreenContext: ToolingScaffoldScreenContext
+ val mcpToolsScreenContext: McpToolsScreenContext
val licensesScreenContext: LicensesScreenContext
val settingsScreenContext: SettingsScreenContext
val pluginScreenContextFactory: PluginScreenContext.Factory
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenContext.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenContext.kt
new file mode 100644
index 00000000..95828ba7
--- /dev/null
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenContext.kt
@@ -0,0 +1,20 @@
+package com.kitakkun.jetwhale.host.drawer
+
+import com.kitakkun.jetwhale.host.architecture.ScreenContext
+import com.kitakkun.jetwhale.host.model.DebugSessionsSubscriptionKey
+import com.kitakkun.jetwhale.host.model.LoadedPluginsMetaDataSubscriptionKey
+import com.kitakkun.jetwhale.host.model.McpActivitySubscriptionKey
+import com.kitakkun.jetwhale.host.model.McpCapablePluginsSubscriptionKey
+import dev.zacsweers.metro.Inject
+
+/**
+ * The MCP tools browser subscribes to the whole picture itself rather than being handed one
+ * plugin's slice, so it can show every session and plugin and let the user narrow it down.
+ */
+@Inject
+class McpToolsScreenContext(
+ val mcpCapablePluginsSubscriptionKey: McpCapablePluginsSubscriptionKey,
+ val mcpActivitySubscriptionKey: McpActivitySubscriptionKey,
+ val debugSessionsSubscriptionKey: DebugSessionsSubscriptionKey,
+ val loadedPluginsMetaDataSubscriptionKey: LoadedPluginsMetaDataSubscriptionKey,
+) : ScreenContext
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavKeys.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavKeys.kt
index 272d34c2..38ab0995 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavKeys.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavKeys.kt
@@ -36,3 +36,14 @@ data object DisabledPluginNavKey : NavKey
@Serializable
data object LogViewerNavKey : NavKey
+
+/**
+ * The MCP tools browser. [pluginId] and [sessionId] seed the screen's filters — null means
+ * "all", so opening it from a plugin's badge lands on that plugin while the screen itself can
+ * widen the view afterwards.
+ */
+@Serializable
+data class McpToolsNavKey(
+ val pluginId: String?,
+ val sessionId: String?,
+) : NavKey
From 8580795fe39fdc4ed39935b10045277fddea054e Mon Sep 17 00:00:00 2001
From: kitakkun <48154936+kitakkun@users.noreply.github.com>
Date: Tue, 28 Jul 2026 23:08:14 +0900
Subject: [PATCH 09/13] feat(host): browse MCP tools in a screen with plugin
and session filters
The MCP browser was a dialog handed one plugin's slice of tools and history,
so it could not answer "when was this tool last called?" across plugins.
Make it a window-backed screen that subscribes to the MCP, session and plugin
data itself through `McpToolsScreenContext`, and narrow it with two filters
seeded from `McpToolsNavKey`: a null plugin or session id means "All", and both
can be changed once the screen is open.
Tool rows now name the plugin that publishes them, since the short name alone is
ambiguous once plugins are mixed. History keeps calls that named no session
visible under a specific session too, because those tools target none.
The per-section copy actions move inline next to the heading they copy, leaving
"Copy details" as the pane's only labelled button.
---
.../composeResources/values-ja/strings.xml | 5 +
.../main/composeResources/values/strings.xml | 5 +
.../{McpToolsDialog.kt => McpToolsScreen.kt} | 477 ++++++++++--------
.../host/drawer/McpToolsScreenRoot.kt | 160 ++++++
.../host/navigation/JetWhaleNavDisplay.kt | 1 +
.../host/navigation/NavBackStackExtensions.kt | 14 +
.../jetwhale/host/navigation/NavEntries.kt | 32 ++
7 files changed, 496 insertions(+), 198 deletions(-)
rename jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/{McpToolsDialog.kt => McpToolsScreen.kt} (63%)
create mode 100644 jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt
diff --git a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
index aa769822..699f4b4d 100644
--- a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
+++ b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
@@ -50,4 +50,9 @@
引数なし
レスポンス
レスポンスなし
+ MCP Tools
+ MCP tools を閲覧
+ プラグイン
+ セッション
+ すべて
diff --git a/jetwhale-host/app/src/main/composeResources/values/strings.xml b/jetwhale-host/app/src/main/composeResources/values/strings.xml
index f1c2bf4e..7a77507d 100644
--- a/jetwhale-host/app/src/main/composeResources/values/strings.xml
+++ b/jetwhale-host/app/src/main/composeResources/values/strings.xml
@@ -50,4 +50,9 @@
No arguments
Response
No response
+ MCP Tools
+ Browse MCP tools
+ Plugin
+ Session
+ All
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt
similarity index 63%
rename from jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt
rename to jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt
index a2f58089..03a2b8d4 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDialog.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt
@@ -15,8 +15,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
@@ -24,12 +24,16 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
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.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
@@ -50,10 +54,7 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.Dialog
-import androidx.compose.ui.window.DialogProperties
import com.kitakkun.jetwhale.host.Res
-import com.kitakkun.jetwhale.host.close
import com.kitakkun.jetwhale.host.mcp_history_copy_arguments
import com.kitakkun.jetwhale.host.mcp_history_copy_details
import com.kitakkun.jetwhale.host.mcp_history_copy_response
@@ -66,6 +67,9 @@ import com.kitakkun.jetwhale.host.mcp_history_response
import com.kitakkun.jetwhale.host.mcp_history_succeeded
import com.kitakkun.jetwhale.host.mcp_tool_executing
import com.kitakkun.jetwhale.host.mcp_tools_available
+import com.kitakkun.jetwhale.host.mcp_tools_filter_all
+import com.kitakkun.jetwhale.host.mcp_tools_filter_plugin
+import com.kitakkun.jetwhale.host.mcp_tools_filter_session
import com.kitakkun.jetwhale.host.mcp_tools_no_match
import com.kitakkun.jetwhale.host.mcp_tools_parameters
import com.kitakkun.jetwhale.host.mcp_tools_required
@@ -74,124 +78,158 @@ import com.kitakkun.jetwhale.host.mcp_tools_tab_history
import com.kitakkun.jetwhale.host.mcp_tools_tab_tools
import com.kitakkun.jetwhale.host.model.McpCallRecord
import com.kitakkun.jetwhale.host.model.McpToolParameterSummary
-import com.kitakkun.jetwhale.host.model.McpToolSummary
import kotlinx.collections.immutable.ImmutableList
import org.jetbrains.compose.resources.stringResource
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
-/** The panes the MCP dialog can show: the tools a plugin publishes, or the calls already made. */
-internal enum class McpDialogTab {
+/** Everything the MCP tools browser draws, already narrowed to the selected plugin and session. */
+data class McpToolsScreenUiState(
+ val pluginOptions: ImmutableList,
+ val sessionOptions: ImmutableList,
+ val selectedPluginId: String?,
+ val selectedSessionId: String?,
+ val toolRows: ImmutableList,
+ val callHistory: ImmutableList,
+ val runningToolName: String?,
+)
+
+/** The panes the MCP browser can show: the tools plugins publish, or the calls already made. */
+internal enum class McpToolsTab {
Tools,
History,
}
@Composable
-internal fun McpToolsDialog(
- operating: Boolean,
- pluginName: String,
- tools: ImmutableList,
- callHistory: ImmutableList,
- runningToolName: String?,
- onDismiss: () -> Unit,
+fun McpToolsScreen(
+ uiState: McpToolsScreenUiState,
+ onSelectPluginFilter: (String?) -> Unit,
+ onSelectSessionFilter: (String?) -> Unit,
) {
- Dialog(
- onDismissRequest = onDismiss,
- // The platform default constrains the dialog to its content's size; tool descriptions and
- // history need the room to grow with the window instead.
- properties = DialogProperties(usePlatformDefaultWidth = false),
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(20.dp),
) {
- Surface(shape = MaterialTheme.shapes.large) {
- Column(
- modifier = Modifier
- // Take most of the window so long descriptions and history are readable, but
- // stop growing past a comfortable reading width on a large display.
- .fillMaxSize(MCP_TOOLS_DIALOG_WINDOW_FRACTION)
- .sizeIn(
- minWidth = 640.dp,
- minHeight = 440.dp,
- maxWidth = 1200.dp,
- maxHeight = 860.dp,
- )
- .padding(20.dp),
- ) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(
- text = pluginName,
- style = MaterialTheme.typography.titleLarge,
- modifier = Modifier.weight(1f),
- )
- Text(
- text = stringResource(
- if (operating) Res.string.mcp_tool_executing else Res.string.mcp_tools_available,
- ),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- Spacer(Modifier.size(12.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ McpFilterDropdown(
+ label = stringResource(Res.string.mcp_tools_filter_plugin),
+ options = uiState.pluginOptions,
+ selectedId = uiState.selectedPluginId,
+ onSelect = onSelectPluginFilter,
+ )
+ McpFilterDropdown(
+ label = stringResource(Res.string.mcp_tools_filter_session),
+ options = uiState.sessionOptions,
+ selectedId = uiState.selectedSessionId,
+ onSelect = onSelectSessionFilter,
+ )
+ Spacer(Modifier.weight(1f))
+ Text(
+ text = stringResource(
+ if (uiState.runningToolName != null) Res.string.mcp_tool_executing else Res.string.mcp_tools_available,
+ ),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Spacer(Modifier.size(12.dp))
- var selectedTab by remember { mutableStateOf(McpDialogTab.Tools) }
- TabRow(selectedTabIndex = selectedTab.ordinal) {
- Tab(
- selected = selectedTab == McpDialogTab.Tools,
- onClick = { selectedTab = McpDialogTab.Tools },
- text = { Text(stringResource(Res.string.mcp_tools_tab_tools)) },
- )
- Tab(
- selected = selectedTab == McpDialogTab.History,
- onClick = { selectedTab = McpDialogTab.History },
- text = {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- ) {
- Text(stringResource(Res.string.mcp_tools_tab_history))
- if (callHistory.isNotEmpty()) {
- // How many calls the history holds, so the count is visible
- // without opening the tab.
- McpToolCallCountBadge(count = callHistory.size, running = false)
- }
- }
- },
- )
- }
- Spacer(Modifier.size(12.dp))
+ var selectedTab by remember { mutableStateOf(McpToolsTab.Tools) }
+ TabRow(selectedTabIndex = selectedTab.ordinal) {
+ Tab(
+ selected = selectedTab == McpToolsTab.Tools,
+ onClick = { selectedTab = McpToolsTab.Tools },
+ text = { Text(stringResource(Res.string.mcp_tools_tab_tools)) },
+ )
+ Tab(
+ selected = selectedTab == McpToolsTab.History,
+ onClick = { selectedTab = McpToolsTab.History },
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text(stringResource(Res.string.mcp_tools_tab_history))
+ if (uiState.callHistory.isNotEmpty()) {
+ // How many calls the current scope holds, so the count is visible
+ // without opening the tab.
+ McpToolCallCountBadge(count = uiState.callHistory.size, running = false)
+ }
+ }
+ },
+ )
+ }
+ Spacer(Modifier.size(12.dp))
- // Hoisted out of the pane so switching tabs and coming back keeps the search and the
- // selected tool where the user left them.
- var query by remember { mutableStateOf("") }
- var selectedName by remember { mutableStateOf(tools.firstOrNull()?.name) }
+ // Hoisted out of the pane so switching tabs and coming back keeps the search and the
+ // selected tool where the user left them.
+ var query by remember { mutableStateOf("") }
+ var selectedToolKey by remember { mutableStateOf(null) }
- when (selectedTab) {
- McpDialogTab.Tools -> McpToolsPane(
- tools = tools,
- query = query,
- onQueryChange = { query = it },
- selectedToolName = selectedName,
- onSelectTool = { selectedName = it },
- runningToolName = runningToolName,
- // Counts come from the retained history, so they cover the calls the
- // dialog can actually show rather than all time.
- callCounts = remember(callHistory) { callHistory.groupingBy { it.toolName }.eachCount() },
- modifier = Modifier.weight(1f),
- )
+ when (selectedTab) {
+ McpToolsTab.Tools -> McpToolsPane(
+ toolRows = uiState.toolRows,
+ query = query,
+ onQueryChange = { query = it },
+ selectedToolKey = selectedToolKey,
+ onSelectTool = { selectedToolKey = it },
+ modifier = Modifier.weight(1f),
+ )
- McpDialogTab.History -> McpCallHistoryPane(
- callHistory = callHistory,
- modifier = Modifier.weight(1f),
- )
- }
+ McpToolsTab.History -> McpCallHistoryPane(
+ callHistory = uiState.callHistory,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+}
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.End,
- ) {
- TextButton(onClick = onDismiss) {
- Text(stringResource(Res.string.close))
- }
- }
+/** "All" plus one entry per option, so the user can widen a filter the nav key seeded. */
+@Composable
+private fun McpFilterDropdown(
+ label: String,
+ options: ImmutableList,
+ selectedId: String?,
+ onSelect: (String?) -> Unit,
+) {
+ var expanded by remember { mutableStateOf(false) }
+ val allLabel = stringResource(Res.string.mcp_tools_filter_all)
+ val selectedLabel = options.firstOrNull { it.id == selectedId }?.label ?: allLabel
+
+ Box {
+ OutlinedButton(onClick = { expanded = true }) {
+ Text(
+ text = "$label: $selectedLabel",
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.widthIn(max = 260.dp),
+ )
+ }
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ ) {
+ DropdownMenuItem(
+ text = { Text(allLabel) },
+ onClick = {
+ onSelect(null)
+ expanded = false
+ },
+ )
+ options.forEach { option ->
+ DropdownMenuItem(
+ text = { Text(option.label) },
+ onClick = {
+ onSelect(option.id)
+ expanded = false
+ },
+ )
}
}
}
@@ -226,33 +264,32 @@ internal fun McpToolCallCountBadge(count: Int, running: Boolean) {
}
}
-/** Two-pane browser over the tools a plugin publishes: search + list on the left, detail right. */
+/** Two-pane browser over the tools in scope: search + list on the left, detail right. */
@Composable
private fun McpToolsPane(
- tools: ImmutableList,
+ toolRows: ImmutableList,
query: String,
onQueryChange: (String) -> Unit,
- selectedToolName: String?,
+ selectedToolKey: String?,
onSelectTool: (String) -> Unit,
- runningToolName: String?,
- callCounts: Map,
modifier: Modifier,
) {
- val filtered = remember(query, tools) {
+ val filtered = remember(query, toolRows) {
if (query.isBlank()) {
- tools
+ toolRows
} else {
- tools.filter {
- it.name.contains(query, ignoreCase = true) ||
- it.description.contains(query, ignoreCase = true)
+ toolRows.filter {
+ it.tool.name.contains(query, ignoreCase = true) ||
+ it.tool.description.contains(query, ignoreCase = true) ||
+ it.pluginName.contains(query, ignoreCase = true)
}
}
}
- val selected = filtered.firstOrNull { it.name == selectedToolName } ?: filtered.firstOrNull()
+ val selected = filtered.firstOrNull { it.key == selectedToolKey } ?: filtered.firstOrNull()
Row(modifier = modifier) {
// Left pane: search + tool list.
- Column(modifier = Modifier.width(300.dp)) {
+ Column(modifier = Modifier.width(320.dp)) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
@@ -263,10 +300,8 @@ private fun McpToolsPane(
)
Spacer(Modifier.size(8.dp))
LazyColumn(modifier = Modifier.fillMaxHeight()) {
- items(filtered, key = { it.name }) { tool ->
- val isSelected = tool.name == selected?.name
- val isRunning = tool.name == runningToolName
- val callCount = callCounts[tool.name] ?: 0
+ items(filtered, key = { it.key }) { row ->
+ val isSelected = row.key == selected?.key
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
@@ -276,25 +311,35 @@ private fun McpToolsPane(
.background(
if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
)
- .clickable { onSelectTool(tool.name) }
+ .clickable { onSelectTool(row.key) }
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
- Text(
- text = tool.name.substringAfterLast('.'),
- style = MaterialTheme.typography.bodyMedium,
- fontFamily = FontFamily.Monospace,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- color = if (isSelected) {
- MaterialTheme.colorScheme.onSecondaryContainer
- } else {
- MaterialTheme.colorScheme.onSurface
- },
- // Takes the free space so the count and dot sit against the right edge.
- modifier = Modifier.weight(1f),
- )
- if (callCount > 0 || isRunning) {
- McpToolCallCountBadge(count = callCount, running = isRunning)
+ // Takes the free space so the count sits against the right edge.
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = row.tool.name.substringAfterLast('.'),
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = if (isSelected) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ )
+ // The short name alone is ambiguous once plugins are mixed, so every row
+ // names the plugin that publishes the tool.
+ Text(
+ text = row.pluginName,
+ style = MaterialTheme.typography.labelSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (row.callCount > 0 || row.running) {
+ McpToolCallCountBadge(count = row.callCount, running = row.running)
}
}
}
@@ -316,34 +361,39 @@ private fun McpToolsPane(
)
} else {
Text(
- text = selected.name.substringAfterLast('.'),
+ text = selected.tool.name.substringAfterLast('.'),
style = MaterialTheme.typography.titleMedium,
fontFamily = FontFamily.Monospace,
)
Text(
- text = selected.name,
+ text = selected.pluginName,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = selected.tool.name,
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
- text = selected.description,
+ text = selected.tool.description,
style = MaterialTheme.typography.bodyMedium,
)
- if (selected.parameters.isNotEmpty()) {
+ if (selected.tool.parameters.isNotEmpty()) {
Spacer(Modifier.size(4.dp))
Text(
text = stringResource(Res.string.mcp_tools_parameters),
style = MaterialTheme.typography.labelLarge,
)
- selected.parameters.forEach { param -> McpParameterRow(param) }
+ selected.tool.parameters.forEach { param -> McpParameterRow(param) }
}
}
}
}
}
-/** What an agent already did with this plugin, newest call first. */
+/** What agents already did in the selected scope, newest call first. */
@Composable
private fun McpCallHistoryPane(
callHistory: ImmutableList,
@@ -368,7 +418,7 @@ private fun McpCallHistoryPane(
Row(modifier = modifier) {
LazyColumn(
- modifier = Modifier.width(300.dp),
+ modifier = Modifier.width(320.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
items(callHistory, key = { it.id }) { record ->
@@ -389,7 +439,10 @@ private fun McpCallHistoryPane(
}
}
-/** Right pane: everything recorded about the selected call, with copy actions. */
+/**
+ * Right pane: everything recorded about the selected call. Each section carries its own inline copy
+ * icon; the one action that takes the whole record stays a labelled button.
+ */
@Composable
private fun McpCallDetailPane(
record: McpCallRecord,
@@ -406,11 +459,20 @@ private fun McpCallDetailPane(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
- Text(
- text = record.toolName.substringAfterLast('.'),
- style = MaterialTheme.typography.titleMedium,
- fontFamily = FontFamily.Monospace,
- )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = record.toolName.substringAfterLast('.'),
+ style = MaterialTheme.typography.titleMedium,
+ fontFamily = FontFamily.Monospace,
+ )
+ McpCopyIconButton(
+ contentDescription = stringResource(Res.string.mcp_history_copy_tool_name),
+ onClick = { clipboardManager.setText(AnnotatedString(record.toolName)) },
+ )
+ }
Text(
text = record.toolName,
style = MaterialTheme.typography.labelSmall,
@@ -445,10 +507,21 @@ private fun McpCallDetailPane(
}
Spacer(Modifier.size(4.dp))
- Text(
- text = stringResource(Res.string.mcp_tools_parameters),
- style = MaterialTheme.typography.labelLarge,
- )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = stringResource(Res.string.mcp_tools_parameters),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ if (record.arguments.isNotEmpty()) {
+ McpCopyIconButton(
+ contentDescription = stringResource(Res.string.mcp_history_copy_arguments),
+ onClick = { clipboardManager.setText(AnnotatedString(renderedArguments)) },
+ )
+ }
+ }
if (record.arguments.isEmpty()) {
Text(
text = stringResource(Res.string.mcp_history_no_arguments),
@@ -474,10 +547,21 @@ private fun McpCallDetailPane(
}
Spacer(Modifier.size(4.dp))
- Text(
- text = stringResource(Res.string.mcp_history_response),
- style = MaterialTheme.typography.labelLarge,
- )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = stringResource(Res.string.mcp_history_response),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ if (record.response.isNotEmpty()) {
+ McpCopyIconButton(
+ contentDescription = stringResource(Res.string.mcp_history_copy_response),
+ onClick = { clipboardManager.setText(AnnotatedString(record.response)) },
+ )
+ }
+ }
if (record.response.isEmpty()) {
Text(
text = stringResource(Res.string.mcp_history_no_response),
@@ -489,7 +573,7 @@ private fun McpCallDetailPane(
modifier = Modifier
.fillMaxWidth()
// Bounded and scrolled on its own so a long response stays readable instead of
- // pushing the copy actions out of the pane.
+ // pushing the copy action out of the pane.
.heightIn(max = 240.dp)
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
@@ -506,47 +590,45 @@ private fun McpCallDetailPane(
}
Spacer(Modifier.size(4.dp))
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- TextButton(
- onClick = { clipboardManager.setText(AnnotatedString(record.toolName)) },
- ) {
- Text(stringResource(Res.string.mcp_history_copy_tool_name))
- }
- if (record.arguments.isNotEmpty()) {
- TextButton(
- onClick = { clipboardManager.setText(AnnotatedString(renderedArguments)) },
- ) {
- Text(stringResource(Res.string.mcp_history_copy_arguments))
- }
- }
- if (record.response.isNotEmpty()) {
- TextButton(
- onClick = { clipboardManager.setText(AnnotatedString(record.response)) },
- ) {
- Text(stringResource(Res.string.mcp_history_copy_response))
- }
- }
- TextButton(
- onClick = {
- clipboardManager.setText(
- AnnotatedString(
- buildCallDetails(
- toolName = record.toolName,
- statusLabel = statusLabel,
- finishedAt = finishedAt,
- renderedArguments = renderedArguments,
- response = record.response,
- ),
+ TextButton(
+ onClick = {
+ clipboardManager.setText(
+ AnnotatedString(
+ buildCallDetails(
+ toolName = record.toolName,
+ statusLabel = statusLabel,
+ finishedAt = finishedAt,
+ renderedArguments = renderedArguments,
+ response = record.response,
),
- )
- },
- ) {
- Text(stringResource(Res.string.mcp_history_copy_details))
- }
+ ),
+ )
+ },
+ ) {
+ Text(stringResource(Res.string.mcp_history_copy_details))
}
}
}
+/** Copy affordance that sits beside a heading without competing with it for attention. */
+@Composable
+private fun McpCopyIconButton(
+ contentDescription: String,
+ onClick: () -> Unit,
+) {
+ IconButton(
+ onClick = onClick,
+ modifier = Modifier.size(24.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.ContentCopy,
+ contentDescription = contentDescription,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(16.dp),
+ )
+ }
+}
+
@Composable
private fun McpCallHistoryRow(
record: McpCallRecord,
@@ -643,6 +725,7 @@ private fun McpCallHistoryRow(
}
}
}
+
private fun buildCallDetails(
toolName: String,
statusLabel: String,
@@ -667,8 +750,6 @@ private fun buildCallDetails(
}
/** Wall-clock time of day, which is what the user can line up against their own actions. */
-private const val MCP_TOOLS_DIALOG_WINDOW_FRACTION = 0.8f
-
private val CallHistoryTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
private fun formatCallTime(epochMillis: Long): String = Instant.ofEpochMilli(epochMillis)
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt
new file mode 100644
index 00000000..473c1b76
--- /dev/null
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt
@@ -0,0 +1,160 @@
+package com.kitakkun.jetwhale.host.drawer
+
+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
+import androidx.compose.runtime.retain.retain
+import androidx.compose.runtime.setValue
+import com.kitakkun.jetwhale.host.architecture.SoilDataBoundary
+import com.kitakkun.jetwhale.host.model.DebugSession
+import com.kitakkun.jetwhale.host.model.McpActivity
+import com.kitakkun.jetwhale.host.model.McpCapablePlugins
+import com.kitakkun.jetwhale.host.model.McpToolSummary
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.delay
+import soil.query.compose.rememberSubscription
+import kotlin.time.Duration.Companion.milliseconds
+
+/** How long a finished MCP tool call keeps reading as "running" on the screen. */
+private val MCP_TOOLS_RUNNING_LINGER = 1500.milliseconds
+
+/** One tool row, carrying the plugin that publishes it because the list mixes plugins. */
+data class McpToolRowUiState(
+ val pluginId: String,
+ val pluginName: String,
+ val tool: McpToolSummary,
+ val callCount: Int,
+ val running: Boolean,
+) {
+ /** Unique across plugins publishing a tool of the same name. */
+ val key: String get() = "$pluginId/${tool.name}"
+}
+
+/** An entry of a filter dropdown. A null [id] is the "All" entry. */
+data class McpFilterOption(
+ val id: String?,
+ val label: String,
+)
+
+@Composable
+context(screenContext: McpToolsScreenContext)
+fun McpToolsScreenRoot(
+ initialPluginId: String?,
+ initialSessionId: String?,
+) {
+ SoilDataBoundary(
+ state1 = rememberSubscription(screenContext.loadedPluginsMetaDataSubscriptionKey),
+ state2 = rememberSubscription(screenContext.debugSessionsSubscriptionKey),
+ state3 = rememberSubscription(screenContext.mcpActivitySubscriptionKey),
+ state4 = rememberSubscription(screenContext.mcpCapablePluginsSubscriptionKey),
+ ) { loadedPlugins, debugSessions, mcpActivity, mcpCapablePlugins ->
+ var selectedPluginId by retain { mutableStateOf(initialPluginId) }
+ var selectedSessionId by retain { mutableStateOf(initialSessionId) }
+
+ // A call can start and finish between two frames, so watching the running list drops fast
+ // calls entirely. Latch on the monotonic counter and hold briefly instead, matching how the
+ // drawer decides a plugin is under AI control.
+ var running by remember { mutableStateOf(false) }
+ LaunchedEffect(mcpActivity.startedCount) {
+ if (mcpActivity.startedCount > 0L) {
+ running = true
+ delay(MCP_TOOLS_RUNNING_LINGER)
+ running = false
+ }
+ }
+ val runningInvocation = mcpActivity.lastStartedInvocation?.takeIf { running }
+
+ val pluginNamesById = remember(loadedPlugins) { loadedPlugins.associate { it.id to it.name } }
+
+ McpToolsScreen(
+ uiState = rememberMcpToolsUiState(
+ mcpCapablePlugins = mcpCapablePlugins,
+ mcpActivity = mcpActivity,
+ debugSessions = debugSessions,
+ pluginNamesById = pluginNamesById,
+ selectedPluginId = selectedPluginId,
+ selectedSessionId = selectedSessionId,
+ runningPluginId = runningInvocation?.pluginId,
+ runningToolName = runningInvocation?.toolName,
+ ),
+ onSelectPluginFilter = { selectedPluginId = it },
+ onSelectSessionFilter = { selectedSessionId = it },
+ )
+ }
+}
+
+@Composable
+private fun rememberMcpToolsUiState(
+ mcpCapablePlugins: McpCapablePlugins,
+ mcpActivity: McpActivity,
+ debugSessions: ImmutableList,
+ pluginNamesById: Map,
+ selectedPluginId: String?,
+ selectedSessionId: String?,
+ runningPluginId: String?,
+ runningToolName: String?,
+): McpToolsScreenUiState {
+ val sessionOptions = remember(debugSessions) {
+ debugSessions
+ .map { McpFilterOption(id = it.id, label = "${it.deviceDisplayName} · ${it.appDisplayName}") }
+ .toImmutableList()
+ }
+
+ // Built from every session so the list of plugins does not shift under the user when they narrow
+ // the session filter, which would make their own plugin selection disappear.
+ val pluginOptions = remember(mcpCapablePlugins, mcpActivity.recentCalls, pluginNamesById) {
+ val publishingIds = mcpCapablePlugins.toolsBySessionAndPlugin.values.flatMap { it.keys }
+ val calledIds = mcpActivity.recentCalls.mapNotNull { it.pluginId }
+ (publishingIds + calledIds)
+ .distinct()
+ .map { McpFilterOption(id = it, label = pluginNamesById[it] ?: it) }
+ .sortedBy { it.label }
+ .toImmutableList()
+ }
+
+ // Calls that named no session came from a tool that targets none, so they stay visible under a
+ // specific session too; hiding them would make a session look quieter than it was.
+ val callHistory = remember(mcpActivity.recentCalls, selectedPluginId, selectedSessionId) {
+ mcpActivity.recentCalls
+ .filter { selectedPluginId == null || it.pluginId == selectedPluginId }
+ .filter { selectedSessionId == null || it.sessionId == null || it.sessionId == selectedSessionId }
+ .toImmutableList()
+ }
+
+ val toolRows = remember(mcpCapablePlugins, callHistory, pluginNamesById, selectedPluginId, selectedSessionId, runningPluginId, runningToolName) {
+ // The same plugin publishes the same tools in every session it is active in, so the scope can
+ // yield duplicates that carry no extra information.
+ val callCounts = callHistory.groupingBy { it.pluginId to it.toolName }.eachCount()
+ mcpCapablePlugins.toolsBySessionAndPlugin
+ .filterKeys { selectedSessionId == null || it == selectedSessionId }
+ .values
+ .flatMap { toolsByPlugin -> toolsByPlugin.entries }
+ .filter { selectedPluginId == null || it.key == selectedPluginId }
+ .flatMap { (pluginId, tools) -> tools.map { pluginId to it } }
+ .distinctBy { (pluginId, tool) -> "$pluginId/${tool.name}" }
+ .map { (pluginId, tool) ->
+ McpToolRowUiState(
+ pluginId = pluginId,
+ pluginName = pluginNamesById[pluginId] ?: pluginId,
+ tool = tool,
+ callCount = callCounts[pluginId to tool.name] ?: 0,
+ running = pluginId == runningPluginId && tool.name == runningToolName,
+ )
+ }
+ .sortedWith(compareBy({ it.pluginName }, { it.tool.name }))
+ .toImmutableList()
+ }
+
+ return McpToolsScreenUiState(
+ pluginOptions = pluginOptions,
+ sessionOptions = sessionOptions,
+ selectedPluginId = selectedPluginId,
+ selectedSessionId = selectedSessionId,
+ toolRows = toolRows,
+ callHistory = callHistory,
+ runningToolName = runningToolName,
+ )
+}
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavDisplay.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavDisplay.kt
index 4461e531..dbe665b5 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavDisplay.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavDisplay.kt
@@ -62,6 +62,7 @@ fun JetWhaleNavDisplay(
)
licensesEntry(onClickBack = backStack::removeLastOrNull)
logViewerEntry()
+ mcpToolsEntry()
pluginEntries(
isOpenedOnPopout = backStack::isPluginPoppedOut,
onBringbackToMainWindow = backStack::bringPluginBackToMainWindow,
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavBackStackExtensions.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavBackStackExtensions.kt
index 6aa7ce2c..79f185e3 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavBackStackExtensions.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavBackStackExtensions.kt
@@ -13,6 +13,20 @@ fun NavBackStack.addSingleTop(index: Int, navKey: T) {
add(index, navKey)
}
+/**
+ * Shows the MCP tools browser, seeded with the scope it was opened from.
+ *
+ * At most one browser window exists: opening it from a different scope re-seeds the filters rather
+ * than stacking a second window, and re-opening it with the same scope leaves the window as it is so
+ * the user does not lose its position or their own filter changes.
+ */
+fun NavBackStack.openMcpTools(pluginId: String?, sessionId: String?) {
+ val navKey = McpToolsNavKey(pluginId = pluginId, sessionId = sessionId)
+ if (any { it == navKey }) return
+ removeAll { it is McpToolsNavKey }
+ add(navKey)
+}
+
/**
* Whether the given plugin is currently shown in a separate popout window for [sessionId].
*/
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt
index 9dffec31..b5e77ef6 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt
@@ -15,7 +15,9 @@ import androidx.navigation3.runtime.NavKey
import com.kitakkun.jetwhale.host.LocalComposeWindow
import com.kitakkun.jetwhale.host.Res
import com.kitakkun.jetwhale.host.di.JetWhaleAppGraph
+import com.kitakkun.jetwhale.host.drawer.McpToolsScreenRoot
import com.kitakkun.jetwhale.host.log_viewer_window_title
+import com.kitakkun.jetwhale.host.mcp_tools_window_title
import com.kitakkun.jetwhale.host.plugin.PluginScreenRoot
import com.kitakkun.jetwhale.host.screen.EmptyPluginScreen
import com.kitakkun.jetwhale.host.screen.InfoScreen
@@ -184,3 +186,33 @@ fun EntryProviderScope.logViewerEntry() {
}
}
}
+
+context(appGraph: JetWhaleAppGraph)
+fun EntryProviderScope.mcpToolsEntry() {
+ entry(
+ metadata = WindowSceneStrategy.window(
+ WindowProperties(
+ width = 1100.dp,
+ height = 750.dp,
+ ),
+ ),
+ ) { navKey ->
+ val window = LocalComposeWindow.current
+ val windowTitle = stringResource(Res.string.mcp_tools_window_title)
+
+ LaunchedEffect(window, windowTitle) {
+ window.title = windowTitle
+ }
+
+ context(
+ retain {
+ appGraph.mcpToolsScreenContext
+ },
+ ) {
+ McpToolsScreenRoot(
+ initialPluginId = navKey.pluginId,
+ initialSessionId = navKey.sessionId,
+ )
+ }
+ }
+}
From f6e1a66bfef2efc4ffa1c0e19d82074f72d5c0c3 Mon Sep 17 00:00:00 2001
From: kitakkun <48154936+kitakkun@users.noreply.github.com>
Date: Tue, 28 Jul 2026 23:08:22 +0900
Subject: [PATCH 10/13] refactor(host): navigate to the MCP browser from the
drawer
The drawer built a per-plugin tool list and call history purely to hand them to a
dialog it owned. Now that the screen subscribes to that data itself, the badge
only needs to know whether the plugin publishes tools at all, so
`DrawerPluginItemUiState` carries `exposesMcpTools` instead of the tools, the
history and the running tool name.
Clicking the badge navigates to the browser scoped to that plugin and the
selected session. A drawer button beside the settings icon opens it unscoped, so
it is reachable without a plugin badge.
---
.../com/kitakkun/jetwhale/host/JetWhaleApp.kt | 4 ++
.../jetwhale/host/component/ToolingDrawer.kt | 5 +++
.../host/drawer/DrawerPluginItemUiState.kt | 15 ++-----
.../host/drawer/ExpandedToolingDrawerView.kt | 32 ++++++++-------
.../host/drawer/JetWhaleToolingScaffold.kt | 6 +++
.../host/drawer/McpToolsDrawerButton.kt | 39 +++++++++++++++++++
.../host/drawer/PluginDrawerItemView.kt | 38 +++++-------------
.../host/drawer/ShrunkToolingDrawerView.kt | 4 ++
.../host/drawer/ToolingScaffoldPresenter.kt | 17 +-------
.../host/drawer/ToolingScaffoldRoot.kt | 5 +++
10 files changed, 95 insertions(+), 70 deletions(-)
create mode 100644 jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDrawerButton.kt
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/JetWhaleApp.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/JetWhaleApp.kt
index 7d880e1f..136b8d49 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/JetWhaleApp.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/JetWhaleApp.kt
@@ -35,6 +35,7 @@ import com.kitakkun.jetwhale.host.navigation.addSingleTop
import com.kitakkun.jetwhale.host.navigation.bringPluginBackToMainWindow
import com.kitakkun.jetwhale.host.navigation.followPluginToSession
import com.kitakkun.jetwhale.host.navigation.isPluginPoppedOut
+import com.kitakkun.jetwhale.host.navigation.openMcpTools
import com.kitakkun.jetwhale.host.settings.SettingsScreenSegmentedMenu
import com.kitakkun.jetwhale.host.ui.AppEnvironment
import com.kitakkun.jetwhale.host.ui.JetWhaleTheme
@@ -139,6 +140,9 @@ fun JetWhaleApp() {
onClickPlugin = { pluginId, sessionId ->
backStack.addSingleTop(PluginNavKey(pluginId, sessionId))
},
+ onOpenMcpTools = { pluginId, sessionId ->
+ backStack.openMcpTools(pluginId = pluginId, sessionId = sessionId)
+ },
onClickPopout = { pluginId, pluginName, sessionId ->
backStack.addSingleTop(
PluginPopoutNavKey(
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/component/ToolingDrawer.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/component/ToolingDrawer.kt
index 4af76e3e..ebdb3934 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/component/ToolingDrawer.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/component/ToolingDrawer.kt
@@ -24,6 +24,8 @@ fun ToolingDrawer(
onClickPluginSettings: () -> Unit,
onClickInfo: () -> Unit,
onClickPlugin: (String) -> Unit,
+ onOpenMcpTools: (pluginId: String) -> Unit,
+ onOpenAllMcpTools: () -> Unit,
onSelectSession: (DebugSession) -> Unit,
onClickPopout: (DrawerPluginItemUiState) -> Unit,
isPoppedOut: (pluginId: String) -> Boolean,
@@ -45,6 +47,8 @@ fun ToolingDrawer(
onClickShrinkDrawer = { expandMenu = false },
onClickSettings = onClickSettings,
onClickPluginSettings = onClickPluginSettings,
+ onOpenMcpTools = onOpenMcpTools,
+ onOpenAllMcpTools = onOpenAllMcpTools,
onClickPlugin = { onClickPlugin(it.id) },
onSelectSession = onSelectSession,
onClickPopout = onClickPopout,
@@ -64,6 +68,7 @@ fun ToolingDrawer(
onClickExpandMenu = { expandMenu = true },
onClickSettings = onClickSettings,
onClickInfo = onClickInfo,
+ onOpenAllMcpTools = onOpenAllMcpTools,
onSelectSession = onSelectSession,
)
},
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt
index d4eae91e..85fba52b 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt
@@ -1,10 +1,7 @@
package com.kitakkun.jetwhale.host.drawer
-import com.kitakkun.jetwhale.host.model.McpCallRecord
-import com.kitakkun.jetwhale.host.model.McpToolSummary
import com.kitakkun.jetwhale.host.model.PluginAvailability
import com.kitakkun.jetwhale.host.model.PluginIconResource
-import kotlinx.collections.immutable.ImmutableList
data class DrawerPluginItemUiState(
val name: String,
@@ -14,12 +11,6 @@ data class DrawerPluginItemUiState(
val pluginAvailability: PluginAvailability,
/** True while an AI agent is driving this plugin's UI in the selected session. */
val underAiControl: Boolean,
- /** The MCP tools this plugin exposes for the selected session; empty when it publishes none. */
- val mcpTools: ImmutableList,
- /** Completed MCP tool calls attributed to this plugin, newest first. */
- val mcpCallHistory: ImmutableList,
- /** The tool an agent is running on this plugin right now, or null when none is in flight. */
- val runningMcpToolName: String?,
-) {
- val exposesMcpTools: Boolean get() = mcpTools.isNotEmpty()
-}
+ /** True when this plugin publishes MCP tools of its own for the selected session. */
+ val exposesMcpTools: Boolean,
+)
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt
index 53a63c8a..ca9d13b7 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt
@@ -64,6 +64,8 @@ fun ExpandedToolingDrawerView(
onClickShrinkDrawer: () -> Unit,
onClickSettings: () -> Unit,
onClickPluginSettings: () -> Unit,
+ onOpenMcpTools: (pluginId: String) -> Unit,
+ onOpenAllMcpTools: () -> Unit,
onClickPlugin: (DrawerPluginItemUiState) -> Unit,
onSelectSession: (DebugSession) -> Unit,
onClickPopout: (DrawerPluginItemUiState) -> Unit,
@@ -82,11 +84,16 @@ fun ExpandedToolingDrawerView(
contentDescription = null,
)
}
- IconButton(onClick = onClickSettings) {
- Icon(
- imageVector = Icons.Default.Settings,
- contentDescription = null,
- )
+ Row {
+ // Opens the browser unscoped, so the tools an agent can reach are visible without
+ // first finding a plugin that happens to publish some.
+ McpToolsDrawerButton(onClick = onOpenAllMcpTools)
+ IconButton(onClick = onClickSettings) {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = null,
+ )
+ }
}
}
Column(
@@ -169,9 +176,8 @@ fun ExpandedToolingDrawerView(
inactiveIconResource = it.inactiveIconResource,
selected = it.id == selectedPluginId,
underAiControl = it.underAiControl,
- mcpTools = it.mcpTools,
- mcpCallHistory = it.mcpCallHistory,
- runningMcpToolName = it.runningMcpToolName,
+ exposesMcpTools = it.exposesMcpTools,
+ onClickMcpBadge = { onOpenMcpTools(it.id) },
onClick = { onClickPlugin(it) },
popupMenuContent = { dismiss ->
DropdownMenuItem(
@@ -243,9 +249,8 @@ fun ExpandedToolingDrawerView(
inactiveIconResource = it.inactiveIconResource,
selected = false,
underAiControl = it.underAiControl,
- mcpTools = it.mcpTools,
- mcpCallHistory = it.mcpCallHistory,
- runningMcpToolName = it.runningMcpToolName,
+ exposesMcpTools = it.exposesMcpTools,
+ onClickMcpBadge = { onOpenMcpTools(it.id) },
onClick = {
// do nothing
},
@@ -291,9 +296,8 @@ fun ExpandedToolingDrawerView(
inactiveIconResource = it.inactiveIconResource,
selected = false,
underAiControl = it.underAiControl,
- mcpTools = it.mcpTools,
- mcpCallHistory = it.mcpCallHistory,
- runningMcpToolName = it.runningMcpToolName,
+ exposesMcpTools = it.exposesMcpTools,
+ onClickMcpBadge = { onOpenMcpTools(it.id) },
onClick = {
// do nothing
},
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/JetWhaleToolingScaffold.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/JetWhaleToolingScaffold.kt
index 3784c31c..75c0bcdd 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/JetWhaleToolingScaffold.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/JetWhaleToolingScaffold.kt
@@ -19,6 +19,8 @@ fun ToolingScaffold(
onClickPluginSettings: () -> Unit,
onClickInfo: () -> Unit,
onClickPlugin: (String) -> Unit,
+ onOpenMcpTools: (pluginId: String) -> Unit,
+ onOpenAllMcpTools: () -> Unit,
onClickPopout: (DrawerPluginItemUiState) -> Unit,
isPoppedOut: (pluginId: String) -> Boolean,
onClickBringBack: (DrawerPluginItemUiState) -> Unit,
@@ -41,6 +43,8 @@ fun ToolingScaffold(
onClickPluginSettings = onClickPluginSettings,
onClickInfo = onClickInfo,
onClickPlugin = onClickPlugin,
+ onOpenMcpTools = onOpenMcpTools,
+ onOpenAllMcpTools = onOpenAllMcpTools,
onSelectSession = onSelectSession,
onClickPopout = onClickPopout,
isPoppedOut = isPoppedOut,
@@ -72,6 +76,8 @@ private fun ToolingScaffoldPreview() {
onClickPluginSettings = {},
onClickInfo = {},
onClickPlugin = {},
+ onOpenMcpTools = {},
+ onOpenAllMcpTools = {},
onSelectSession = {},
onClickPopout = {},
isPoppedOut = { false },
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDrawerButton.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDrawerButton.kt
new file mode 100644
index 00000000..6929a689
--- /dev/null
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDrawerButton.kt
@@ -0,0 +1,39 @@
+package com.kitakkun.jetwhale.host.drawer
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Build
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.Text
+import androidx.compose.material3.TooltipAnchorPosition
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.runtime.Composable
+import com.kitakkun.jetwhale.host.Res
+import com.kitakkun.jetwhale.host.mcp_tools_open_all
+import org.jetbrains.compose.resources.stringResource
+
+/**
+ * Drawer entry point to the MCP tools browser that is not tied to a plugin, so the browser is
+ * reachable even when no plugin badge is on screen.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun McpToolsDrawerButton(onClick: () -> Unit) {
+ val label = stringResource(Res.string.mcp_tools_open_all)
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
+ tooltip = { PlainTooltip { Text(label) } },
+ state = rememberTooltipState(),
+ ) {
+ IconButton(onClick = onClick) {
+ Icon(
+ imageVector = Icons.Default.Build,
+ contentDescription = label,
+ )
+ }
+ }
+}
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
index 6c198165..ba60d73b 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
@@ -33,12 +33,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.kitakkun.jetwhale.host.Res
-import com.kitakkun.jetwhale.host.model.McpCallRecord
-import com.kitakkun.jetwhale.host.model.McpToolSummary
import com.kitakkun.jetwhale.host.model.PluginIconResource
import com.kitakkun.jetwhale.host.puzzle_filled
import com.kitakkun.jetwhale.host.puzzle_outlined
-import kotlinx.collections.immutable.ImmutableList
import org.jetbrains.compose.resources.painterResource
@Composable
@@ -47,12 +44,11 @@ fun PluginDrawerItemView(
name: String,
selected: Boolean,
underAiControl: Boolean,
- mcpTools: ImmutableList,
- mcpCallHistory: ImmutableList,
- runningMcpToolName: String?,
+ exposesMcpTools: Boolean,
activeIconResource: PluginIconResource?,
inactiveIconResource: PluginIconResource?,
onClick: () -> Unit,
+ onClickMcpBadge: () -> Unit,
popupMenuContent: (@Composable ColumnScope.(dismiss: () -> Unit) -> Unit)? = null,
modifier: Modifier = Modifier,
) {
@@ -73,13 +69,10 @@ fun PluginDrawerItemView(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
- if (mcpTools.isNotEmpty()) {
+ if (exposesMcpTools) {
McpBadge(
operating = underAiControl,
- pluginName = name,
- tools = mcpTools,
- callHistory = mcpCallHistory,
- runningToolName = runningMcpToolName,
+ onClick = onClickMcpBadge,
)
}
}
@@ -138,17 +131,14 @@ fun PluginDrawerItemView(
/**
* A compact "MCP" badge shown after a plugin's name when it exposes MCP tools. It is filled while an
- * agent is running one of those tools and outlined otherwise, and opens a dialog listing the tools.
+ * agent is running one of those tools and outlined otherwise, and opens the MCP tools browser scoped
+ * to this plugin.
*/
@Composable
private fun McpBadge(
operating: Boolean,
- pluginName: String,
- tools: ImmutableList,
- callHistory: ImmutableList,
- runningToolName: String?,
+ onClick: () -> Unit,
) {
- var showDialog by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(4.dp)
val contentColor = if (operating) Color.Black else MaterialTheme.colorScheme.onSurfaceVariant
val decoration = if (operating) {
@@ -160,7 +150,7 @@ private fun McpBadge(
modifier = Modifier
.clip(shape)
.then(decoration)
- .clickable { showDialog = true }
+ .clickable(onClick = onClick)
.padding(horizontal = 5.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
@@ -170,7 +160,7 @@ private fun McpBadge(
style = MaterialTheme.typography.labelSmall,
color = contentColor,
)
- // Signals that clicking opens a separate dialog.
+ // Signals that clicking opens a separate window.
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = null,
@@ -178,14 +168,4 @@ private fun McpBadge(
modifier = Modifier.size(11.dp),
)
}
- if (showDialog) {
- McpToolsDialog(
- operating = operating,
- pluginName = pluginName,
- tools = tools,
- callHistory = callHistory,
- runningToolName = runningToolName,
- onDismiss = { showDialog = false },
- )
- }
}
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ShrunkToolingDrawerView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ShrunkToolingDrawerView.kt
index 3938bd76..04af84c4 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ShrunkToolingDrawerView.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ShrunkToolingDrawerView.kt
@@ -61,6 +61,7 @@ fun ShrunkToolingDrawerView(
onClickSettings: () -> Unit,
onClickPlugin: (String) -> Unit,
onClickInfo: () -> Unit,
+ onOpenAllMcpTools: () -> Unit,
onSelectSession: (DebugSession) -> Unit,
) {
Column(
@@ -126,6 +127,9 @@ fun ShrunkToolingDrawerView(
contentDescription = null,
)
}
+ // Opens the browser unscoped, so the tools an agent can reach are visible without first
+ // finding a plugin that happens to publish some.
+ McpToolsDrawerButton(onClick = onOpenAllMcpTools)
CompactAiActivityIndicatorView(uiState = aiActivity)
HorizontalDivider()
LazyColumn(
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt
index 79dfc892..5dff2056 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt
@@ -72,8 +72,7 @@ fun toolingScaffoldPresenter(
val setPluginEnabledMutation = rememberMutation(presenterContext.setPluginEnabledMutationKey)
- val recentCalls = mcpActivity.recentCalls
- val plugins by remember(loadedPlugins, selectedSession, enabledPluginIds, mcpCapablePlugins, activeInvocation, recentCalls) {
+ val plugins by remember(loadedPlugins, selectedSession, enabledPluginIds, mcpCapablePlugins, activeInvocation) {
derivedStateOf {
// Attribute the operation only when it targets the session the drawer is showing;
// highlighting a plugin for some other device would be misleading.
@@ -81,12 +80,6 @@ fun toolingScaffoldPresenter(
?.takeIf { it.sessionId != null && it.sessionId == selectedSession?.id }
?.pluginId
- // Calls that named no session came from a tool that does not target one, so they belong
- // to whichever session is on screen; the rest are shown only under their own session.
- val callsForSelectedSession = recentCalls.filter {
- it.sessionId == null || it.sessionId == selectedSession?.id
- }
-
loadedPlugins.map { metaData ->
val isInstalledOnAgent = selectedSession?.installedPlugins?.any { installed -> installed.pluginId == metaData.id } == true
val isEnabledInSettings = enabledPluginIds.contains(metaData.id)
@@ -108,13 +101,7 @@ fun toolingScaffoldPresenter(
else -> PluginAvailability.Disabled
},
underAiControl = aiControlledPluginId == metaData.id,
- mcpTools = mcpCapablePlugins.toolsFor(selectedSession?.id, metaData.id).toImmutableList(),
- mcpCallHistory = callsForSelectedSession
- .filter { it.pluginId == metaData.id }
- .toImmutableList(),
- runningMcpToolName = activeInvocation
- ?.takeIf { it.pluginId == metaData.id }
- ?.toolName,
+ exposesMcpTools = mcpCapablePlugins.toolsFor(selectedSession?.id, metaData.id).isNotEmpty(),
)
}.toImmutableList()
}
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldRoot.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldRoot.kt
index 39a22fdc..55aa63b0 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldRoot.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldRoot.kt
@@ -15,6 +15,7 @@ fun ToolingScaffoldRoot(
onClickPluginSettings: () -> Unit,
onClickInfo: () -> Unit,
onClickPlugin: (pluginId: String, sessionId: String) -> Unit,
+ onOpenMcpTools: (pluginId: String?, sessionId: String?) -> Unit,
onClickPopout: (pluginId: String, pluginName: String, sessionId: String) -> Unit,
isPoppedOut: (pluginId: String, sessionId: String) -> Boolean,
onClickBringBack: (pluginId: String, sessionId: String) -> Unit,
@@ -68,6 +69,10 @@ fun ToolingScaffoldRoot(
screenChannel.send(ToolingScaffoldScreenAction.UpdateSelectedPlugin(it))
onClickPlugin(it, selectedSession.id)
},
+ // The browser tolerates a missing session, so the badge stays usable while no session
+ // is selected: it simply opens with the session filter on "All".
+ onOpenMcpTools = { onOpenMcpTools(it, uiState.selectedSession?.id) },
+ onOpenAllMcpTools = { onOpenMcpTools(null, null) },
onClickPopout = {
val selectedSession = uiState.selectedSession ?: return@ToolingScaffold
onClickPopout(it.id, it.name, selectedSession.id)
From 0037d5410b7f00c9e99e7f5299c943b5a2dc6399 Mon Sep 17 00:00:00 2001
From: kitakkun <48154936+kitakkun@users.noreply.github.com>
Date: Tue, 28 Jul 2026 23:09:30 +0900
Subject: [PATCH 11/13] fix(host): record isError tool results as failed calls
MCP lets a tool report its own failure by returning a result flagged with
`isError` instead of throwing, which is how the built-in tools signal a
rejected click or drag. The registrar only watched for a thrown handler, so
those calls landed in history marked as successes.
Also fold `structuredContent` into the recorded response so a tool that
answers with a machine-readable payload is not shown as an empty result.
---
.../jetwhale/host/mcp/McpToolRegistrar.kt | 22 +++--
.../host/mcp/DefaultMcpServerServiceTest.kt | 82 +++++++++++++++++++
2 files changed, 97 insertions(+), 7 deletions(-)
diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt
index bb3bfe3f..77de1d5a 100644
--- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt
+++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt
@@ -87,13 +87,14 @@ class McpToolRegistrar(
value.jsonContent ?: value.toString()
},
)
- // A thrown handler is the only failure signal available here, and it has to reach the
- // repository before it propagates on to the MCP layer.
+ // A tool fails in two ways: the handler throws, or it returns a result flagged with
+ // `isError`, which is how the protocol wants a tool-level failure reported. Both have to
+ // reach the repository before the call leaves here.
var failed = true
var response = ""
try {
handler(request).also {
- failed = false
+ failed = it.isError == true
response = it.renderForHistory()
}
} catch (throwable: Throwable) {
@@ -113,10 +114,17 @@ class McpToolRegistrar(
* Only text blocks carry something a reader can use; every other block type is a binary payload (a
* screenshot, audio, an embedded resource) that is named rather than inlined, so history does not
* fill up with base64.
+ *
+ * A structured payload follows the blocks on its own line. It is the whole answer for a tool that
+ * replies only in `structuredContent`, and it reads as the machine-readable detail behind the prose
+ * for a tool that sends both.
*/
-private fun CallToolResult.renderForHistory(): String = content.joinToString(separator = "\n") { block ->
- when (block) {
- is TextContent -> block.text
- else -> "<${block.type.value}>"
+private fun CallToolResult.renderForHistory(): String {
+ val renderedBlocks = content.map { block ->
+ when (block) {
+ is TextContent -> block.text
+ else -> "<${block.type.value}>"
+ }
}
+ return (renderedBlocks + listOfNotNull(structuredContent?.toString())).joinToString(separator = "\n")
}
diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt
index 0d30b96e..c8fe4ac3 100644
--- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt
+++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt
@@ -28,6 +28,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -469,6 +471,62 @@ class DefaultMcpServerServiceTest {
assertEquals("captured\n", record.response)
}
+ @Test
+ fun `a tool call that reports an error without throwing is recorded as a failure`() = runBlocking {
+ val serviceWithTool = DefaultMcpServerService(
+ pluginInstanceService = pluginInstanceService,
+ mcpActivityRepository = mcpActivityRepository,
+ builtInTools = setOf(ErrorResultMcpTool("fake.rejected")),
+ )
+ val rejectedPort = java.net.ServerSocket(0).use { it.localPort }
+ serviceWithTool.start(host, rejectedPort)
+ // Stopping the server clears recorded activity, so the history has to be read while it runs.
+ val record = try {
+ val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$rejectedPort/sse")
+ try {
+ val result = client.callTool("fake.rejected", emptyMap())
+ // The handler returns normally, so nothing but `isError` marks this as a failure.
+ assertEquals(true, result.isError)
+ } finally {
+ client.close()
+ }
+ mcpActivityRepository.activityFlow.value.recentCalls.single()
+ } finally {
+ serviceWithTool.stop()
+ }
+
+ assertEquals("fake.rejected", record.toolName)
+ assertFalse(record.succeeded)
+ assertEquals("""{"error":"no such element"}""", record.response)
+ }
+
+ @Test
+ fun `a structured response is recorded alongside the text content`() = runBlocking {
+ val serviceWithTool = DefaultMcpServerService(
+ pluginInstanceService = pluginInstanceService,
+ mcpActivityRepository = mcpActivityRepository,
+ builtInTools = setOf(StructuredMcpTool("fake.structured")),
+ )
+ val structuredPort = java.net.ServerSocket(0).use { it.localPort }
+ serviceWithTool.start(host, structuredPort)
+ // Stopping the server clears recorded activity, so the history has to be read while it runs.
+ val record = try {
+ val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$structuredPort/sse")
+ try {
+ client.callTool("fake.structured", emptyMap())
+ } finally {
+ client.close()
+ }
+ mcpActivityRepository.activityFlow.value.recentCalls.single()
+ } finally {
+ serviceWithTool.stop()
+ }
+
+ assertTrue(record.succeeded)
+ assertEquals("measured", record.response.lineSequence().first())
+ assertTrue("\"width\":120" in record.response, "Structured payload missing from ${record.response}")
+ }
+
@Test
fun `a throwing tool call is recorded in history as a failure`() = runBlocking {
val serviceWithTool = DefaultMcpServerService(
@@ -584,6 +642,30 @@ private class MediaMcpTool(private val name: String) : JetWhaleMcpTool {
}
}
+/** Reports a tool-level failure the way the protocol prefers: a normal return flagged `isError`. */
+private class ErrorResultMcpTool(private val name: String) : JetWhaleMcpTool {
+ override fun register(registrar: McpToolRegistrar) {
+ registrar.addTool(name = name, description = "Always reports an error result", inputSchema = ToolSchema()) { _ ->
+ errorResult("no such element")
+ }
+ }
+}
+
+/** Answers with both prose and a machine-readable payload, as a tool with an output schema does. */
+private class StructuredMcpTool(private val name: String) : JetWhaleMcpTool {
+ override fun register(registrar: McpToolRegistrar) {
+ registrar.addTool(name = name, description = "Returns structured content", inputSchema = ToolSchema()) { _ ->
+ CallToolResult(
+ content = listOf(TextContent("measured")),
+ structuredContent = buildJsonObject {
+ put("width", 120)
+ put("height", 40)
+ },
+ )
+ }
+ }
+}
+
private class FailingMcpTool(private val name: String) : JetWhaleMcpTool {
override fun register(registrar: McpToolRegistrar) {
registrar.addTool(name = name, description = "Always throws", inputSchema = ToolSchema()) { _ ->
From 5b3b6ea631a3397bc567b19b0633b71cd06d0dd7 Mon Sep 17 00:00:00 2001
From: kitakkun <48154936+kitakkun@users.noreply.github.com>
Date: Tue, 28 Jul 2026 23:32:51 +0900
Subject: [PATCH 12/13] feat(host): filter the MCP browser by several plugins
and sessions
The plugin and session filters accepted a single value, so two plugins
could not be looked at side by side. Both now hold a set of ids, and an
empty set means every value passes.
Each picked value gets its own removable `InputChip` with a close icon;
the group falls back to an "All" chip that opens the picker while its set
is empty. The picker keeps a check beside the entries already chosen and
stays open across picks so several can be added in one go.
---
.../composeResources/values-ja/strings.xml | 2 +
.../main/composeResources/values/strings.xml | 2 +
.../jetwhale/host/drawer/McpToolsScreen.kt | 196 +++++++++++++-----
.../host/drawer/McpToolsScreenRoot.kt | 39 ++--
4 files changed, 172 insertions(+), 67 deletions(-)
diff --git a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
index 699f4b4d..96f2d694 100644
--- a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
+++ b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
@@ -55,4 +55,6 @@
プラグイン
セッション
すべて
+ フィルターを追加
+ フィルターを解除
diff --git a/jetwhale-host/app/src/main/composeResources/values/strings.xml b/jetwhale-host/app/src/main/composeResources/values/strings.xml
index 7a77507d..ebc9cf9b 100644
--- a/jetwhale-host/app/src/main/composeResources/values/strings.xml
+++ b/jetwhale-host/app/src/main/composeResources/values/strings.xml
@@ -55,4 +55,6 @@
Plugin
Session
All
+ Add filter
+ Remove filter
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt
index 03a2b8d4..a037a204 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt
@@ -7,6 +7,7 @@ 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.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
@@ -23,16 +24,20 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.AssistChip
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.InputChip
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
@@ -67,8 +72,10 @@ import com.kitakkun.jetwhale.host.mcp_history_response
import com.kitakkun.jetwhale.host.mcp_history_succeeded
import com.kitakkun.jetwhale.host.mcp_tool_executing
import com.kitakkun.jetwhale.host.mcp_tools_available
+import com.kitakkun.jetwhale.host.mcp_tools_filter_add
import com.kitakkun.jetwhale.host.mcp_tools_filter_all
import com.kitakkun.jetwhale.host.mcp_tools_filter_plugin
+import com.kitakkun.jetwhale.host.mcp_tools_filter_remove
import com.kitakkun.jetwhale.host.mcp_tools_filter_session
import com.kitakkun.jetwhale.host.mcp_tools_no_match
import com.kitakkun.jetwhale.host.mcp_tools_parameters
@@ -79,6 +86,7 @@ import com.kitakkun.jetwhale.host.mcp_tools_tab_tools
import com.kitakkun.jetwhale.host.model.McpCallRecord
import com.kitakkun.jetwhale.host.model.McpToolParameterSummary
import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
import org.jetbrains.compose.resources.stringResource
import java.time.Instant
import java.time.ZoneId
@@ -88,8 +96,8 @@ import java.time.format.DateTimeFormatter
data class McpToolsScreenUiState(
val pluginOptions: ImmutableList,
val sessionOptions: ImmutableList,
- val selectedPluginId: String?,
- val selectedSessionId: String?,
+ val selectedPluginIds: ImmutableSet,
+ val selectedSessionIds: ImmutableSet,
val toolRows: ImmutableList,
val callHistory: ImmutableList,
val runningToolName: String?,
@@ -104,8 +112,8 @@ internal enum class McpToolsTab {
@Composable
fun McpToolsScreen(
uiState: McpToolsScreenUiState,
- onSelectPluginFilter: (String?) -> Unit,
- onSelectSessionFilter: (String?) -> Unit,
+ onSelectPluginFilters: (Set) -> Unit,
+ onSelectSessionFilters: (Set) -> Unit,
) {
Column(
modifier = Modifier
@@ -113,29 +121,35 @@ fun McpToolsScreen(
.padding(20.dp),
) {
Row(
- verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
- McpFilterDropdown(
- label = stringResource(Res.string.mcp_tools_filter_plugin),
- options = uiState.pluginOptions,
- selectedId = uiState.selectedPluginId,
- onSelect = onSelectPluginFilter,
- )
- McpFilterDropdown(
- label = stringResource(Res.string.mcp_tools_filter_session),
- options = uiState.sessionOptions,
- selectedId = uiState.selectedSessionId,
- onSelect = onSelectSessionFilter,
- )
- Spacer(Modifier.weight(1f))
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.weight(1f),
+ ) {
+ McpFilterChipGroup(
+ label = stringResource(Res.string.mcp_tools_filter_plugin),
+ options = uiState.pluginOptions,
+ selectedIds = uiState.selectedPluginIds,
+ onSelectionChange = onSelectPluginFilters,
+ )
+ McpFilterChipGroup(
+ label = stringResource(Res.string.mcp_tools_filter_session),
+ options = uiState.sessionOptions,
+ selectedIds = uiState.selectedSessionIds,
+ onSelectionChange = onSelectSessionFilters,
+ )
+ }
Text(
text = stringResource(
if (uiState.runningToolName != null) Res.string.mcp_tool_executing else Res.string.mcp_tools_available,
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
+ // Held against the first chip row instead of the middle of a block whose height
+ // grows as chips wrap.
+ modifier = Modifier.padding(top = 8.dp),
)
}
Spacer(Modifier.size(12.dp))
@@ -190,51 +204,135 @@ fun McpToolsScreen(
}
}
-/** "All" plus one entry per option, so the user can widen a filter the nav key seeded. */
+/** Icon size shared by the filter chips and the entries of their picker. */
+private val McpFilterIconSize = 18.dp
+
+/**
+ * One filter group: a removable chip per picked value, plus a chip that opens the picker. An empty
+ * [selectedIds] narrows nothing, and the group reads as "All" until a value is picked.
+ */
@Composable
-private fun McpFilterDropdown(
+private fun McpFilterChipGroup(
label: String,
options: ImmutableList,
- selectedId: String?,
- onSelect: (String?) -> Unit,
+ selectedIds: ImmutableSet,
+ onSelectionChange: (Set) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val allLabel = stringResource(Res.string.mcp_tools_filter_all)
- val selectedLabel = options.firstOrNull { it.id == selectedId }?.label ?: allLabel
+ val addLabel = stringResource(Res.string.mcp_tools_filter_add)
+ val removeLabel = stringResource(Res.string.mcp_tools_filter_remove)
+ // A picked value outlives the option that named it once a session goes away, so the raw id
+ // stands in rather than dropping a filter that is still narrowing the screen.
+ val selectedChips = remember(options, selectedIds) {
+ val labelsById = options.associate { it.id to it.label }
+ selectedIds
+ .map { id -> McpFilterOption(id = id, label = labelsById[id] ?: id) }
+ .sortedBy { it.label }
+ }
- Box {
- OutlinedButton(onClick = { expanded = true }) {
- Text(
- text = "$label: $selectedLabel",
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.widthIn(max = 260.dp),
- )
- }
- DropdownMenu(
- expanded = expanded,
- onDismissRequest = { expanded = false },
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ // Fixed width and held against the first chip row so both groups' labels line up even
+ // when one of them wraps onto several rows.
+ modifier = Modifier
+ .width(76.dp)
+ .padding(top = 8.dp),
+ )
+ // Wraps so a long list of plugins or sessions grows downwards instead of widening a dialog
+ // that is already bounded.
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.weight(1f),
) {
- DropdownMenuItem(
- text = { Text(allLabel) },
- onClick = {
- onSelect(null)
- expanded = false
- },
- )
- options.forEach { option ->
- DropdownMenuItem(
- text = { Text(option.label) },
- onClick = {
- onSelect(option.id)
- expanded = false
+ selectedChips.forEach { option ->
+ InputChip(
+ selected = true,
+ onClick = { onSelectionChange(selectedIds - option.id) },
+ label = {
+ Text(
+ text = option.label,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.widthIn(max = 220.dp),
+ )
+ },
+ trailingIcon = {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = removeLabel,
+ modifier = Modifier.size(McpFilterIconSize),
+ )
},
)
}
+ Box {
+ val filtering = selectedChips.isNotEmpty()
+ val addIcon: (@Composable () -> Unit)? = if (filtering) {
+ {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ modifier = Modifier.size(McpFilterIconSize),
+ )
+ }
+ } else {
+ null
+ }
+ AssistChip(
+ onClick = { expanded = true },
+ enabled = options.isNotEmpty(),
+ label = { Text(if (filtering) addLabel else allLabel) },
+ leadingIcon = addIcon,
+ )
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ ) {
+ // The menu survives every pick so several values can be added in one go, and a
+ // check marks what is already picked while it is open.
+ DropdownMenuItem(
+ text = { Text(allLabel) },
+ onClick = { onSelectionChange(emptySet()) },
+ trailingIcon = { if (selectedIds.isEmpty()) McpFilterSelectedCheck() },
+ )
+ options.forEach { option ->
+ val selected = option.id in selectedIds
+ DropdownMenuItem(
+ text = { Text(option.label) },
+ onClick = {
+ onSelectionChange(
+ if (selected) selectedIds - option.id else selectedIds + option.id,
+ )
+ },
+ trailingIcon = { if (selected) McpFilterSelectedCheck() },
+ )
+ }
+ }
+ }
}
}
}
+/** Marks an entry of a filter picker as already narrowing the screen. */
+@Composable
+private fun McpFilterSelectedCheck() {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = null,
+ modifier = Modifier.size(McpFilterIconSize),
+ )
+}
+
/**
* Trailing badge on a tool row: the number of recorded calls, shown quietly. While an agent is
* running the tool it takes the accent fill and the same rotating ring the drawer item uses, so
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt
index 473c1b76..ff9a583e 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt
@@ -13,7 +13,9 @@ import com.kitakkun.jetwhale.host.model.McpActivity
import com.kitakkun.jetwhale.host.model.McpCapablePlugins
import com.kitakkun.jetwhale.host.model.McpToolSummary
import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.delay
import soil.query.compose.rememberSubscription
import kotlin.time.Duration.Companion.milliseconds
@@ -33,9 +35,9 @@ data class McpToolRowUiState(
val key: String get() = "$pluginId/${tool.name}"
}
-/** An entry of a filter dropdown. A null [id] is the "All" entry. */
+/** One value a filter group can be narrowed to. */
data class McpFilterOption(
- val id: String?,
+ val id: String,
val label: String,
)
@@ -51,8 +53,9 @@ fun McpToolsScreenRoot(
state3 = rememberSubscription(screenContext.mcpActivitySubscriptionKey),
state4 = rememberSubscription(screenContext.mcpCapablePluginsSubscriptionKey),
) { loadedPlugins, debugSessions, mcpActivity, mcpCapablePlugins ->
- var selectedPluginId by retain { mutableStateOf(initialPluginId) }
- var selectedSessionId by retain { mutableStateOf(initialSessionId) }
+ // An empty set filters nothing, so the nav key's "no preference" maps onto it directly.
+ var selectedPluginIds by retain { mutableStateOf(setOfNotNull(initialPluginId).toPersistentSet()) }
+ var selectedSessionIds by retain { mutableStateOf(setOfNotNull(initialSessionId).toPersistentSet()) }
// A call can start and finish between two frames, so watching the running list drops fast
// calls entirely. Latch on the monotonic counter and hold briefly instead, matching how the
@@ -75,13 +78,13 @@ fun McpToolsScreenRoot(
mcpActivity = mcpActivity,
debugSessions = debugSessions,
pluginNamesById = pluginNamesById,
- selectedPluginId = selectedPluginId,
- selectedSessionId = selectedSessionId,
+ selectedPluginIds = selectedPluginIds,
+ selectedSessionIds = selectedSessionIds,
runningPluginId = runningInvocation?.pluginId,
runningToolName = runningInvocation?.toolName,
),
- onSelectPluginFilter = { selectedPluginId = it },
- onSelectSessionFilter = { selectedSessionId = it },
+ onSelectPluginFilters = { selectedPluginIds = it.toPersistentSet() },
+ onSelectSessionFilters = { selectedSessionIds = it.toPersistentSet() },
)
}
}
@@ -92,8 +95,8 @@ private fun rememberMcpToolsUiState(
mcpActivity: McpActivity,
debugSessions: ImmutableList,
pluginNamesById: Map,
- selectedPluginId: String?,
- selectedSessionId: String?,
+ selectedPluginIds: ImmutableSet,
+ selectedSessionIds: ImmutableSet,
runningPluginId: String?,
runningToolName: String?,
): McpToolsScreenUiState {
@@ -117,22 +120,22 @@ private fun rememberMcpToolsUiState(
// Calls that named no session came from a tool that targets none, so they stay visible under a
// specific session too; hiding them would make a session look quieter than it was.
- val callHistory = remember(mcpActivity.recentCalls, selectedPluginId, selectedSessionId) {
+ val callHistory = remember(mcpActivity.recentCalls, selectedPluginIds, selectedSessionIds) {
mcpActivity.recentCalls
- .filter { selectedPluginId == null || it.pluginId == selectedPluginId }
- .filter { selectedSessionId == null || it.sessionId == null || it.sessionId == selectedSessionId }
+ .filter { selectedPluginIds.isEmpty() || it.pluginId in selectedPluginIds }
+ .filter { selectedSessionIds.isEmpty() || it.sessionId == null || it.sessionId in selectedSessionIds }
.toImmutableList()
}
- val toolRows = remember(mcpCapablePlugins, callHistory, pluginNamesById, selectedPluginId, selectedSessionId, runningPluginId, runningToolName) {
+ val toolRows = remember(mcpCapablePlugins, callHistory, pluginNamesById, selectedPluginIds, selectedSessionIds, runningPluginId, runningToolName) {
// The same plugin publishes the same tools in every session it is active in, so the scope can
// yield duplicates that carry no extra information.
val callCounts = callHistory.groupingBy { it.pluginId to it.toolName }.eachCount()
mcpCapablePlugins.toolsBySessionAndPlugin
- .filterKeys { selectedSessionId == null || it == selectedSessionId }
+ .filterKeys { selectedSessionIds.isEmpty() || it in selectedSessionIds }
.values
.flatMap { toolsByPlugin -> toolsByPlugin.entries }
- .filter { selectedPluginId == null || it.key == selectedPluginId }
+ .filter { selectedPluginIds.isEmpty() || it.key in selectedPluginIds }
.flatMap { (pluginId, tools) -> tools.map { pluginId to it } }
.distinctBy { (pluginId, tool) -> "$pluginId/${tool.name}" }
.map { (pluginId, tool) ->
@@ -151,8 +154,8 @@ private fun rememberMcpToolsUiState(
return McpToolsScreenUiState(
pluginOptions = pluginOptions,
sessionOptions = sessionOptions,
- selectedPluginId = selectedPluginId,
- selectedSessionId = selectedSessionId,
+ selectedPluginIds = selectedPluginIds,
+ selectedSessionIds = selectedSessionIds,
toolRows = toolRows,
callHistory = callHistory,
runningToolName = runningToolName,
From f8b43225685d318d5e8de079752439b579fb0338 Mon Sep 17 00:00:00 2001
From: kitakkun <48154936+kitakkun@users.noreply.github.com>
Date: Tue, 28 Jul 2026 23:34:11 +0900
Subject: [PATCH 13/13] fix(host): open the MCP browser as a dialog inside the
window
A separate window did not inherit the app theme, leaving the browser
unreadable on a white background. Register it with the dialog strategy used
by the other dialogs, disable the platform default width so the browser can
size itself, and give it the same window-fraction sizing the tool dialog
used before it became a screen.
---
.../composeResources/values-ja/strings.xml | 1 -
.../main/composeResources/values/strings.xml | 1 -
.../jetwhale/host/drawer/McpToolsScreen.kt | 166 ++++++++++--------
.../jetwhale/host/navigation/NavEntries.kt | 16 +-
4 files changed, 94 insertions(+), 90 deletions(-)
diff --git a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
index 96f2d694..482f35c0 100644
--- a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
+++ b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
@@ -50,7 +50,6 @@
引数なし
レスポンス
レスポンスなし
- MCP Tools
MCP tools を閲覧
プラグイン
セッション
diff --git a/jetwhale-host/app/src/main/composeResources/values/strings.xml b/jetwhale-host/app/src/main/composeResources/values/strings.xml
index ebc9cf9b..ccd2be5c 100644
--- a/jetwhale-host/app/src/main/composeResources/values/strings.xml
+++ b/jetwhale-host/app/src/main/composeResources/values/strings.xml
@@ -50,7 +50,6 @@
No arguments
Response
No response
- MCP Tools
Browse MCP tools
Plugin
Session
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt
index a037a204..b8ba948b 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
@@ -39,6 +40,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.InputChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
@@ -109,97 +111,109 @@ internal enum class McpToolsTab {
History,
}
+private const val MCP_TOOLS_DIALOG_WINDOW_FRACTION = 0.8f
+
@Composable
fun McpToolsScreen(
uiState: McpToolsScreenUiState,
onSelectPluginFilters: (Set) -> Unit,
onSelectSessionFilters: (Set) -> Unit,
) {
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(20.dp),
- ) {
- Row(
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- modifier = Modifier.fillMaxWidth(),
+ Surface(shape = MaterialTheme.shapes.large) {
+ Column(
+ modifier = Modifier
+ // Take most of the window so tool descriptions and history are readable, but stop
+ // growing past a comfortable reading width on a large display.
+ .fillMaxSize(MCP_TOOLS_DIALOG_WINDOW_FRACTION)
+ .sizeIn(
+ minWidth = 640.dp,
+ minHeight = 440.dp,
+ maxWidth = 1200.dp,
+ maxHeight = 860.dp,
+ )
+ .padding(20.dp),
) {
- Column(
- verticalArrangement = Arrangement.spacedBy(4.dp),
- modifier = Modifier.weight(1f),
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxWidth(),
) {
- McpFilterChipGroup(
- label = stringResource(Res.string.mcp_tools_filter_plugin),
- options = uiState.pluginOptions,
- selectedIds = uiState.selectedPluginIds,
- onSelectionChange = onSelectPluginFilters,
- )
- McpFilterChipGroup(
- label = stringResource(Res.string.mcp_tools_filter_session),
- options = uiState.sessionOptions,
- selectedIds = uiState.selectedSessionIds,
- onSelectionChange = onSelectSessionFilters,
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.weight(1f),
+ ) {
+ McpFilterChipGroup(
+ label = stringResource(Res.string.mcp_tools_filter_plugin),
+ options = uiState.pluginOptions,
+ selectedIds = uiState.selectedPluginIds,
+ onSelectionChange = onSelectPluginFilters,
+ )
+ McpFilterChipGroup(
+ label = stringResource(Res.string.mcp_tools_filter_session),
+ options = uiState.sessionOptions,
+ selectedIds = uiState.selectedSessionIds,
+ onSelectionChange = onSelectSessionFilters,
+ )
+ }
+ Text(
+ text = stringResource(
+ if (uiState.runningToolName != null) Res.string.mcp_tool_executing else Res.string.mcp_tools_available,
+ ),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ // Held against the first chip row instead of the middle of a block whose height
+ // grows as chips wrap.
+ modifier = Modifier.padding(top = 8.dp),
)
}
- Text(
- text = stringResource(
- if (uiState.runningToolName != null) Res.string.mcp_tool_executing else Res.string.mcp_tools_available,
- ),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- // Held against the first chip row instead of the middle of a block whose height
- // grows as chips wrap.
- modifier = Modifier.padding(top = 8.dp),
- )
- }
- Spacer(Modifier.size(12.dp))
+ Spacer(Modifier.size(12.dp))
- var selectedTab by remember { mutableStateOf(McpToolsTab.Tools) }
- TabRow(selectedTabIndex = selectedTab.ordinal) {
- Tab(
- selected = selectedTab == McpToolsTab.Tools,
- onClick = { selectedTab = McpToolsTab.Tools },
- text = { Text(stringResource(Res.string.mcp_tools_tab_tools)) },
- )
- Tab(
- selected = selectedTab == McpToolsTab.History,
- onClick = { selectedTab = McpToolsTab.History },
- text = {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- ) {
- Text(stringResource(Res.string.mcp_tools_tab_history))
- if (uiState.callHistory.isNotEmpty()) {
- // How many calls the current scope holds, so the count is visible
- // without opening the tab.
- McpToolCallCountBadge(count = uiState.callHistory.size, running = false)
+ var selectedTab by remember { mutableStateOf(McpToolsTab.Tools) }
+ TabRow(selectedTabIndex = selectedTab.ordinal) {
+ Tab(
+ selected = selectedTab == McpToolsTab.Tools,
+ onClick = { selectedTab = McpToolsTab.Tools },
+ text = { Text(stringResource(Res.string.mcp_tools_tab_tools)) },
+ )
+ Tab(
+ selected = selectedTab == McpToolsTab.History,
+ onClick = { selectedTab = McpToolsTab.History },
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text(stringResource(Res.string.mcp_tools_tab_history))
+ if (uiState.callHistory.isNotEmpty()) {
+ // How many calls the current scope holds, so the count is visible
+ // without opening the tab.
+ McpToolCallCountBadge(count = uiState.callHistory.size, running = false)
+ }
}
- }
- },
- )
- }
- Spacer(Modifier.size(12.dp))
+ },
+ )
+ }
+ Spacer(Modifier.size(12.dp))
- // Hoisted out of the pane so switching tabs and coming back keeps the search and the
- // selected tool where the user left them.
- var query by remember { mutableStateOf("") }
- var selectedToolKey by remember { mutableStateOf(null) }
+ // Hoisted out of the pane so switching tabs and coming back keeps the search and the
+ // selected tool where the user left them.
+ var query by remember { mutableStateOf("") }
+ var selectedToolKey by remember { mutableStateOf(null) }
- when (selectedTab) {
- McpToolsTab.Tools -> McpToolsPane(
- toolRows = uiState.toolRows,
- query = query,
- onQueryChange = { query = it },
- selectedToolKey = selectedToolKey,
- onSelectTool = { selectedToolKey = it },
- modifier = Modifier.weight(1f),
- )
+ when (selectedTab) {
+ McpToolsTab.Tools -> McpToolsPane(
+ toolRows = uiState.toolRows,
+ query = query,
+ onQueryChange = { query = it },
+ selectedToolKey = selectedToolKey,
+ onSelectTool = { selectedToolKey = it },
+ modifier = Modifier.weight(1f),
+ )
- McpToolsTab.History -> McpCallHistoryPane(
- callHistory = uiState.callHistory,
- modifier = Modifier.weight(1f),
- )
+ McpToolsTab.History -> McpCallHistoryPane(
+ callHistory = uiState.callHistory,
+ modifier = Modifier.weight(1f),
+ )
+ }
}
}
}
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt
index b5e77ef6..9ae5327b 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt
@@ -17,7 +17,6 @@ import com.kitakkun.jetwhale.host.Res
import com.kitakkun.jetwhale.host.di.JetWhaleAppGraph
import com.kitakkun.jetwhale.host.drawer.McpToolsScreenRoot
import com.kitakkun.jetwhale.host.log_viewer_window_title
-import com.kitakkun.jetwhale.host.mcp_tools_window_title
import com.kitakkun.jetwhale.host.plugin.PluginScreenRoot
import com.kitakkun.jetwhale.host.screen.EmptyPluginScreen
import com.kitakkun.jetwhale.host.screen.InfoScreen
@@ -190,20 +189,13 @@ fun EntryProviderScope.logViewerEntry() {
context(appGraph: JetWhaleAppGraph)
fun EntryProviderScope.mcpToolsEntry() {
entry(
- metadata = WindowSceneStrategy.window(
- WindowProperties(
- width = 1100.dp,
- height = 750.dp,
+ // The browser sizes itself; the platform default width would squeeze it to a narrow column.
+ metadata = StableDialogSceneStrategy.dialog(
+ dialogProperties = DialogProperties(
+ usePlatformDefaultWidth = false,
),
),
) { navKey ->
- val window = LocalComposeWindow.current
- val windowTitle = stringResource(Res.string.mcp_tools_window_title)
-
- LaunchedEffect(window, windowTitle) {
- window.title = windowTitle
- }
-
context(
retain {
appGraph.mcpToolsScreenContext