tool card UI redesign: semantic icons, inline diff persistence, tool detail page, MCP-friendly titles
Nightly Build / build (push) Successful in 6m38s

This commit is contained in:
2026-07-21 23:39:41 +01:00
parent 8e891fbced
commit c11702c3d3
44 changed files with 1103 additions and 50 deletions
+13
View File
@@ -43,6 +43,12 @@ pub enum ServerEvent {
message_id: i64,
name: String,
arguments: Value,
/// Friendly, static card title ("Edit File", "Read File", or an MCP tool's
/// resolved friendly name). Separate from `name` (the raw LLM function id).
display_name: String,
/// Semantic icon key (`edit`/`read`/`shell`/`mcp`/…) the frontend maps to a
/// glyph + accent color. Never a glyph — the core commits to meaning, not look.
icon: String,
/// Concise human-readable label (≤60 chars): tool + primary argument.
label_short: String,
/// Verbose human-readable label (≤120 chars): tool + all meaningful arguments.
@@ -62,6 +68,13 @@ pub enum ServerEvent {
/// server; the frontend treats an absent/unknown value as plain text, so
/// older clients degrade gracefully.
result_type: String,
/// For a file-write tool: the file content before/after the write, so the
/// card renders the diff inline even for an auto-allowed write (one that
/// never emitted a `PendingWrite`). Absent for non-write tools.
#[serde(skip_serializing_if = "Option::is_none")]
preview_old: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
preview_new: Option<String>,
},
/// A tool call failed. DB status: error.
ToolError {
+24
View File
@@ -72,6 +72,30 @@ pub trait Tool: Send + Sync {
self.name().to_string()
}
/// Friendly, **static** display name for this tool ("Edit File", "Read File"),
/// shown as the card title in the chat UI — separate from [`name`](Self::name),
/// which stays the raw LLM function id. Several tools map to the same friendly
/// verb (e.g. `write_file`/`edit_file`/`insert_at_line` → "Edit File"). The
/// default returns the raw name so unmapped tools still render something.
fn display_name(&self) -> &str {
self.name()
}
/// Semantic icon key for the chat card — **not** a glyph. The frontend maps the
/// key to a concrete icon + accent color (themeable), so the core commits to a
/// meaning, never a look. Known keys: `edit`, `read`, `list`, `search`, `shell`,
/// `subagent`, `image`, `config`, `introspection`. The default derives from
/// [`category`](Self::category).
fn icon(&self) -> &str {
match self.category() {
ToolCategory::Filesystem => "file",
ToolCategory::Shell => "shell",
ToolCategory::Subagent => "subagent",
ToolCategory::Introspection => "introspection",
ToolCategory::Config => "config",
}
}
/// If this invocation targets a single file the user can open in the file
/// viewer, return its path (relative to the project root, or absolute).
/// Tools that target a directory (list/grep) or no file at all return
+55 -5
View File
@@ -11,6 +11,13 @@ pub struct LlmToolCall {
/// payload, e.g. MCP `structuredContent`). Drives frontend rendering.
pub result_type: String,
pub status: String,
/// For a file-write tool: the file content **before**/**after** the write,
/// captured at execution time so the diff renders inline in the chat card and
/// survives a page reload (it was previously only on the transient `PendingWrite`
/// event). `None` for non-write tools, an unreadable path, or content over the
/// size cap (no diff shown then). Only populated by `for_message` (history).
pub preview_old: Option<String>,
pub preview_new: Option<String>,
}
/// Inserts a tool call in `running` state and returns its id.
@@ -55,6 +62,25 @@ pub async fn complete(pool: &SqlitePool, id: i64, result: &str, result_type: &st
Ok(())
}
/// Persists a file-write tool's before/after snapshot (the diff preview) on its row.
/// Both `None` is a valid no-op state (non-write tool, unreadable path, or content
/// over the size cap). Separate from [`complete`] so the status/result write and the
/// preview write stay independent, and so it can run for both the live and resume paths.
pub async fn set_preview(
pool: &SqlitePool,
id: i64,
old: Option<&str>,
new: Option<&str>,
) -> anyhow::Result<()> {
sqlx::query("UPDATE chat_llm_tools SET preview_old = ?, preview_new = ? WHERE id = ?")
.bind(old)
.bind(new)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn fail(pool: &SqlitePool, id: i64, error: &str) -> anyhow::Result<()> {
sqlx::query(
"UPDATE chat_llm_tools SET result = ?, status = 'failed' WHERE id = ?",
@@ -117,13 +143,15 @@ pub async fn pending_for_stack(
Ok(rows.into_iter().map(row_to_tool).collect())
}
/// All tool calls for a single assistant message, ordered chronologically.
/// All tool calls for a single assistant message, ordered chronologically. Unlike
/// [`pending_for_stack`], this also reads the diff-preview columns, so the history
/// projection can re-render a write's diff after a page reload.
pub async fn for_message(
pool: &SqlitePool,
message_id: i64,
) -> anyhow::Result<Vec<LlmToolCall>> {
let rows = sqlx::query_as::<_, (i64, i64, String, Option<String>, Option<String>, String, String)>(
"SELECT id, message_id, name, arguments, result, result_type, status
let rows = sqlx::query_as::<_, (i64, i64, String, Option<String>, Option<String>, String, String, Option<String>, Option<String>)>(
"SELECT id, message_id, name, arguments, result, result_type, status, preview_old, preview_new
FROM chat_llm_tools
WHERE message_id = ?
ORDER BY id ASC",
@@ -132,7 +160,28 @@ pub async fn for_message(
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(row_to_tool).collect())
Ok(rows.into_iter()
.map(|(id, message_id, name, arguments, result, result_type, status, preview_old, preview_new)| {
LlmToolCall { id, message_id, name, arguments, result, result_type, status, preview_old, preview_new }
})
.collect())
}
/// A single tool call by id, with its diff-preview columns. Backs the tool-detail
/// page (`GET /api/tools/{id}`). Returns `None` when the id is unknown in this pool.
pub async fn get(pool: &SqlitePool, id: i64) -> anyhow::Result<Option<LlmToolCall>> {
let row = sqlx::query_as::<_, (i64, i64, String, Option<String>, Option<String>, String, String, Option<String>, Option<String>)>(
"SELECT id, message_id, name, arguments, result, result_type, status, preview_old, preview_new
FROM chat_llm_tools
WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.map(|(id, message_id, name, arguments, result, result_type, status, preview_old, preview_new)| {
LlmToolCall { id, message_id, name, arguments, result, result_type, status, preview_old, preview_new }
}))
}
fn row_to_tool(
@@ -140,5 +189,6 @@ fn row_to_tool(
i64, i64, String, Option<String>, Option<String>, String, String,
),
) -> LlmToolCall {
LlmToolCall { id, message_id, name, arguments, result, result_type, status }
// The resume path (`pending_for_stack`) never needs the diff preview.
LlmToolCall { id, message_id, name, arguments, result, result_type, status, preview_old: None, preview_new: None }
}
+31 -3
View File
@@ -54,6 +54,11 @@ pub struct McpCatalogRow {
pub icon_large_path: Option<String>,
pub friendly_name: Option<String>,
pub description: Option<String>,
/// Manifest-declared friendly tool names: a JSON array of `{name, display_name}`
/// snapshotting the connector's `tools[]` block. Drives the UI card title for an
/// `mcp__<server>__<tool>` call (override > live MCP `title` > prettified name).
/// NULL when the manifest declares none.
pub tool_meta_json: Option<String>,
/// Marketplace build number — the **comparison key** for updates (a feed entry
/// with a higher `version` than this installed one is "update available").
/// Monotonic per connector; `version_string`/`version_release_date` are display
@@ -105,9 +110,26 @@ const SELECT: &str =
script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json, \
deliver_json, role_filter, verify_command, \
verify_script_path, icon_small_path, icon_large_path, friendly_name, \
description, version, version_string, version_release_date, created_at \
description, tool_meta_json, version, version_string, version_release_date, created_at \
FROM mcp_catalog";
/// Parses a catalog row's `tool_meta_json` (a `[{name, display_name}]` array) into
/// the `tool name → display title` override map the runtime feeds into
/// [`McpServerSpec::tool_titles`]. Empty on NULL or malformed JSON — the runtime then
/// falls back to the server's live MCP `title` and finally a prettified raw name.
pub fn parse_tool_titles(tool_meta_json: Option<&str>) -> HashMap<String, String> {
#[derive(serde::Deserialize)]
struct ToolMeta { name: String, display_name: Option<String> }
tool_meta_json
.and_then(|s| serde_json::from_str::<Vec<ToolMeta>>(s).ok())
.map(|metas| {
metas.into_iter()
.filter_map(|m| m.display_name.map(|dn| (m.name, dn)))
.collect()
})
.unwrap_or_default()
}
// ── Reads ────────────────────────────────────────────────────────────────────
pub async fn list(pool: &SqlitePool) -> Result<Vec<McpCatalogRow>> {
@@ -167,6 +189,8 @@ pub struct UpsertCatalog<'a> {
pub icon_large_path: Option<&'a str>,
pub friendly_name: Option<&'a str>,
pub description: Option<&'a str>,
/// JSON array of `{name, display_name}` snapshotting the manifest's `tools[]`.
pub tool_meta_json: Option<String>,
/// Versioning (from the feed). All three `None` for the admin's manual form,
/// which COALESCEs them away rather than blanking an installed entry's version.
pub version: Option<i64>,
@@ -181,8 +205,8 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json,
deliver_json, role_filter, verify_command,
verify_script_path, icon_small_path, icon_large_path, friendly_name, description,
version, version_string, version_release_date)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)
tool_meta_json, version, version_string, version_release_date)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)
ON CONFLICT(name) DO UPDATE SET
scope = excluded.scope,
source = excluded.source,
@@ -208,6 +232,9 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
icon_large_path = COALESCE(excluded.icon_large_path, mcp_catalog.icon_large_path),
friendly_name = excluded.friendly_name,
description = excluded.description,
-- Manifest tool titles are installer-owned like icons: COALESCE so the
-- admin's manual catalog form (which never sends them) can't blank them.
tool_meta_json = COALESCE(excluded.tool_meta_json, mcp_catalog.tool_meta_json),
-- Version fields come from the feed on (re)install; the admin's manual
-- form passes NULL, so COALESCE keeps the installed version rather than
-- wiping it (same rationale as icons above).
@@ -237,6 +264,7 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
.bind(e.icon_large_path)
.bind(e.friendly_name)
.bind(e.description)
.bind(e.tool_meta_json)
.bind(e.version)
.bind(e.version_string)
.bind(e.version_release_date)
+8
View File
@@ -557,6 +557,7 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
icon_large_path TEXT,
friendly_name TEXT,
description TEXT,
tool_meta_json TEXT, -- [{name,display_name}] friendly tool names from the manifest
version INTEGER, -- marketplace build number: the update-comparison key
version_string TEXT, -- semver, display only
version_release_date TEXT, -- ISO date, display only
@@ -569,6 +570,8 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
ensure_column(pool, "mcp_catalog", "oauth_provider", "TEXT").await?;
ensure_column(pool, "mcp_catalog", "oauth_scopes_json", "TEXT").await?;
ensure_column(pool, "mcp_catalog", "deliver_json", "TEXT").await?;
// Manifest-declared friendly tool names (UI card titles) — additive.
ensure_column(pool, "mcp_catalog", "tool_meta_json", "TEXT").await?;
// Versioning columns are additive: the installed `version` integer is compared
// against the feed's to surface "update available" in the marketplace UI.
ensure_column(pool, "mcp_catalog", "version", "INTEGER").await?;
@@ -745,11 +748,16 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
result TEXT,
status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running', 'pending', 'done', 'failed', 'cancelled', 'rejected')),
result_type TEXT NOT NULL DEFAULT 'string' CHECK(result_type IN ('string', 'json')),
preview_old TEXT, -- file-write diff: content before the write
preview_new TEXT, -- file-write diff: content after the write
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await?;
// Diff-preview columns are additive — reach an already-created table in place.
ensure_column(pool, "chat_llm_tools", "preview_old", "TEXT").await?;
ensure_column(pool, "chat_llm_tools", "preview_new", "TEXT").await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_stack_session ON chat_sessions_stack(session_id)",
+39
View File
@@ -44,6 +44,10 @@ pub struct McpManager {
servers: RwLock<HashMap<String, Arc<dyn McpServerClient>>>,
errors: RwLock<HashMap<String, String>>,
descriptions: RwLock<HashMap<String, Option<String>>>,
/// Per-server manifest-declared friendly tool names (`server → tool → title`),
/// the authoritative override for a tool's UI display name (`tool_display_name`).
/// Populated from each spec's `tool_titles` at connect, forgotten on stop.
titles: RwLock<HashMap<String, HashMap<String, String>>>,
notification_tx: mpsc::UnboundedSender<McpNotification>,
/// Feeds per-server diagnostic lines (stderr, `notifications/message`,
/// lifecycle) to the `logs::log_consumer`, which writes `logs/mcp/<name>.log`.
@@ -69,6 +73,7 @@ impl McpManager {
servers: RwLock::new(HashMap::new()),
errors: RwLock::new(HashMap::new()),
descriptions: RwLock::new(HashMap::new()),
titles: RwLock::new(HashMap::new()),
notification_tx,
log_tx,
elicitation_handler: RwLock::new(None),
@@ -179,8 +184,10 @@ impl McpManager {
}
{
let mut descs = self.descriptions.write().unwrap();
let mut titles = self.titles.write().unwrap();
for spec in &specs {
descs.insert(spec.config.name.clone(), spec.description.clone());
titles.insert(spec.config.name.clone(), spec.tool_titles.clone());
}
}
if boot {
@@ -256,6 +263,7 @@ impl McpManager {
self.log_lifecycle(&name, format!("connected — {} tool(s)", tool_names.len()));
self.errors.write().unwrap().remove(&name);
self.descriptions.write().unwrap().insert(name.clone(), spec.description);
self.titles.write().unwrap().insert(name.clone(), spec.tool_titles);
self.servers.write().unwrap().insert(name, client);
Ok(tool_names)
}
@@ -266,6 +274,7 @@ impl McpManager {
self.servers.write().unwrap().remove(name);
self.errors.write().unwrap().remove(name);
self.descriptions.write().unwrap().remove(name);
self.titles.write().unwrap().remove(name);
}
/// Stops **every** running server (each dropped client → `kill_on_drop` kills
@@ -277,6 +286,7 @@ impl McpManager {
self.servers.write().unwrap().clear();
self.errors.write().unwrap().clear();
self.descriptions.write().unwrap().clear();
self.titles.write().unwrap().clear();
}
/// Whether a server by this name currently has a live connection in the
@@ -299,6 +309,18 @@ impl McpManager {
.collect()
}
/// Best friendly name for a tool for UI display: the manifest-declared override
/// (`tool_titles`) wins, else the server's live MCP `title` (2025-06-18+), else
/// `None` — the caller falls back to a prettified raw name. Cheap: an O(tools)
/// scan per call, run once per tool-call event.
pub fn tool_display_name(&self, server: &str, tool: &str) -> Option<String> {
if let Some(t) = self.titles.read().unwrap().get(server).and_then(|m| m.get(tool)) {
return Some(t.clone());
}
self.servers.read().unwrap().get(server)
.and_then(|s| s.tools().iter().find(|t| t.name == tool).and_then(|t| t.title.clone()))
}
pub fn server_descriptions(&self) -> HashMap<String, Option<String>> {
self.descriptions.read().unwrap().clone()
}
@@ -407,6 +429,11 @@ impl McpManager {
pub struct McpServerSpec {
pub config: McpServerConfig,
pub description: Option<String>,
/// Manifest-declared friendly tool names (`tool name → display title`), the
/// authoritative override for a connector's UI display names. Empty for globals
/// and for per-user rows with no catalog `tool_meta_json`; the runtime then
/// falls back to the server's live MCP `title` and finally a prettified name.
pub tool_titles: HashMap<String, String>,
}
fn transport_of(s: &str) -> McpTransport {
@@ -532,6 +559,7 @@ pub fn global_row_spec(row: &crate::db::mcp_global_servers::McpGlobalServerRow)
launch_in: None,
},
description: row.description.clone(),
tool_titles: HashMap::new(),
}
}
@@ -616,6 +644,9 @@ pub fn user_row_spec(
// A per-user connector's description falls back to its catalog name; the
// catalog's friendly description can be injected by the caller if richer.
description: row.catalog_name.clone(),
// Manifest tool titles are loaded from the catalog by `user_row_spec_resolved`,
// which has registry access; the bare sync builder leaves them empty.
tool_titles: HashMap::new(),
}
}
@@ -632,6 +663,14 @@ pub async fn user_row_spec_resolved(
registry: &SqlitePool,
) -> McpServerSpec {
let mut spec = user_row_spec(row, container);
// Manifest-declared friendly tool names (UI card titles): snapshot the catalog's
// `tool_meta_json` for this activation so `tool_display_name` can override the raw
// name. Best-effort — a missing/failed lookup just leaves the live `title` path.
if let Some(catalog_name) = row.catalog_name.as_deref() {
if let Ok(Some(entry)) = crate::db::mcp_catalog::get_by_name(registry, catalog_name).await {
spec.tool_titles = crate::db::mcp_catalog::parse_tool_titles(entry.tool_meta_json.as_deref());
}
}
if let (Some(provider), Some(deliver), Some(refresh)) =
(row.oauth_provider.as_deref(), row.deliver(), row.api_key.as_deref())
{
+15
View File
@@ -25,6 +25,10 @@ pub trait McpProvider: Send + Sync {
fn tools_for(&self, names: &[String]) -> Vec<McpTool>;
fn server_descriptions(&self) -> HashMap<String, Option<String>>;
fn server_infos(&self) -> Vec<Value>;
/// Best friendly name for a `server`/`tool` pair for the chat card (manifest
/// override > live MCP `title` > `None`, the caller then prettifies the raw
/// name). Routed to whichever runtime owns the server.
fn tool_display_name(&self, server: &str, tool: &str) -> Option<String>;
async fn call(&self, server: &str, tool: &str, args: Value) -> Result<ToolResult>;
}
@@ -34,6 +38,9 @@ impl McpProvider for McpManager {
fn tools_for(&self, names: &[String]) -> Vec<McpTool> { McpManager::tools_for(self, names) }
fn server_descriptions(&self) -> HashMap<String, Option<String>> { McpManager::server_descriptions(self) }
fn server_infos(&self) -> Vec<Value> { McpManager::server_infos(self) }
fn tool_display_name(&self, server: &str, tool: &str) -> Option<String> {
McpManager::tool_display_name(self, server, tool)
}
async fn call(&self, server: &str, tool: &str, args: Value) -> Result<ToolResult> {
McpManager::call(self, server, tool, args).await
}
@@ -97,6 +104,14 @@ impl McpProvider for UserMcpView {
v
}
fn tool_display_name(&self, server: &str, tool: &str) -> Option<String> {
if self.accessible_global.contains(server) {
self.global.tool_display_name(server, tool)
} else {
self.user.tool_display_name(server, tool)
}
}
async fn call(&self, server: &str, tool: &str, args: Value) -> Result<ToolResult> {
if self.accessible_global.contains(server) {
self.global.call(server, tool, args).await
@@ -12,11 +12,30 @@ use tokio_util::sync::CancellationToken;
use tracing::warn;
use crate::events::ServerEvent;
use crate::tools::{drive_execution, tool_names as tn, ExecutionOutcome, ToolResult};
use crate::tools::{drive_execution, is_file_write_tool, tool_names as tn, ExecutionOutcome, ToolResult};
use super::ChatSessionHandler;
use super::interface_tools::AgentRunConfig;
/// Max bytes captured per side of a file-write diff preview. Beyond this the side is
/// dropped (`None`) so a huge file never bloats a row or the WS payload — the detail
/// page then shows no diff for it.
const MAX_PREVIEW_BYTES: usize = 256 * 1024;
/// A file-write tool's before/after snapshot, captured by `execute_tool_call` around
/// the write so the diff renders inline and survives a reload (Phase 2). `None` sides
/// mean unreadable / new file / over the cap.
pub(super) struct WritePreview {
pub old: Option<String>,
pub new: Option<String>,
}
/// Drops a captured snapshot over the size cap (a truncated snapshot would render a
/// misleading diff, so omit it entirely).
fn cap_preview(s: Option<String>) -> Option<String> {
s.filter(|c| c.len() <= MAX_PREVIEW_BYTES)
}
/// Whether a tool call is a synchronous sub-agent dispatch, i.e. one intercepted
/// by `execute_tool_call` and routed to `dispatch_sub_agent` rather than the
/// registry. Covers `execute_task` (mode=sync), `execute_subtask`, and the legacy
@@ -30,8 +49,12 @@ pub(super) fn is_sync_sub_agent(tool_name: &str, args: &Value) -> bool {
/// Result of routing a single tool call to its executor.
pub(super) enum DispatchResult {
/// Normal completion / failure / cancellation — the caller records it.
Outcome(ExecutionOutcome),
/// Normal completion / failure / cancellation — the caller records it. `preview`
/// carries a file-write's before/after snapshot (else `None`) for the diff card.
Outcome {
outcome: ExecutionOutcome,
preview: Option<WritePreview>,
},
/// The turn must end now and the tool row must stay `pending`: the
/// `ask_user_clarification` WS channel closed while awaiting an answer. The
/// caller returns `TurnOutcome::Cancelled` **without** recording the tool, so
@@ -84,12 +107,38 @@ impl ChatSessionHandler {
// Unified cancellable path. The execution owns its in-flight state and
// its own stop(); on /stop the work future is dropped (aborting I/O /
// killing the child) and the tool is recorded as Cancelled, not Failed.
match self.build_execution(tool_name, args.clone(), config) {
//
// For a file-write tool, bracket the execution with a before/after
// snapshot so its diff renders inline and survives a reload (Phase 2).
// The reads route memory-vs-disk exactly like the write itself
// (`read_current_content`); `new` is captured only on success.
let write_path = if is_file_write_tool(tool_name) {
args["path"].as_str().map(str::to_string)
} else {
None
};
let preview_old = match &write_path {
Some(p) => cap_preview(self.read_current_content(p).await),
None => None,
};
let outcome = match self.build_execution(tool_name, args.clone(), config) {
Some(exec) => drive_execution(exec.as_ref(), token).await,
None => ExecutionOutcome::Failed(format!("Unknown tool: {tool_name}")),
}
};
let preview = match &write_path {
Some(p) => {
let new = if matches!(outcome, ExecutionOutcome::Completed(_)) {
cap_preview(self.read_current_content(p).await)
} else {
None
};
Some(WritePreview { old: preview_old, new })
}
None => None,
};
return DispatchResult::Outcome { outcome, preview };
};
DispatchResult::Outcome(outcome)
DispatchResult::Outcome { outcome, preview: None }
}
}
@@ -70,17 +70,26 @@ impl<'a> TurnEmitter<'a> {
message_id: i64,
name: String,
arguments: Value,
display_name: String,
icon: String,
label_short: String,
label_full: String,
path: Option<String>,
) {
self.emit(ServerEvent::ToolStart {
tool_call_id, message_id, name, arguments, label_short, label_full, path,
tool_call_id, message_id, name, arguments, display_name, icon, label_short, label_full, path,
}).await;
}
pub(super) async fn tool_done(&self, tool_call_id: i64, result: String, result_type: String) {
self.emit(ServerEvent::ToolDone { tool_call_id, result, result_type }).await;
pub(super) async fn tool_done(
&self,
tool_call_id: i64,
result: String,
result_type: String,
preview_old: Option<String>,
preview_new: Option<String>,
) {
self.emit(ServerEvent::ToolDone { tool_call_id, result, result_type, preview_old, preview_new }).await;
}
pub(super) async fn tool_error(&self, tool_call_id: i64, error: String) {
@@ -207,6 +207,21 @@ impl ChatSessionHandler {
/// Handles a single tool call within a round: persists the call row, emits
/// `ToolStart`, resolves the working directory, runs the approval gate, handles
/// `restart`, dispatches, and records the outcome. Returns [`CallFlow::Continue`]
/// Card metadata (friendly display name + semantic icon key) for a tool call.
/// Delegates to the registry seam [`ToolRegistry::display_meta`], then layers the
/// MCP display-name override on for an `mcp__server__tool` name (manifest title >
/// live MCP `title` > the prettified name the seam already produced). The single
/// place the live loop resolves a card title, mirroring `describe_call`.
pub(super) fn tool_ui_meta(&self, name: &str, args: &serde_json::Value) -> (String, String) {
let mut meta = self.tools.display_meta(name, args);
if let Some((server, tool)) = crate::mcp::parse_mcp_tool_name(name) {
if let Some(friendly) = self.mcp.tool_display_name(server, tool) {
meta.display_name = friendly;
}
}
(meta.display_name, meta.icon)
}
/// to move on to the next call, or [`CallFlow::End`] to end the whole turn.
#[allow(clippy::too_many_arguments)]
async fn handle_tool_call(
@@ -225,10 +240,12 @@ impl ChatSessionHandler {
let args_str = serde_json::to_string(&call.arguments)
.unwrap_or_else(|_| "{}".to_string());
let tool_call_id = chat_llm_tools::append(pool, message_id, &call.name, &args_str).await?;
let (display_name, icon) = self.tool_ui_meta(&call.name, &call.arguments);
em.tool_start(
tool_call_id, message_id,
call.name.clone(),
call.arguments.clone(),
display_name, icon,
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Short),
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Full),
self.tools.target_path(&call.name, &call.arguments),
@@ -252,7 +269,7 @@ impl ChatSessionHandler {
if call.name == tn::RESTART {
info!(session_id = self.session_id, tool_call_id, "restart approved — marking done then exiting");
chat_llm_tools::complete(pool, tool_call_id, "Riavvio avviato.", "string").await?;
em.tool_done(tool_call_id, "Riavvio avviato.".to_string(), "string".to_string()).await;
em.tool_done(tool_call_id, "Riavvio avviato.".to_string(), "string".to_string(), None, None).await;
// Use _exit() to skip C atexit handlers (e.g. Metal GPU cleanup in
// whisper-rs/ggml, which aborts with SIGABRT and yields exit code 134
// instead of 255 — breaking the run.sh restart supervisor).
@@ -262,15 +279,15 @@ impl ChatSessionHandler {
// Route the approved call to its executor. `AbortPending` means the
// clarification WS channel closed — end the turn and leave the tool
// `pending` for resume to re-ask.
let outcome = match self.execute_tool_call(
let (outcome, preview) = match self.execute_tool_call(
stack_id, config, tool_call_id, &call.name, &call.arguments, token, tx,
).await {
DispatchResult::Outcome(o) => o,
DispatchResult::Outcome { outcome, preview } => (outcome, preview),
DispatchResult::AbortPending => return Ok(CallFlow::End(TurnOutcome::Cancelled)),
};
match self.record_tool_outcome(
tool_call_id, &call.name, &call.arguments, outcome, em, Some(all_tool_calls),
tool_call_id, &call.name, &call.arguments, outcome, preview, em, Some(all_tool_calls),
).await? {
RecordFlow::Continue => Ok(CallFlow::Continue),
RecordFlow::Abort => Ok(CallFlow::End(TurnOutcome::Cancelled)),
@@ -312,10 +329,12 @@ impl ChatSessionHandler {
let args_str = serde_json::to_string(&call.arguments)
.unwrap_or_else(|_| "{}".to_string());
let tool_call_id = chat_llm_tools::append(pool, message_id, &call.name, &args_str).await?;
let (display_name, icon) = self.tool_ui_meta(&call.name, &call.arguments);
em.tool_start(
tool_call_id, message_id,
call.name.clone(),
call.arguments.clone(),
display_name, icon,
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Short),
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Full),
self.tools.target_path(&call.name, &call.arguments),
@@ -346,8 +365,9 @@ impl ChatSessionHandler {
Ok(GateOutcome::Proceed) => match self.execute_tool_call(
stack_id, config, tool_call_id, &name, &arguments, token, tx,
).await {
DispatchResult::Outcome(outcome) => Ok(GatedExec::Done { arguments, outcome }),
DispatchResult::AbortPending => Ok(GatedExec::AbortTurn),
// Sub-agent batches never carry a file-write preview.
DispatchResult::Outcome { outcome, .. } => Ok(GatedExec::Done { arguments, outcome }),
DispatchResult::AbortPending => Ok(GatedExec::AbortTurn),
},
Ok(GateOutcome::Rejected) => Ok(GatedExec::Rejected),
Ok(GateOutcome::ChannelClosed) => Ok(GatedExec::AbortTurn),
@@ -371,7 +391,7 @@ impl ChatSessionHandler {
GatedExec::AbortTurn => abort = true,
GatedExec::Done { arguments, outcome } => {
match self.record_tool_outcome(
*tool_call_id, &call.name, &arguments, outcome, em, Some(all_tool_calls),
*tool_call_id, &call.name, &arguments, outcome, None, em, Some(all_tool_calls),
).await? {
RecordFlow::Continue => {}
RecordFlow::Abort => abort = true,
@@ -13,6 +13,7 @@ use crate::db::chat_llm_tools;
use crate::tools::{is_file_write_tool, ExecutionOutcome};
use super::ChatSessionHandler;
use super::dispatch::WritePreview;
use super::emitter::TurnEmitter;
/// Whether the enclosing loop should keep going after an outcome is recorded.
@@ -38,6 +39,7 @@ impl ChatSessionHandler {
tool_name: &str,
args: &Value,
outcome: ExecutionOutcome,
preview: Option<WritePreview>,
em: &TurnEmitter<'_>,
accumulate: Option<&mut Vec<ToolCallEvent>>,
) -> anyhow::Result<RecordFlow> {
@@ -48,6 +50,15 @@ impl ChatSessionHandler {
let kind = result.kind();
debug!(session_id = self.session_id, tool = %tool_name, tool_call_id, result_len = wire.len(), "tool done");
chat_llm_tools::complete(pool, tool_call_id, &wire, kind).await?;
// Persist a file-write's diff snapshot so it re-renders after a reload,
// and carry it on the event so an auto-allowed write shows the diff live.
let (preview_old, preview_new) = match preview {
Some(WritePreview { old, new }) => {
chat_llm_tools::set_preview(pool, tool_call_id, old.as_deref(), new.as_deref()).await?;
(old, new)
}
None => (None, None),
};
if let Some(acc) = accumulate {
if is_file_write_tool(tool_name)
&& let Some(p) = args["path"].as_str()
@@ -61,7 +72,7 @@ impl ChatSessionHandler {
status: "done".to_string(),
});
}
em.tool_done(tool_call_id, wire, kind.to_string()).await;
em.tool_done(tool_call_id, wire, kind.to_string(), preview_old, preview_new).await;
Ok(RecordFlow::Continue)
}
ExecutionOutcome::Failed(msg) => {
@@ -164,7 +164,7 @@ impl ChatSessionHandler {
if is_error {
em.tool_error(parent_tool_call_id, result_str).await;
} else {
em.tool_done(parent_tool_call_id, result_str, "string".to_string()).await;
em.tool_done(parent_tool_call_id, result_str, "string".to_string(), None, None).await;
}
// Now the parent is the deepest active stack.
@@ -305,11 +305,13 @@ impl ChatSessionHandler {
// Re-dispatch it directly so the question is re-asked to the user.
if tc.name == tn::ASK_USER_CLARIFICATION {
info!(session_id = self.session_id, tool_call_id = tc.id, "resume: re-asking clarification question");
let (display_name, icon) = self.tool_ui_meta(&tc.name, &args);
em.tool_start(
tc.id,
tc.message_id,
tc.name.clone(),
args.clone(),
display_name, icon,
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short),
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full),
self.tools.target_path(&tc.name, &args),
@@ -318,7 +320,7 @@ impl ChatSessionHandler {
match result {
Ok(answer) => {
chat_llm_tools::complete(pool, tc.id, &answer, "string").await?;
em.tool_done(tc.id, answer, "string".to_string()).await;
em.tool_done(tc.id, answer, "string".to_string(), None, None).await;
}
Err(e) if matches!(e.downcast_ref::<super::AgentFlowSignal>(), Some(super::AgentFlowSignal::QuestionChannelClosed)) => {
// WS disconnected again mid-resume. Tool stays 'pending' — next resume re-asks.
@@ -335,11 +337,13 @@ impl ChatSessionHandler {
}
// Announce the tool is being re-tried.
let (display_name, icon) = self.tool_ui_meta(&tc.name, &args);
em.tool_start(
tc.id,
tc.message_id,
tc.name.clone(),
args.clone(),
display_name, icon,
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short),
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full),
self.tools.target_path(&tc.name, &args),
@@ -358,7 +362,7 @@ impl ChatSessionHandler {
if tc.name == tn::RESTART {
info!(session_id = self.session_id, tool_call_id = tc.id, "restart approved (resume) — marking done then exiting");
chat_llm_tools::complete(pool, tc.id, "Riavvio avviato.", "string").await?;
em.tool_done(tc.id, "Riavvio avviato.".to_string(), "string".to_string()).await;
em.tool_done(tc.id, "Riavvio avviato.".to_string(), "string".to_string(), None, None).await;
// Use _exit() to skip C atexit handlers (e.g. Metal GPU cleanup in
// whisper-rs/ggml, which aborts with SIGABRT and yields exit code 134
// instead of 255 — breaking the run.sh restart supervisor).
@@ -371,17 +375,18 @@ impl ChatSessionHandler {
// `run_subtask`) through the recursive interception in `dispatch.rs`;
// `build_execution` alone does not know them and would fail with
// "Unknown tool: execute_task". Args are passed through unchanged.
let outcome = match self.execute_tool_call(
let (outcome, preview) = match self.execute_tool_call(
stack_id, config, tc.id, &tc.name, &args, token, tx,
).await {
super::dispatch::DispatchResult::Outcome(o) => o,
super::dispatch::DispatchResult::Outcome { outcome, preview } => (outcome, preview),
// Clarification WS channel closed mid-resume — leave the tool pending
// so the next resume re-asks (mirrors the live turn's AbortPending).
super::dispatch::DispatchResult::AbortPending => return Ok(true),
};
// resume passes `None`: it does not accumulate ToolCallEvents nor re-emit
// FileChanged (only a live turn does). A /stop mid-resume returns Abort.
match self.record_tool_outcome(tc.id, &tc.name, &args, outcome, &em, None).await? {
// resume passes `None` for accumulate: it does not accumulate ToolCallEvents
// nor re-emit FileChanged (only a live turn does). The write preview IS
// persisted so a re-run write's diff survives. A /stop mid-resume returns Abort.
match self.record_tool_outcome(tc.id, &tc.name, &args, outcome, preview, &em, None).await? {
RecordFlow::Continue => {}
RecordFlow::Abort => return Ok(true),
}
+2
View File
@@ -26,6 +26,8 @@ pub struct ExecuteCmd;
impl Tool for ExecuteCmd {
fn name(&self) -> &str { crate::tools::tool_names::EXECUTE_CMD }
fn display_name(&self) -> &str { "Run Command" }
fn icon(&self) -> &str { "shell" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Shell }
fn description(&self) -> &str {
@@ -114,6 +114,8 @@ impl EditFile {
impl Tool for EditFile {
fn name(&self) -> &str { "edit_file" }
fn display_name(&self) -> &str { "Edit File" }
fn icon(&self) -> &str { "edit" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str {
@@ -16,6 +16,8 @@ impl GrepFiles {
impl Tool for GrepFiles {
fn name(&self) -> &str { "grep_files" }
fn display_name(&self) -> &str { "Search" }
fn icon(&self) -> &str { "search" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str {
@@ -48,6 +48,8 @@ fn apply_insert(text: &str, args: &Value, display: &str) -> Result<(String, Stri
impl Tool for InsertAtLine {
fn name(&self) -> &str { "insert_at_line" }
fn display_name(&self) -> &str { "Edit File" }
fn icon(&self) -> &str { "edit" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str {
@@ -25,6 +25,8 @@ impl ListFiles {
impl Tool for ListFiles {
fn name(&self) -> &str { "list_files" }
fn display_name(&self) -> &str { "List Files" }
fn icon(&self) -> &str { "list" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str {
@@ -46,6 +46,8 @@ fn render_hits(store: &str, hits: &[MemoryHit], out: &mut String) {
impl Tool for MemorySearch {
fn name(&self) -> &str { "memory_search" }
fn display_name(&self) -> &str { "Search Memory" }
fn icon(&self) -> &str { "search" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Introspection }
fn description(&self) -> &str {
@@ -49,6 +49,8 @@ fn number_lines(content: &str, start: usize, end_line: Option<usize>, limit: Opt
impl Tool for ReadFile {
fn name(&self) -> &str { "read_file" }
fn display_name(&self) -> &str { "Read File" }
fn icon(&self) -> &str { "read" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str {
@@ -55,6 +55,8 @@ fn apply_replace(content: &str, args: &Value, display: &str) -> Result<(String,
impl Tool for ReplaceLines {
fn name(&self) -> &str { "replace_lines" }
fn display_name(&self) -> &str { "Edit File" }
fn icon(&self) -> &str { "edit" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str {
@@ -66,6 +66,8 @@ fn render_search(text: &str, args: &Value, display: &str) -> Result<String> {
impl Tool for SearchFile {
fn name(&self) -> &str { "search_file" }
fn display_name(&self) -> &str { "Search" }
fn icon(&self) -> &str { "search" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str {
@@ -21,6 +21,8 @@ impl WriteFile {
impl Tool for WriteFile {
fn name(&self) -> &str { "write_file" }
fn display_name(&self) -> &str { "Edit File" }
fn icon(&self) -> &str { "edit" }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str {
@@ -47,6 +47,8 @@ pub struct ImageGenerateTool {
impl Tool for ImageGenerateTool {
fn name(&self) -> &str { "image_generate" }
fn display_name(&self) -> &str { "Generate Image" }
fn icon(&self) -> &str { "image" }
fn category(&self) -> ToolCategory { ToolCategory::Config }
fn description(&self) -> &str {
+65
View File
@@ -62,6 +62,33 @@ pub use core_api::tool::{
pub const MAX_LABEL_SHORT: usize = 60;
pub const MAX_LABEL_FULL: usize = 120;
/// UI metadata for one tool call — the friendly card title plus a **semantic** icon
/// key (never a glyph; the frontend maps the key to an icon + accent color). Computed
/// by [`ToolRegistry::display_meta`], the single seam shared by the live WS event and
/// the history projection so the two can't drift.
#[derive(Debug, Clone)]
pub struct ToolUiMeta {
pub display_name: String,
pub icon: String,
}
/// Turns a raw tool id (`snake_case` / `kebab-case`) into a Title-Cased phrase for a
/// UI label when no friendly name is declared — `list_recent_files` → "List Recent
/// Files". The last-resort fallback for MCP and unknown tools.
pub fn prettify_tool_name(name: &str) -> String {
name.split(|c| c == '_' || c == '-')
.filter(|s| !s.is_empty())
.map(|w| {
let mut chars = w.chars();
match chars.next() {
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
/// Registry of all available tools.
pub struct ToolRegistry {
tools: HashMap<String, Arc<dyn Tool>>,
@@ -173,6 +200,44 @@ impl ToolRegistry {
name.to_string()
}
/// Friendly card metadata (display name + semantic icon key) for any tool call,
/// including non-registry tools. Registry tools delegate to
/// [`Tool::display_name`]/[`Tool::icon`]; sub-agent and interface tools are
/// handled inline; an `mcp__server__tool` name gets `icon = "mcp"` and a
/// prettified tool name — the caller (which holds the [`McpProvider`]) may then
/// override `display_name` with the connector's resolved friendly name.
///
/// [`McpProvider`]: crate::mcp::provider::McpProvider
pub fn display_meta(&self, name: &str, args: &Value) -> ToolUiMeta {
if let Some(tool) = self.tools.get(name) {
return ToolUiMeta {
display_name: tool.display_name().to_string(),
icon: tool.icon().to_string(),
};
}
// Sub-agent delegation tools (InterfaceTools, not in the registry).
if name == tool_names::EXECUTE_TASK
|| name == tool_names::EXECUTE_SUBTASK
|| name == "run_subtask"
{
let dn = match args["agent_id"].as_str().map(str::trim).filter(|s| !s.is_empty()) {
Some(a) => format!("Sub-agent: {a}"),
None => "Sub-agent".to_string(),
};
return ToolUiMeta { display_name: dn, icon: "subagent".to_string() };
}
if name == tool_names::SHOW_FILE_TO_USER {
return ToolUiMeta { display_name: "Show File".to_string(), icon: "read".to_string() };
}
// MCP tool `mcp__server__tool`: default to a prettified tool name; the caller
// overrides with the connector's manifest/`title` friendly name when known.
if let Some(rest) = name.strip_prefix("mcp__") {
let tool = rest.split_once("__").map(|(_, t)| t).unwrap_or(rest);
return ToolUiMeta { display_name: prettify_tool_name(tool), icon: "mcp".to_string() };
}
ToolUiMeta { display_name: prettify_tool_name(name), icon: "tool".to_string() }
}
/// Returns the category of a registered tool, or `None` for unknown tools
/// (MCP tools, interface tools, call_agent, etc.).
pub fn category_of(&self, name: &str) -> Option<ToolCategory> {