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..482f35c0 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,22 @@ 一致するツールがありません パラメータ 必須 + ツール + 履歴 + 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 f9328213..ccd2be5c 100644 --- a/jetwhale-host/app/src/main/composeResources/values/strings.xml +++ b/jetwhale-host/app/src/main/composeResources/values/strings.xml @@ -38,4 +38,22 @@ No matching tools Parameters required + Tools + History + No MCP tool calls yet + Succeeded + Failed + Copy tool name + Copy arguments + Copy details + Copy response + No arguments + Response + No response + Browse MCP tools + Plugin + Session + All + Add filter + Remove filter 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/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/DrawerPluginItemUiState.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt index 43ecaa9b..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,9 +1,7 @@ package com.kitakkun.jetwhale.host.drawer -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, @@ -13,8 +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, -) { - 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 9d4dcd58..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,7 +176,8 @@ fun ExpandedToolingDrawerView( inactiveIconResource = it.inactiveIconResource, selected = it.id == selectedPluginId, underAiControl = it.underAiControl, - mcpTools = it.mcpTools, + exposesMcpTools = it.exposesMcpTools, + onClickMcpBadge = { onOpenMcpTools(it.id) }, onClick = { onClickPlugin(it) }, popupMenuContent = { dismiss -> DropdownMenuItem( @@ -241,7 +249,8 @@ fun ExpandedToolingDrawerView( inactiveIconResource = it.inactiveIconResource, selected = false, underAiControl = it.underAiControl, - mcpTools = it.mcpTools, + exposesMcpTools = it.exposesMcpTools, + onClickMcpBadge = { onOpenMcpTools(it.id) }, onClick = { // do nothing }, @@ -287,7 +296,8 @@ fun ExpandedToolingDrawerView( inactiveIconResource = it.inactiveIconResource, selected = false, underAiControl = it.underAiControl, - mcpTools = it.mcpTools, + 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/McpToolsScreen.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt new file mode 100644 index 00000000..b8ba948b --- /dev/null +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt @@ -0,0 +1,910 @@ +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.FlowRow +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.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 +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.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 com.kitakkun.jetwhale.host.Res +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 +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 +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 kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import org.jetbrains.compose.resources.stringResource +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** Everything the MCP tools browser draws, already narrowed to the selected plugin and session. */ +data class McpToolsScreenUiState( + val pluginOptions: ImmutableList, + val sessionOptions: ImmutableList, + val selectedPluginIds: ImmutableSet, + val selectedSessionIds: ImmutableSet, + 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, +} + +private const val MCP_TOOLS_DIALOG_WINDOW_FRACTION = 0.8f + +@Composable +fun McpToolsScreen( + uiState: McpToolsScreenUiState, + onSelectPluginFilters: (Set) -> Unit, + onSelectSessionFilters: (Set) -> Unit, +) { + 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), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + 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)) + + 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 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), + ) + + McpToolsTab.History -> McpCallHistoryPane( + callHistory = uiState.callHistory, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +/** 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 McpFilterChipGroup( + label: String, + options: ImmutableList, + selectedIds: ImmutableSet, + onSelectionChange: (Set) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val allLabel = stringResource(Res.string.mcp_tools_filter_all) + 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 } + } + + 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), + ) { + 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 + * "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 in scope: search + list on the left, detail right. */ +@Composable +private fun McpToolsPane( + toolRows: ImmutableList, + query: String, + onQueryChange: (String) -> Unit, + selectedToolKey: String?, + onSelectTool: (String) -> Unit, + modifier: Modifier, +) { + val filtered = remember(query, toolRows) { + if (query.isBlank()) { + toolRows + } else { + 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.key == selectedToolKey } ?: filtered.firstOrNull() + + Row(modifier = modifier) { + // Left pane: search + tool list. + Column(modifier = Modifier.width(320.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.key }) { row -> + val isSelected = row.key == selected?.key + 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(row.key) } + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + // 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) + } + } + } + } + } + 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.tool.name.substringAfterLast('.'), + style = MaterialTheme.typography.titleMedium, + fontFamily = FontFamily.Monospace, + ) + Text( + 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.tool.description, + style = MaterialTheme.typography.bodyMedium, + ) + if (selected.tool.parameters.isNotEmpty()) { + Spacer(Modifier.size(4.dp)) + Text( + text = stringResource(Res.string.mcp_tools_parameters), + style = MaterialTheme.typography.labelLarge, + ) + selected.tool.parameters.forEach { param -> McpParameterRow(param) } + } + } + } + } +} + +/** What agents already did in the selected scope, 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(320.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. 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, + 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), + ) { + 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, + 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)) + 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), + 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( + 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), + 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 action 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)) + 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)) + } + } +} + +/** 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, + 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 copyResponseLabel = stringResource(Res.string.mcp_history_copy_response) + 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)) + }, + ) + } + if (record.response.isNotEmpty()) { + add( + ContextMenuItem(copyResponseLabel) { + clipboardManager.setText(AnnotatedString(record.response)) + }, + ) + } + add( + ContextMenuItem(copyDetailsLabel) { + clipboardManager.setText( + AnnotatedString( + buildCallDetails( + toolName = record.toolName, + statusLabel = statusLabel, + finishedAt = finishedAt, + renderedArguments = renderedArguments, + response = record.response, + ), + ), + ) + }, + ) + } + }, + ) { + 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, + response: String, +): String = buildString { + appendLine(toolName) + appendLine(statusLabel) + append(finishedAt) + if (renderedArguments.isNotEmpty()) { + 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. */ +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/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/drawer/McpToolsScreenRoot.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt new file mode 100644 index 00000000..ff9a583e --- /dev/null +++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt @@ -0,0 +1,163 @@ +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.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 + +/** 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}" +} + +/** One value a filter group can be narrowed to. */ +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 -> + // 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 + // 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, + selectedPluginIds = selectedPluginIds, + selectedSessionIds = selectedSessionIds, + runningPluginId = runningInvocation?.pluginId, + runningToolName = runningInvocation?.toolName, + ), + onSelectPluginFilters = { selectedPluginIds = it.toPersistentSet() }, + onSelectSessionFilters = { selectedSessionIds = it.toPersistentSet() }, + ) + } +} + +@Composable +private fun rememberMcpToolsUiState( + mcpCapablePlugins: McpCapablePlugins, + mcpActivity: McpActivity, + debugSessions: ImmutableList, + pluginNamesById: Map, + selectedPluginIds: ImmutableSet, + selectedSessionIds: ImmutableSet, + 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, selectedPluginIds, selectedSessionIds) { + mcpActivity.recentCalls + .filter { selectedPluginIds.isEmpty() || it.pluginId in selectedPluginIds } + .filter { selectedSessionIds.isEmpty() || it.sessionId == null || it.sessionId in selectedSessionIds } + .toImmutableList() + } + + 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 { selectedSessionIds.isEmpty() || it in selectedSessionIds } + .values + .flatMap { toolsByPlugin -> toolsByPlugin.entries } + .filter { selectedPluginIds.isEmpty() || it.key in selectedPluginIds } + .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, + selectedPluginIds = selectedPluginIds, + selectedSessionIds = selectedSessionIds, + toolRows = toolRows, + callHistory = callHistory, + runningToolName = runningToolName, + ) +} 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..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 @@ -5,35 +5,21 @@ 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.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.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.automirrored.filled.OpenInNew 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.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 @@ -44,26 +30,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -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 com.kitakkun.jetwhale.host.Res -import com.kitakkun.jetwhale.host.close -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.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 @Composable fun PluginDrawerItemView( @@ -71,10 +44,11 @@ fun PluginDrawerItemView( name: String, selected: Boolean, underAiControl: Boolean, - mcpTools: ImmutableList, + exposesMcpTools: Boolean, activeIconResource: PluginIconResource?, inactiveIconResource: PluginIconResource?, onClick: () -> Unit, + onClickMcpBadge: () -> Unit, popupMenuContent: (@Composable ColumnScope.(dismiss: () -> Unit) -> Unit)? = null, modifier: Modifier = Modifier, ) { @@ -95,8 +69,11 @@ fun PluginDrawerItemView( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) - if (mcpTools.isNotEmpty()) { - McpBadge(operating = underAiControl, pluginName = name, tools = mcpTools) + if (exposesMcpTools) { + McpBadge( + operating = underAiControl, + onClick = onClickMcpBadge, + ) } } }, @@ -154,15 +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, + 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) { @@ -174,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), @@ -184,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, @@ -192,191 +168,4 @@ private fun McpBadge( modifier = Modifier.size(11.dp), ) } - if (showDialog) { - McpToolsDialog( - operating = operating, - pluginName = pluginName, - tools = tools, - onDismiss = { showDialog = false }, - ) - } -} - -@Composable -private fun McpToolsDialog( - operating: Boolean, - pluginName: String, - tools: ImmutableList, - onDismiss: () -> Unit, -) { - Dialog(onDismissRequest = onDismiss) { - Surface(shape = MaterialTheme.shapes.large) { - Column( - modifier = Modifier - .width(760.dp) - .height(500.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 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) } - } - } - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = onDismiss) { - Text(stringResource(Res.string.close)) - } - } - } - } - } -} - -@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/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 3c76187e..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 @@ -101,7 +101,7 @@ fun toolingScaffoldPresenter( else -> PluginAvailability.Disabled }, underAiControl = aiControlledPluginId == metaData.id, - mcpTools = mcpCapablePlugins.toolsFor(selectedSession?.id, metaData.id).toImmutableList(), + 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) 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/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 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..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 @@ -15,6 +15,7 @@ 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.plugin.PluginScreenRoot import com.kitakkun.jetwhale.host.screen.EmptyPluginScreen @@ -184,3 +185,26 @@ fun EntryProviderScope.logViewerEntry() { } } } + +context(appGraph: JetWhaleAppGraph) +fun EntryProviderScope.mcpToolsEntry() { + entry( + // The browser sizes itself; the platform default width would squeeze it to a narrow column. + metadata = StableDialogSceneStrategy.dialog( + dialogProperties = DialogProperties( + usePlatformDefaultWidth = false, + ), + ), + ) { navKey -> + context( + retain { + appGraph.mcpToolsScreenContext + }, + ) { + McpToolsScreenRoot( + initialPluginId = navKey.pluginId, + initialSessionId = navKey.sessionId, + ) + } + } +} 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..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 @@ -2,6 +2,8 @@ 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 import dev.zacsweers.metro.ContributesBinding @@ -33,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( @@ -51,12 +61,34 @@ class DefaultMcpActivityRepository : McpActivityRepository { return invocationId } - override fun toolInvocationFinished(invocationId: Long) { + 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( 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, + arguments = finished.arguments, + response = truncatedResponse, + ) + (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..2ea14ebb --- /dev/null +++ b/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt @@ -0,0 +1,197 @@ +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 +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", emptyMap()) + repository.toolInvocationFinished(id, failed = false, response = "") + + 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", 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", emptyMap()) + repository.toolInvocationFinished(id, failed = true, response = "") + + 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", emptyMap()) + repository.toolInvocationFinished(id, failed = false, response = "") + } + + 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", emptyMap()) + repository.toolInvocationFinished(id, failed = false, response = "") + } + + 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, response = "") + + 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, response = "") + + 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, response = "") + + 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, response = "") + + 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, 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, response = "") + + 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..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 @@ -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 /** @@ -78,12 +79,52 @@ 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 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) + handler(request).also { + failed = it.isError == true + 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) + 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. + * + * 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 { + 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 39a4f15f..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 @@ -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 @@ -27,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 @@ -411,6 +414,146 @@ 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) + 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 + 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( + 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) + assertEquals("boom", record.response) + } + @OptIn(ExperimentalJetWhaleApi::class) @Test fun `a plugin tool call is attributed to the plugin that owns it`() = runBlocking { @@ -485,6 +628,44 @@ 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"), + ), + ) + } + } +} + +/** 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()) { _ -> 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..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 @@ -2,6 +2,8 @@ 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 import kotlinx.coroutines.flow.MutableStateFlow @@ -33,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 { @@ -51,12 +61,32 @@ class FakeMcpActivityRepository : McpActivityRepository { return invocation.id } - override fun toolInvocationFinished(invocationId: Long) { + 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( 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, + arguments = finished.arguments, + response = truncatedResponse, + ) + (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..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 @@ -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,8 +45,50 @@ 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, ) +/** + * 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, + /** 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 * something other than themselves is driving the debugger. @@ -33,15 +105,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..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 @@ -19,11 +19,27 @@ 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 - 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. + * @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, response: String) /** Drops all recorded activity, so a server restart does not inherit stale connection counts. */ fun clear()