Version 0.0.1 #2

Merged
dguiducci merged 42 commits from main into release 2026-07-22 14:44:42 +01:00
44 changed files with 1103 additions and 50 deletions
Showing only changes of commit c11702c3d3 - Show all commits
+13
View File
@@ -43,6 +43,12 @@ pub enum ServerEvent {
message_id: i64, message_id: i64,
name: String, name: String,
arguments: Value, 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. /// Concise human-readable label (≤60 chars): tool + primary argument.
label_short: String, label_short: String,
/// Verbose human-readable label (≤120 chars): tool + all meaningful arguments. /// 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 /// server; the frontend treats an absent/unknown value as plain text, so
/// older clients degrade gracefully. /// older clients degrade gracefully.
result_type: String, 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. /// A tool call failed. DB status: error.
ToolError { ToolError {
+24
View File
@@ -72,6 +72,30 @@ pub trait Tool: Send + Sync {
self.name().to_string() 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 /// 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). /// viewer, return its path (relative to the project root, or absolute).
/// Tools that target a directory (list/grep) or no file at all return /// 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. /// payload, e.g. MCP `structuredContent`). Drives frontend rendering.
pub result_type: String, pub result_type: String,
pub status: 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. /// 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(()) 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<()> { pub async fn fail(pool: &SqlitePool, id: i64, error: &str) -> anyhow::Result<()> {
sqlx::query( sqlx::query(
"UPDATE chat_llm_tools SET result = ?, status = 'failed' WHERE id = ?", "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()) 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( pub async fn for_message(
pool: &SqlitePool, pool: &SqlitePool,
message_id: i64, message_id: i64,
) -> anyhow::Result<Vec<LlmToolCall>> { ) -> anyhow::Result<Vec<LlmToolCall>> {
let rows = sqlx::query_as::<_, (i64, i64, String, Option<String>, Option<String>, String, String)>( 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 "SELECT id, message_id, name, arguments, result, result_type, status, preview_old, preview_new
FROM chat_llm_tools FROM chat_llm_tools
WHERE message_id = ? WHERE message_id = ?
ORDER BY id ASC", ORDER BY id ASC",
@@ -132,7 +160,28 @@ pub async fn for_message(
.fetch_all(pool) .fetch_all(pool)
.await?; .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( fn row_to_tool(
@@ -140,5 +189,6 @@ fn row_to_tool(
i64, i64, String, Option<String>, Option<String>, String, String, i64, i64, String, Option<String>, Option<String>, String, String,
), ),
) -> LlmToolCall { ) -> 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 icon_large_path: Option<String>,
pub friendly_name: Option<String>, pub friendly_name: Option<String>,
pub description: 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 /// Marketplace build number — the **comparison key** for updates (a feed entry
/// with a higher `version` than this installed one is "update available"). /// with a higher `version` than this installed one is "update available").
/// Monotonic per connector; `version_string`/`version_release_date` are display /// 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, \ script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json, \
deliver_json, role_filter, verify_command, \ deliver_json, role_filter, verify_command, \
verify_script_path, icon_small_path, icon_large_path, friendly_name, \ 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"; 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 ──────────────────────────────────────────────────────────────────── // ── Reads ────────────────────────────────────────────────────────────────────
pub async fn list(pool: &SqlitePool) -> Result<Vec<McpCatalogRow>> { 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 icon_large_path: Option<&'a str>,
pub friendly_name: Option<&'a str>, pub friendly_name: Option<&'a str>,
pub description: 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, /// 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. /// which COALESCEs them away rather than blanking an installed entry's version.
pub version: Option<i64>, 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, script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json,
deliver_json, role_filter, verify_command, deliver_json, role_filter, verify_command,
verify_script_path, icon_small_path, icon_large_path, friendly_name, description, verify_script_path, icon_small_path, icon_large_path, friendly_name, description,
version, version_string, version_release_date) 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) 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 ON CONFLICT(name) DO UPDATE SET
scope = excluded.scope, scope = excluded.scope,
source = excluded.source, 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), icon_large_path = COALESCE(excluded.icon_large_path, mcp_catalog.icon_large_path),
friendly_name = excluded.friendly_name, friendly_name = excluded.friendly_name,
description = excluded.description, 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 -- Version fields come from the feed on (re)install; the admin's manual
-- form passes NULL, so COALESCE keeps the installed version rather than -- form passes NULL, so COALESCE keeps the installed version rather than
-- wiping it (same rationale as icons above). -- 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.icon_large_path)
.bind(e.friendly_name) .bind(e.friendly_name)
.bind(e.description) .bind(e.description)
.bind(e.tool_meta_json)
.bind(e.version) .bind(e.version)
.bind(e.version_string) .bind(e.version_string)
.bind(e.version_release_date) .bind(e.version_release_date)
+8
View File
@@ -557,6 +557,7 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
icon_large_path TEXT, icon_large_path TEXT,
friendly_name TEXT, friendly_name TEXT,
description 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 INTEGER, -- marketplace build number: the update-comparison key
version_string TEXT, -- semver, display only version_string TEXT, -- semver, display only
version_release_date TEXT, -- ISO date, 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_provider", "TEXT").await?;
ensure_column(pool, "mcp_catalog", "oauth_scopes_json", "TEXT").await?; ensure_column(pool, "mcp_catalog", "oauth_scopes_json", "TEXT").await?;
ensure_column(pool, "mcp_catalog", "deliver_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 // Versioning columns are additive: the installed `version` integer is compared
// against the feed's to surface "update available" in the marketplace UI. // against the feed's to surface "update available" in the marketplace UI.
ensure_column(pool, "mcp_catalog", "version", "INTEGER").await?; ensure_column(pool, "mcp_catalog", "version", "INTEGER").await?;
@@ -745,11 +748,16 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
result TEXT, result TEXT,
status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running', 'pending', 'done', 'failed', 'cancelled', 'rejected')), 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')), 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')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
)", )",
) )
.execute(pool) .execute(pool)
.await?; .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( sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_stack_session ON chat_sessions_stack(session_id)", "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>>>, servers: RwLock<HashMap<String, Arc<dyn McpServerClient>>>,
errors: RwLock<HashMap<String, String>>, errors: RwLock<HashMap<String, String>>,
descriptions: RwLock<HashMap<String, Option<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>, notification_tx: mpsc::UnboundedSender<McpNotification>,
/// Feeds per-server diagnostic lines (stderr, `notifications/message`, /// Feeds per-server diagnostic lines (stderr, `notifications/message`,
/// lifecycle) to the `logs::log_consumer`, which writes `logs/mcp/<name>.log`. /// lifecycle) to the `logs::log_consumer`, which writes `logs/mcp/<name>.log`.
@@ -69,6 +73,7 @@ impl McpManager {
servers: RwLock::new(HashMap::new()), servers: RwLock::new(HashMap::new()),
errors: RwLock::new(HashMap::new()), errors: RwLock::new(HashMap::new()),
descriptions: RwLock::new(HashMap::new()), descriptions: RwLock::new(HashMap::new()),
titles: RwLock::new(HashMap::new()),
notification_tx, notification_tx,
log_tx, log_tx,
elicitation_handler: RwLock::new(None), elicitation_handler: RwLock::new(None),
@@ -179,8 +184,10 @@ impl McpManager {
} }
{ {
let mut descs = self.descriptions.write().unwrap(); let mut descs = self.descriptions.write().unwrap();
let mut titles = self.titles.write().unwrap();
for spec in &specs { for spec in &specs {
descs.insert(spec.config.name.clone(), spec.description.clone()); descs.insert(spec.config.name.clone(), spec.description.clone());
titles.insert(spec.config.name.clone(), spec.tool_titles.clone());
} }
} }
if boot { if boot {
@@ -256,6 +263,7 @@ impl McpManager {
self.log_lifecycle(&name, format!("connected — {} tool(s)", tool_names.len())); self.log_lifecycle(&name, format!("connected — {} tool(s)", tool_names.len()));
self.errors.write().unwrap().remove(&name); self.errors.write().unwrap().remove(&name);
self.descriptions.write().unwrap().insert(name.clone(), spec.description); 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); self.servers.write().unwrap().insert(name, client);
Ok(tool_names) Ok(tool_names)
} }
@@ -266,6 +274,7 @@ impl McpManager {
self.servers.write().unwrap().remove(name); self.servers.write().unwrap().remove(name);
self.errors.write().unwrap().remove(name); self.errors.write().unwrap().remove(name);
self.descriptions.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 /// Stops **every** running server (each dropped client → `kill_on_drop` kills
@@ -277,6 +286,7 @@ impl McpManager {
self.servers.write().unwrap().clear(); self.servers.write().unwrap().clear();
self.errors.write().unwrap().clear(); self.errors.write().unwrap().clear();
self.descriptions.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 /// Whether a server by this name currently has a live connection in the
@@ -299,6 +309,18 @@ impl McpManager {
.collect() .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>> { pub fn server_descriptions(&self) -> HashMap<String, Option<String>> {
self.descriptions.read().unwrap().clone() self.descriptions.read().unwrap().clone()
} }
@@ -407,6 +429,11 @@ impl McpManager {
pub struct McpServerSpec { pub struct McpServerSpec {
pub config: McpServerConfig, pub config: McpServerConfig,
pub description: Option<String>, 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 { 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, launch_in: None,
}, },
description: row.description.clone(), 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 // 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. // catalog's friendly description can be injected by the caller if richer.
description: row.catalog_name.clone(), 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, registry: &SqlitePool,
) -> McpServerSpec { ) -> McpServerSpec {
let mut spec = user_row_spec(row, container); 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)) = if let (Some(provider), Some(deliver), Some(refresh)) =
(row.oauth_provider.as_deref(), row.deliver(), row.api_key.as_deref()) (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 tools_for(&self, names: &[String]) -> Vec<McpTool>;
fn server_descriptions(&self) -> HashMap<String, Option<String>>; fn server_descriptions(&self) -> HashMap<String, Option<String>>;
fn server_infos(&self) -> Vec<Value>; 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>; 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 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_descriptions(&self) -> HashMap<String, Option<String>> { McpManager::server_descriptions(self) }
fn server_infos(&self) -> Vec<Value> { McpManager::server_infos(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> { async fn call(&self, server: &str, tool: &str, args: Value) -> Result<ToolResult> {
McpManager::call(self, server, tool, args).await McpManager::call(self, server, tool, args).await
} }
@@ -97,6 +104,14 @@ impl McpProvider for UserMcpView {
v 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> { async fn call(&self, server: &str, tool: &str, args: Value) -> Result<ToolResult> {
if self.accessible_global.contains(server) { if self.accessible_global.contains(server) {
self.global.call(server, tool, args).await self.global.call(server, tool, args).await
@@ -12,11 +12,30 @@ use tokio_util::sync::CancellationToken;
use tracing::warn; use tracing::warn;
use crate::events::ServerEvent; 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::ChatSessionHandler;
use super::interface_tools::AgentRunConfig; 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 /// 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 /// by `execute_tool_call` and routed to `dispatch_sub_agent` rather than the
/// registry. Covers `execute_task` (mode=sync), `execute_subtask`, and the legacy /// 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. /// Result of routing a single tool call to its executor.
pub(super) enum DispatchResult { pub(super) enum DispatchResult {
/// Normal completion / failure / cancellation — the caller records it. /// Normal completion / failure / cancellation — the caller records it. `preview`
Outcome(ExecutionOutcome), /// 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 /// The turn must end now and the tool row must stay `pending`: the
/// `ask_user_clarification` WS channel closed while awaiting an answer. The /// `ask_user_clarification` WS channel closed while awaiting an answer. The
/// caller returns `TurnOutcome::Cancelled` **without** recording the tool, so /// 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 // Unified cancellable path. The execution owns its in-flight state and
// its own stop(); on /stop the work future is dropped (aborting I/O / // 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. // 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, Some(exec) => drive_execution(exec.as_ref(), token).await,
None => ExecutionOutcome::Failed(format!("Unknown tool: {tool_name}")), None => ExecutionOutcome::Failed(format!("Unknown tool: {tool_name}")),
}
}; };
DispatchResult::Outcome(outcome) 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, preview: None }
} }
} }
@@ -70,17 +70,26 @@ impl<'a> TurnEmitter<'a> {
message_id: i64, message_id: i64,
name: String, name: String,
arguments: Value, arguments: Value,
display_name: String,
icon: String,
label_short: String, label_short: String,
label_full: String, label_full: String,
path: Option<String>, path: Option<String>,
) { ) {
self.emit(ServerEvent::ToolStart { 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; }).await;
} }
pub(super) async fn tool_done(&self, tool_call_id: i64, result: String, result_type: String) { pub(super) async fn tool_done(
self.emit(ServerEvent::ToolDone { tool_call_id, result, result_type }).await; &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) { 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 /// Handles a single tool call within a round: persists the call row, emits
/// `ToolStart`, resolves the working directory, runs the approval gate, handles /// `ToolStart`, resolves the working directory, runs the approval gate, handles
/// `restart`, dispatches, and records the outcome. Returns [`CallFlow::Continue`] /// `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. /// to move on to the next call, or [`CallFlow::End`] to end the whole turn.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn handle_tool_call( async fn handle_tool_call(
@@ -225,10 +240,12 @@ impl ChatSessionHandler {
let args_str = serde_json::to_string(&call.arguments) let args_str = serde_json::to_string(&call.arguments)
.unwrap_or_else(|_| "{}".to_string()); .unwrap_or_else(|_| "{}".to_string());
let tool_call_id = chat_llm_tools::append(pool, message_id, &call.name, &args_str).await?; 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( em.tool_start(
tool_call_id, message_id, tool_call_id, message_id,
call.name.clone(), call.name.clone(),
call.arguments.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::Short),
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Full), self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Full),
self.tools.target_path(&call.name, &call.arguments), self.tools.target_path(&call.name, &call.arguments),
@@ -252,7 +269,7 @@ impl ChatSessionHandler {
if call.name == tn::RESTART { if call.name == tn::RESTART {
info!(session_id = self.session_id, tool_call_id, "restart approved — marking done then exiting"); 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?; 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 // 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 // whisper-rs/ggml, which aborts with SIGABRT and yields exit code 134
// instead of 255 — breaking the run.sh restart supervisor). // 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 // Route the approved call to its executor. `AbortPending` means the
// clarification WS channel closed — end the turn and leave the tool // clarification WS channel closed — end the turn and leave the tool
// `pending` for resume to re-ask. // `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, stack_id, config, tool_call_id, &call.name, &call.arguments, token, tx,
).await { ).await {
DispatchResult::Outcome(o) => o, DispatchResult::Outcome { outcome, preview } => (outcome, preview),
DispatchResult::AbortPending => return Ok(CallFlow::End(TurnOutcome::Cancelled)), DispatchResult::AbortPending => return Ok(CallFlow::End(TurnOutcome::Cancelled)),
}; };
match self.record_tool_outcome( 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? { ).await? {
RecordFlow::Continue => Ok(CallFlow::Continue), RecordFlow::Continue => Ok(CallFlow::Continue),
RecordFlow::Abort => Ok(CallFlow::End(TurnOutcome::Cancelled)), RecordFlow::Abort => Ok(CallFlow::End(TurnOutcome::Cancelled)),
@@ -312,10 +329,12 @@ impl ChatSessionHandler {
let args_str = serde_json::to_string(&call.arguments) let args_str = serde_json::to_string(&call.arguments)
.unwrap_or_else(|_| "{}".to_string()); .unwrap_or_else(|_| "{}".to_string());
let tool_call_id = chat_llm_tools::append(pool, message_id, &call.name, &args_str).await?; 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( em.tool_start(
tool_call_id, message_id, tool_call_id, message_id,
call.name.clone(), call.name.clone(),
call.arguments.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::Short),
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Full), self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Full),
self.tools.target_path(&call.name, &call.arguments), self.tools.target_path(&call.name, &call.arguments),
@@ -346,7 +365,8 @@ impl ChatSessionHandler {
Ok(GateOutcome::Proceed) => match self.execute_tool_call( Ok(GateOutcome::Proceed) => match self.execute_tool_call(
stack_id, config, tool_call_id, &name, &arguments, token, tx, stack_id, config, tool_call_id, &name, &arguments, token, tx,
).await { ).await {
DispatchResult::Outcome(outcome) => Ok(GatedExec::Done { arguments, outcome }), // Sub-agent batches never carry a file-write preview.
DispatchResult::Outcome { outcome, .. } => Ok(GatedExec::Done { arguments, outcome }),
DispatchResult::AbortPending => Ok(GatedExec::AbortTurn), DispatchResult::AbortPending => Ok(GatedExec::AbortTurn),
}, },
Ok(GateOutcome::Rejected) => Ok(GatedExec::Rejected), Ok(GateOutcome::Rejected) => Ok(GatedExec::Rejected),
@@ -371,7 +391,7 @@ impl ChatSessionHandler {
GatedExec::AbortTurn => abort = true, GatedExec::AbortTurn => abort = true,
GatedExec::Done { arguments, outcome } => { GatedExec::Done { arguments, outcome } => {
match self.record_tool_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? { ).await? {
RecordFlow::Continue => {} RecordFlow::Continue => {}
RecordFlow::Abort => abort = true, RecordFlow::Abort => abort = true,
@@ -13,6 +13,7 @@ use crate::db::chat_llm_tools;
use crate::tools::{is_file_write_tool, ExecutionOutcome}; use crate::tools::{is_file_write_tool, ExecutionOutcome};
use super::ChatSessionHandler; use super::ChatSessionHandler;
use super::dispatch::WritePreview;
use super::emitter::TurnEmitter; use super::emitter::TurnEmitter;
/// Whether the enclosing loop should keep going after an outcome is recorded. /// Whether the enclosing loop should keep going after an outcome is recorded.
@@ -38,6 +39,7 @@ impl ChatSessionHandler {
tool_name: &str, tool_name: &str,
args: &Value, args: &Value,
outcome: ExecutionOutcome, outcome: ExecutionOutcome,
preview: Option<WritePreview>,
em: &TurnEmitter<'_>, em: &TurnEmitter<'_>,
accumulate: Option<&mut Vec<ToolCallEvent>>, accumulate: Option<&mut Vec<ToolCallEvent>>,
) -> anyhow::Result<RecordFlow> { ) -> anyhow::Result<RecordFlow> {
@@ -48,6 +50,15 @@ impl ChatSessionHandler {
let kind = result.kind(); let kind = result.kind();
debug!(session_id = self.session_id, tool = %tool_name, tool_call_id, result_len = wire.len(), "tool done"); 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?; 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 let Some(acc) = accumulate {
if is_file_write_tool(tool_name) if is_file_write_tool(tool_name)
&& let Some(p) = args["path"].as_str() && let Some(p) = args["path"].as_str()
@@ -61,7 +72,7 @@ impl ChatSessionHandler {
status: "done".to_string(), 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) Ok(RecordFlow::Continue)
} }
ExecutionOutcome::Failed(msg) => { ExecutionOutcome::Failed(msg) => {
@@ -164,7 +164,7 @@ impl ChatSessionHandler {
if is_error { if is_error {
em.tool_error(parent_tool_call_id, result_str).await; em.tool_error(parent_tool_call_id, result_str).await;
} else { } 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. // 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. // Re-dispatch it directly so the question is re-asked to the user.
if tc.name == tn::ASK_USER_CLARIFICATION { if tc.name == tn::ASK_USER_CLARIFICATION {
info!(session_id = self.session_id, tool_call_id = tc.id, "resume: re-asking clarification question"); 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( em.tool_start(
tc.id, tc.id,
tc.message_id, tc.message_id,
tc.name.clone(), tc.name.clone(),
args.clone(), args.clone(),
display_name, icon,
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short), self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short),
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full), self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full),
self.tools.target_path(&tc.name, &args), self.tools.target_path(&tc.name, &args),
@@ -318,7 +320,7 @@ impl ChatSessionHandler {
match result { match result {
Ok(answer) => { Ok(answer) => {
chat_llm_tools::complete(pool, tc.id, &answer, "string").await?; 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)) => { 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. // 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. // Announce the tool is being re-tried.
let (display_name, icon) = self.tool_ui_meta(&tc.name, &args);
em.tool_start( em.tool_start(
tc.id, tc.id,
tc.message_id, tc.message_id,
tc.name.clone(), tc.name.clone(),
args.clone(), args.clone(),
display_name, icon,
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short), self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short),
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full), self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full),
self.tools.target_path(&tc.name, &args), self.tools.target_path(&tc.name, &args),
@@ -358,7 +362,7 @@ impl ChatSessionHandler {
if tc.name == tn::RESTART { if tc.name == tn::RESTART {
info!(session_id = self.session_id, tool_call_id = tc.id, "restart approved (resume) — marking done then exiting"); 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?; 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 // 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 // whisper-rs/ggml, which aborts with SIGABRT and yields exit code 134
// instead of 255 — breaking the run.sh restart supervisor). // instead of 255 — breaking the run.sh restart supervisor).
@@ -371,17 +375,18 @@ impl ChatSessionHandler {
// `run_subtask`) through the recursive interception in `dispatch.rs`; // `run_subtask`) through the recursive interception in `dispatch.rs`;
// `build_execution` alone does not know them and would fail with // `build_execution` alone does not know them and would fail with
// "Unknown tool: execute_task". Args are passed through unchanged. // "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, stack_id, config, tc.id, &tc.name, &args, token, tx,
).await { ).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 // Clarification WS channel closed mid-resume — leave the tool pending
// so the next resume re-asks (mirrors the live turn's AbortPending). // so the next resume re-asks (mirrors the live turn's AbortPending).
super::dispatch::DispatchResult::AbortPending => return Ok(true), super::dispatch::DispatchResult::AbortPending => return Ok(true),
}; };
// resume passes `None`: it does not accumulate ToolCallEvents nor re-emit // resume passes `None` for accumulate: it does not accumulate ToolCallEvents
// FileChanged (only a live turn does). A /stop mid-resume returns Abort. // nor re-emit FileChanged (only a live turn does). The write preview IS
match self.record_tool_outcome(tc.id, &tc.name, &args, outcome, &em, None).await? { // 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::Continue => {}
RecordFlow::Abort => return Ok(true), RecordFlow::Abort => return Ok(true),
} }
+2
View File
@@ -26,6 +26,8 @@ pub struct ExecuteCmd;
impl Tool for ExecuteCmd { impl Tool for ExecuteCmd {
fn name(&self) -> &str { crate::tools::tool_names::EXECUTE_CMD } 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 category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Shell }
fn description(&self) -> &str { fn description(&self) -> &str {
@@ -114,6 +114,8 @@ impl EditFile {
impl Tool for EditFile { impl Tool for EditFile {
fn name(&self) -> &str { "edit_file" } 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 category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str { fn description(&self) -> &str {
@@ -16,6 +16,8 @@ impl GrepFiles {
impl Tool for GrepFiles { impl Tool for GrepFiles {
fn name(&self) -> &str { "grep_files" } 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 category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str { fn description(&self) -> &str {
@@ -48,6 +48,8 @@ fn apply_insert(text: &str, args: &Value, display: &str) -> Result<(String, Stri
impl Tool for InsertAtLine { impl Tool for InsertAtLine {
fn name(&self) -> &str { "insert_at_line" } 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 category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str { fn description(&self) -> &str {
@@ -25,6 +25,8 @@ impl ListFiles {
impl Tool for ListFiles { impl Tool for ListFiles {
fn name(&self) -> &str { "list_files" } 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 category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str { fn description(&self) -> &str {
@@ -46,6 +46,8 @@ fn render_hits(store: &str, hits: &[MemoryHit], out: &mut String) {
impl Tool for MemorySearch { impl Tool for MemorySearch {
fn name(&self) -> &str { "memory_search" } 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 category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Introspection }
fn description(&self) -> &str { 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 { impl Tool for ReadFile {
fn name(&self) -> &str { "read_file" } 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 category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str { fn description(&self) -> &str {
@@ -55,6 +55,8 @@ fn apply_replace(content: &str, args: &Value, display: &str) -> Result<(String,
impl Tool for ReplaceLines { impl Tool for ReplaceLines {
fn name(&self) -> &str { "replace_lines" } 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 category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str { fn description(&self) -> &str {
@@ -66,6 +66,8 @@ fn render_search(text: &str, args: &Value, display: &str) -> Result<String> {
impl Tool for SearchFile { impl Tool for SearchFile {
fn name(&self) -> &str { "search_file" } 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 category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str { fn description(&self) -> &str {
@@ -21,6 +21,8 @@ impl WriteFile {
impl Tool for WriteFile { impl Tool for WriteFile {
fn name(&self) -> &str { "write_file" } 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 category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
fn description(&self) -> &str { fn description(&self) -> &str {
@@ -47,6 +47,8 @@ pub struct ImageGenerateTool {
impl Tool for ImageGenerateTool { impl Tool for ImageGenerateTool {
fn name(&self) -> &str { "image_generate" } 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 category(&self) -> ToolCategory { ToolCategory::Config }
fn description(&self) -> &str { 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_SHORT: usize = 60;
pub const MAX_LABEL_FULL: usize = 120; 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. /// Registry of all available tools.
pub struct ToolRegistry { pub struct ToolRegistry {
tools: HashMap<String, Arc<dyn Tool>>, tools: HashMap<String, Arc<dyn Tool>>,
@@ -173,6 +200,44 @@ impl ToolRegistry {
name.to_string() 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 /// Returns the category of a registered tool, or `None` for unknown tools
/// (MCP tools, interface tools, call_agent, etc.). /// (MCP tools, interface tools, call_agent, etc.).
pub fn category_of(&self, name: &str) -> Option<ToolCategory> { pub fn category_of(&self, name: &str) -> Option<ToolCategory> {
+16
View File
@@ -201,6 +201,16 @@ struct VerifySpec {
#[serde(default)] timeout_secs: Option<u64>, #[serde(default)] timeout_secs: Option<u64>,
} }
/// One entry of the manifest's optional `tools[]` block: a friendly display name
/// for a raw MCP tool. Snapshotted into `mcp_catalog.tool_meta_json` and used as the
/// authoritative UI card title (override > live MCP `title` > prettified raw name).
/// Icons are not per-tool — a connector's own icon covers all its tools.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ToolMeta {
name: String,
#[serde(default)] display_name: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)] #[derive(Debug, Clone, Default, Deserialize)]
struct Manifest { struct Manifest {
#[serde(default)] name: Option<String>, #[serde(default)] name: Option<String>,
@@ -226,6 +236,8 @@ struct Manifest {
#[serde(default)] env: Vec<EnvEntry>, #[serde(default)] env: Vec<EnvEntry>,
/// Optional verify-before-save command. /// Optional verify-before-save command.
#[serde(default)] verify: Option<VerifySpec>, #[serde(default)] verify: Option<VerifySpec>,
/// Optional friendly display names for the connector's tools (UI card titles).
#[serde(default)] tools: Vec<ToolMeta>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -762,6 +774,10 @@ pub async fn install(
.llm_short_description .llm_short_description
.as_deref() .as_deref()
.or(h.entry.user_description.as_deref()), .or(h.entry.user_description.as_deref()),
// Snapshot the manifest's friendly tool names for the UI card titles.
tool_meta_json: (!h.manifest.tools.is_empty())
.then(|| serde_json::to_string(&h.manifest.tools).ok())
.flatten(),
// Snapshot the feed's version so a later listing can compare it against a // Snapshot the feed's version so a later listing can compare it against a
// newer feed and surface "update available". Manifest wins over index. // newer feed and surface "update available". Manifest wins over index.
version: h.manifest.version.or(h.entry.version), version: h.manifest.version.or(h.entry.version),
+3
View File
@@ -322,6 +322,9 @@ pub async fn catalog_upsert(
icon_large_path: None, icon_large_path: None,
friendly_name: body.friendly_name.as_deref(), friendly_name: body.friendly_name.as_deref(),
description: body.description.as_deref(), description: body.description.as_deref(),
// Friendly tool names are the feed's to set; the manual form leaves them
// untouched (COALESCE in `upsert`).
tool_meta_json: None,
// Versioning is the feed's to set (marketplace install); the manual form // Versioning is the feed's to set (marketplace install); the manual form
// leaves it untouched (COALESCE in `upsert`). // leaves it untouched (COALESCE in `upsert`).
version: None, version: None,
+2
View File
@@ -68,6 +68,8 @@ pub fn router() -> Router<Arc<Skald>> {
// File attachments: streamed to disk, so the default body-size limit is // File attachments: streamed to disk, so the default body-size limit is
// disabled on this route only. // disabled on this route only.
.route("/{source}/uploads", post(uploads::upload).layer(DefaultBodyLimit::disable())) .route("/{source}/uploads", post(uploads::upload).layer(DefaultBodyLimit::disable()))
// Full execution detail for one tool call (the tool-detail page).
.route("/tools/{tool_call_id}", get(sessions::tool_detail))
// Source-agnostic approval resolve, keyed by globally-unique tool_call_id. // Source-agnostic approval resolve, keyed by globally-unique tool_call_id.
.route("/tools/{tool_call_id}/resolve", post(sessions::resolve_tool)) .route("/tools/{tool_call_id}/resolve", post(sessions::resolve_tool))
// Back-compat alias for older web clients that POST to /web/tools/... // Back-compat alias for older web clients that POST to /web/tools/...
+93 -2
View File
@@ -17,6 +17,7 @@ use std::sync::Arc;
use skald_core::skald::{Skald, UserContext}; use skald_core::skald::{Skald, UserContext};
use skald_core::session::handler::ApprovalDecision; use skald_core::session::handler::ApprovalDecision;
use skald_core::approval::ApprovalManager; use skald_core::approval::ApprovalManager;
use skald_core::mcp::{McpManager, parse_mcp_tool_name};
use skald_core::tools::{ToolRegistry, ToolDescriptionLength, tool_names as tn}; use skald_core::tools::{ToolRegistry, ToolDescriptionLength, tool_names as tn};
use super::{ApiError, guard::AuthUser, require_context}; use super::{ApiError, guard::AuthUser, require_context};
@@ -112,11 +113,35 @@ async fn messages_for_source(skald: &Arc<Skald>, ctx: &UserContext, source: &str
.collect(); .collect();
let mut items: Vec<Value> = Vec::new(); let mut items: Vec<Value> = Vec::new();
build_items(db, skald.tools(), &ctx.approval, &main_stack, &subagent_map, &mut items).await?; build_items(db, skald.tools(), skald.mcp(), &ctx.user_mcp, &ctx.approval, &main_stack, &subagent_map, &mut items).await?;
Ok(Json(items)) Ok(Json(items))
} }
/// Card metadata (friendly display name + semantic icon key) for a persisted tool
/// call, mirroring the live loop's `tool_ui_meta`: the registry seam, with the MCP
/// display-name override layered on (per-user runtime first, then global) for an
/// `mcp__server__tool` name. History resolves against the running runtimes, so the
/// friendly name survives a refresh; a stopped server falls back to the prettified
/// name the seam produced.
fn tool_card_meta(
tools: &ToolRegistry,
global_mcp: &McpManager,
user_mcp: &McpManager,
name: &str,
args: &Value,
) -> (String, String) {
let mut meta = tools.display_meta(name, args);
if let Some((server, tool)) = parse_mcp_tool_name(name) {
if let Some(friendly) = user_mcp.tool_display_name(server, tool)
.or_else(|| global_mcp.tool_display_name(server, tool))
{
meta.display_name = friendly;
}
}
(meta.display_name, meta.icon)
}
// ── POST /api/tools/:tool_call_id/resolve — approve/reject a pending tool ───── // ── POST /api/tools/:tool_call_id/resolve — approve/reject a pending tool ─────
// (source-agnostic; /api/web/tools/... kept as a back-compat alias) // (source-agnostic; /api/web/tools/... kept as a back-compat alias)
@@ -269,6 +294,56 @@ pub async fn resolve_tool(
} }
} }
// ── GET /api/tools/:tool_call_id — full execution detail for the detail page ──
/// One tool call's full record — input args, result, and (for a file-write) the
/// before/after snapshot — for the dedicated tool-detail page. Read from the
/// caller's own pool (the `tool_call_id` is local to it), so ownership is implicit.
pub async fn tool_detail(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ResolveToolPath>,
) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let tc = chat_llm_tools::get(&ctx.pool, p.tool_call_id).await?
.ok_or_else(|| ApiError::not_found(format!("tool_call_id {} not found", p.tool_call_id)))?;
let args: Value = tc.arguments.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null);
// Normalize the DB status the same way as the history projection: an interrupted
// `running` row surfaces as an error, terminal states pass through.
let (status, result, error) = match tc.status.as_str() {
"done" => ("done", tc.result.clone(), None),
"pending" => ("pending", None, None),
"running" => ("error", None, Some("Interrupted.".to_string())),
"cancelled" => ("cancelled", None, tc.result.clone()),
"rejected" => ("rejected", None, tc.result.clone()),
_ => ("error", None, tc.result.clone()),
};
let (display_name, icon) = tool_card_meta(skald.tools(), skald.mcp(), &ctx.user_mcp, &tc.name, &args);
let label_full = skald.tools().describe_call(&tc.name, &args, ToolDescriptionLength::Full);
let target_path = skald.tools().target_path(&tc.name, &args);
Ok(Json(json!({
"tool_call_id": tc.id,
"name": tc.name,
"display_name": display_name,
"icon": icon,
"label_full": label_full,
"path": target_path,
"arguments": args,
"status": status,
"result": result,
"result_type": tc.result_type,
"error": error,
"preview_old": tc.preview_old,
"preview_new": tc.preview_new,
})))
}
// ── GET /api/sessions — list sessions by source (paginated) ────────────────── // ── GET /api/sessions — list sessions by source (paginated) ──────────────────
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -473,10 +548,16 @@ fn build_debug_items<'a>(
let label_short = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short); let label_short = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short);
let label_full = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full); let label_full = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full);
let target_path = tools.target_path(&tc.name, &args); let target_path = tools.target_path(&tc.name, &args);
// Debug view has no MCP runtime handy: use the registry seam
// directly (MCP tools fall back to a prettified name + `mcp`
// icon, no live/manifest title override).
let meta = tools.display_meta(&tc.name, &args);
items.push(json!({ items.push(json!({
"kind": "tool", "kind": "tool",
"tool_call_id": tc.id, "tool_call_id": tc.id,
"name": tc.name, "name": tc.name,
"display_name": meta.display_name,
"icon": meta.icon,
"label_short": label_short, "label_short": label_short,
"label_full": label_full, "label_full": label_full,
"path": target_path, "path": target_path,
@@ -485,6 +566,8 @@ fn build_debug_items<'a>(
"result": result, "result": result,
"result_type": tc.result_type, "result_type": tc.result_type,
"error": error, "error": error,
"preview_old": tc.preview_old,
"preview_new": tc.preview_new,
})); }));
if let Some(sub_stack) = subagent_map.get(&tc.id) { if let Some(sub_stack) = subagent_map.get(&tc.id) {
@@ -513,9 +596,12 @@ fn build_debug_items<'a>(
// ── Recursive message-tree builder ──────────────────────────────────────────── // ── Recursive message-tree builder ────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
fn build_items<'a>( fn build_items<'a>(
db: &'a SqlitePool, db: &'a SqlitePool,
tools: &'a ToolRegistry, tools: &'a ToolRegistry,
global_mcp: &'a McpManager,
user_mcp: &'a McpManager,
approval: &'a ApprovalManager, approval: &'a ApprovalManager,
stack: &'a SessionStack, stack: &'a SessionStack,
subagent_map: &'a HashMap<i64, SessionStack>, subagent_map: &'a HashMap<i64, SessionStack>,
@@ -601,11 +687,14 @@ fn build_items<'a>(
let label_short = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short); let label_short = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short);
let label_full = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full); let label_full = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full);
let target_path = tools.target_path(&tc.name, &args); let target_path = tools.target_path(&tc.name, &args);
let (display_name, icon) = tool_card_meta(tools, global_mcp, user_mcp, &tc.name, &args);
items.push(json!({ items.push(json!({
"kind": "tool", "kind": "tool",
"tool_call_id": tc.id, "tool_call_id": tc.id,
"request_id": request_id, "request_id": request_id,
"name": tc.name, "name": tc.name,
"display_name": display_name,
"icon": icon,
"label_short": label_short, "label_short": label_short,
"label_full": label_full, "label_full": label_full,
"path": target_path, "path": target_path,
@@ -614,6 +703,8 @@ fn build_items<'a>(
"result": result, "result": result,
"result_type": tc.result_type, "result_type": tc.result_type,
"error": error, "error": error,
"preview_old": tc.preview_old,
"preview_new": tc.preview_new,
})); }));
if let Some(sub_stack) = subagent_map.get(&tc.id) { if let Some(sub_stack) = subagent_map.get(&tc.id) {
@@ -624,7 +715,7 @@ fn build_items<'a>(
"depth": sub_stack.depth, "depth": sub_stack.depth,
"done": true, "done": true,
})); }));
build_items(db, tools, approval, sub_stack, subagent_map, items).await?; build_items(db, tools, global_mcp, user_mcp, approval, sub_stack, subagent_map, items).await?;
items.push(json!({ items.push(json!({
"kind": "agent_end", "kind": "agent_end",
"agent_id": sub_stack.agent_id, "agent_id": sub_stack.agent_id,
+4 -1
View File
@@ -32,11 +32,13 @@ import { SessionDetailPage } from './components/session-detail.js';
import { TicSessionsPage } from './components/tic-sessions.js'; import { TicSessionsPage } from './components/tic-sessions.js';
import { ProjectsPage } from './components/projects/index.js'; import { ProjectsPage } from './components/projects/index.js';
import { FileViewerPage } from './components/file-viewer-page.js'; import { FileViewerPage } from './components/file-viewer-page.js';
import { ToolDetailPage } from './components/tool-detail-page.js';
import { SetupPage } from './components/setup-page.js'; import { SetupPage } from './components/setup-page.js';
import { LoginPage } from './components/login-page.js'; import { LoginPage } from './components/login-page.js';
// Register the global `openFile(path)` helper (window.openFile → location.hash). // Register the global `openFile(path)` / `openToolDetail(id)` helpers.
import './lib/open-file.js'; import './lib/open-file.js';
import './lib/open-tool.js';
import { initI18n } from './lib/i18n.js'; import { initI18n } from './lib/i18n.js';
customElements.define('app-topbar', AppTopbar); customElements.define('app-topbar', AppTopbar);
@@ -73,6 +75,7 @@ customElements.define('session-detail-page', SessionDetailPage);
customElements.define('tic-sessions-page', TicSessionsPage); customElements.define('tic-sessions-page', TicSessionsPage);
customElements.define('projects-page', ProjectsPage); customElements.define('projects-page', ProjectsPage);
customElements.define('file-viewer-page', FileViewerPage); customElements.define('file-viewer-page', FileViewerPage);
customElements.define('tool-detail-page', ToolDetailPage);
customElements.define('setup-page', SetupPage); customElements.define('setup-page', SetupPage);
customElements.define('login-page', LoginPage); customElements.define('login-page', LoginPage);
+106 -2
View File
@@ -2,7 +2,93 @@ import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { renderMarkdown } from '../lib/base.js'; import { renderMarkdown } from '../lib/base.js';
import { openFile } from '../lib/open-file.js'; import { openFile } from '../lib/open-file.js';
import { openToolDetail } from '../lib/open-tool.js';
import { t } from '../lib/i18n.js'; import { t } from '../lib/i18n.js';
import { connectorIconUrl } from './shared/connector-common.js';
// ── Tool icons ─────────────────────────────────────────────────────────────────
/**
* Maps a tool's semantic `icon` key (from the backend `Tool::icon`) to a Bootstrap
* glyph + a CSS accent class. The key commits to meaning; the look lives here and in
* `copilot-messages.css` (theme-aware, no hardcoded colors). Unknown keys fall back
* to the generic wrench.
*/
const TOOL_ICON = {
edit: { glyph: 'bi-pencil-square', cls: 'tool-ico--edit' },
read: { glyph: 'bi-file-earmark-text', cls: 'tool-ico--read' },
list: { glyph: 'bi-folder2-open', cls: 'tool-ico--list' },
search: { glyph: 'bi-search', cls: 'tool-ico--search' },
shell: { glyph: 'bi-terminal', cls: 'tool-ico--shell' },
subagent: { glyph: 'bi-diagram-3', cls: 'tool-ico--subagent' },
image: { glyph: 'bi-image', cls: 'tool-ico--image' },
config: { glyph: 'bi-sliders', cls: 'tool-ico--config' },
introspection: { glyph: 'bi-info-circle', cls: 'tool-ico--introspection' },
file: { glyph: 'bi-file-earmark', cls: 'tool-ico--file' },
mcp: { glyph: 'bi-plug', cls: 'tool-ico--mcp' },
tool: { glyph: 'bi-wrench', cls: 'tool-ico--tool' },
};
/** Whether a tool call carries a file-write diff snapshot to render. */
function hasPreview(msg) {
return msg.preview_new != null || msg.preview_old != null;
}
/**
* Whether to render the diff inline on the tool card. Suppressed while a sibling
* `pending_write` card for the same call is present (it already shows the diff an
* approval-gated write). After a reload there is no such card, so the tool card
* becomes the single place the diff lives. `host` may be absent in bare renders.
*/
function showInlineDiff(host, msg) {
if (!hasPreview(msg)) return false;
const siblings = host && host._messages;
if (Array.isArray(siblings)
&& siblings.some(m => m.kind === 'pending_write' && m.tool_call_id === msg.tool_call_id)) {
return false;
}
return true;
}
/** The MCP server name embedded in an `mcp__<server>__<tool>` id, or null. */
function mcpServerOf(name) {
if (typeof name !== 'string' || !name.startsWith('mcp__')) return null;
const rest = name.slice(5);
const i = rest.indexOf('__');
return i === -1 ? rest : rest.slice(0, i);
}
/**
* The leading tool icon for a card. MCP tools show their connector's own icon
* (parsed from the `mcp__server__tool` id), falling back to a plug glyph if the
* connector shipped none; every other tool shows its semantic glyph + accent.
*/
function renderToolIcon(msg) {
const server = mcpServerOf(msg.name);
if (server) {
return html`<span class="copilot-tool-ico-wrap">
<img class="copilot-tool-ico-img" src=${connectorIconUrl(server, 'sm')} alt=""
@error=${(e) => { const w = e.target.closest('.copilot-tool-ico-wrap'); if (w) w.classList.add('img-failed'); }}>
<i class="bi bi-plug copilot-tool-ico tool-ico--mcp"></i>
</span>`;
}
const ic = TOOL_ICON[msg.icon] || TOOL_ICON.tool;
return html`<i class="bi ${ic.glyph} copilot-tool-ico ${ic.cls}"></i>`;
}
/**
* The muted secondary detail beside a tool's friendly title: the target path
* (clickable) or the primary argument. Derived from `label_full` by stripping the
* leading raw tool-name token which the friendly `display_name` now replaces so
* an MCP tool (whose label is just its raw id) shows no redundant secondary.
*/
function toolSecondary(msg) {
let rest = msg.label_full || '';
if (msg.name && rest.startsWith(msg.name)) rest = rest.slice(msg.name.length);
rest = rest.trim();
if (!rest) return nothing;
return html`<span class="copilot-tool-detail">${renderLabel(rest, msg.path)}</span>`;
}
// ── Utilities ──────────────────────────────────────────────────────────────── // ── Utilities ────────────────────────────────────────────────────────────────
@@ -197,9 +283,21 @@ export function renderTool(host, msg) {
<div class="copilot-tool ${isPending ? 'copilot-tool--pending' : ''}"> <div class="copilot-tool ${isPending ? 'copilot-tool--pending' : ''}">
<button class="copilot-tool-header" @click=${() => host._toggleExpand(msg.tool_call_id)}> <button class="copilot-tool-header" @click=${() => host._toggleExpand(msg.tool_call_id)}>
<span class="copilot-tool-status">${statusIcon}</span> <span class="copilot-tool-status">${statusIcon}</span>
<span class="copilot-tool-name">${renderLabel(msg.label_full || msg.name, msg.path)}</span> ${renderToolIcon(msg)}
<span class="copilot-tool-name">
<span class="copilot-tool-title">${msg.display_name || msg.label_full || msg.name}</span>
${toolSecondary(msg)}
</span>
${isPending ? html`<span class="badge bg-warning text-dark ms-2">${t('approval.pending')}</span>` : nothing} ${isPending ? html`<span class="badge bg-warning text-dark ms-2">${t('approval.pending')}</span>` : nothing}
<i class="bi bi-chevron-${isOpen ? 'up' : 'down'} ms-auto"></i> ${msg.status !== 'running' ? html`
<span class="copilot-tool-eye ms-auto" role="button" tabindex="0"
title=${t('copilot.view_details')}
@click=${(e) => { e.stopPropagation(); openToolDetail(msg.tool_call_id); }}
@keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); openToolDetail(msg.tool_call_id); } }}>
<i class="bi bi-eye"></i>
</span>
<i class="bi bi-chevron-${isOpen ? 'up' : 'down'}"></i>
` : html`<i class="bi bi-chevron-${isOpen ? 'up' : 'down'} ms-auto"></i>`}
</button> </button>
${isOpen ? html` ${isOpen ? html`
<div class="copilot-tool-body"> <div class="copilot-tool-body">
@@ -209,6 +307,12 @@ export function renderTool(host, msg) {
<pre class="copilot-tool-pre">${argsStr}</pre> <pre class="copilot-tool-pre">${argsStr}</pre>
</div> </div>
` : nothing} ` : nothing}
${showInlineDiff(host, msg) ? html`
<div class="copilot-tool-section">
<span class="copilot-tool-label">${t('copilot.changes')}</span>
<pre class="copilot-diff">${renderDiff(msg.preview_old || '', msg.preview_new || '')}</pre>
</div>
` : nothing}
${isPending ? (msg.name === 'ask_user_clarification' ? html` ${isPending ? (msg.name === 'ask_user_clarification' ? html`
<div class="copilot-approval-actions"> <div class="copilot-approval-actions">
${msg.question_title ? html`<div class="copilot-clarification-title">${msg.question_title}</div>` : nothing} ${msg.question_title ? html`<div class="copilot-clarification-title">${msg.question_title}</div>` : nothing}
+1 -1
View File
@@ -78,7 +78,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
_pageFromHash() { _pageFromHash() {
const m = location.hash.slice(1).match(/^([^/?]+)/); const m = location.hash.slice(1).match(/^([^/?]+)/);
const seg = m ? m[1] : ''; const seg = m ? m[1] : '';
const known = ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer']; const known = ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer', 'tool_detail'];
return known.includes(seg) ? seg : 'home'; return known.includes(seg) ? seg : 'home';
} }
+25 -8
View File
@@ -5,14 +5,16 @@ import './shared/chat-page.js';
import './shared/projects-page.js'; import './shared/projects-page.js';
import './shared/settings-page.js'; import './shared/settings-page.js';
import './shared/file-viewer-mobile.js'; import './shared/file-viewer-mobile.js';
import './shared/tool-detail-mobile.js';
// Sections addressable via the URL hash — same routing style as the desktop // Sections addressable via the URL hash — same routing style as the desktop
// sidebar (web/components/sidebar.js). The native iOS shell and mobile browsers // sidebar (web/components/sidebar.js). The native iOS shell and mobile browsers
// share this router so the URL always reflects the active section: native menu // share this router so the URL always reflects the active section: native menu
// sync, deep links, and back/refresh restoration all flow from one place. // sync, deep links, and back/refresh restoration all flow from one place.
// `file_viewer` is not a tab — it's opened from content (a clickable tool path // `file_viewer` / `tool_detail` are not tabsthey're opened from content (a
// via openFile() → `#file_viewer?path=...`) and so has no bottom-nav entry. // clickable tool path via openFile() → `#file_viewer?path=...`, or a tool card's
const VALID_SECTIONS = ['inbox', 'projects', 'chat', 'notifications', 'settings', 'file_viewer']; // eye via openToolDetail() → `#tool_detail?id=...`) and have no bottom-nav entry.
const VALID_SECTIONS = ['inbox', 'projects', 'chat', 'notifications', 'settings', 'file_viewer', 'tool_detail'];
class MobileApp extends LitElement { class MobileApp extends LitElement {
// No shadow DOM — lets external CSS and Bootstrap Icons apply directly. // No shadow DOM — lets external CSS and Bootstrap Icons apply directly.
@@ -26,6 +28,8 @@ class MobileApp extends LitElement {
_chatLabel: { state: true }, _chatLabel: { state: true },
// File shown by the file_viewer section (from `#file_viewer?path=...`). // File shown by the file_viewer section (from `#file_viewer?path=...`).
_filePath: { state: true }, _filePath: { state: true },
// Tool call shown by the tool_detail section (from `#tool_detail?id=...`).
_toolId: { state: true },
}; };
constructor() { constructor() {
@@ -34,6 +38,7 @@ class MobileApp extends LitElement {
this._chatSource = 'mobile'; this._chatSource = 'mobile';
this._chatLabel = ''; this._chatLabel = '';
this._filePath = null; this._filePath = null;
this._toolId = null;
// id → name cache, so a cold deep-link (#chat/project-<id> opened by the // id → name cache, so a cold deep-link (#chat/project-<id> opened by the
// native shell) can resolve its header label without the project list open. // native shell) can resolve its header label without the project list open.
this._projectLabels = {}; this._projectLabels = {};
@@ -69,9 +74,9 @@ class MobileApp extends LitElement {
// #file_viewer?path=<enc> → section 'file_viewer' showing a file // #file_viewer?path=<enc> → section 'file_viewer' showing a file
_readHash() { _readHash() {
const raw = location.hash.slice(1); const raw = location.hash.slice(1);
if (!raw) return { section: 'chat', projectId: null, filePath: null }; if (!raw) return { section: 'chat', projectId: null, filePath: null, toolId: null };
// Segment ends at the first `/` (project sub-route) or `?` (query, e.g. the // Segment ends at the first `/` (project sub-route) or `?` (query, e.g. the
// file viewer's `?path=`). // file viewer's `?path=` / the tool detail's `?id=`).
const cut = raw.search(/[/?]/); const cut = raw.search(/[/?]/);
const seg = cut === -1 ? raw : raw.slice(0, cut); const seg = cut === -1 ? raw : raw.slice(0, cut);
const section = VALID_SECTIONS.includes(seg) ? seg : 'chat'; const section = VALID_SECTIONS.includes(seg) ? seg : 'chat';
@@ -79,7 +84,13 @@ class MobileApp extends LitElement {
let filePath = null; let filePath = null;
const m = raw.match(/[?&]path=([^&]*)/); const m = raw.match(/[?&]path=([^&]*)/);
if (m) { try { filePath = decodeURIComponent(m[1]); } catch { /* keep null */ } } if (m) { try { filePath = decodeURIComponent(m[1]); } catch { /* keep null */ } }
return { section, projectId: null, filePath }; return { section, projectId: null, filePath, toolId: null };
}
if (section === 'tool_detail') {
let toolId = null;
const m = raw.match(/[?&]id=([^&]*)/);
if (m) { try { toolId = decodeURIComponent(m[1]); } catch { /* keep null */ } }
return { section, projectId: null, filePath: null, toolId };
} }
const slash = raw.indexOf('/'); const slash = raw.indexOf('/');
const sub = slash === -1 ? '' : raw.slice(slash + 1); const sub = slash === -1 ? '' : raw.slice(slash + 1);
@@ -87,13 +98,14 @@ class MobileApp extends LitElement {
if (section === 'chat' && sub.startsWith('project-')) { if (section === 'chat' && sub.startsWith('project-')) {
projectId = sub.slice('project-'.length) || null; projectId = sub.slice('project-'.length) || null;
} }
return { section, projectId, filePath: null }; return { section, projectId, filePath: null, toolId: null };
} }
_applyHash() { _applyHash() {
const { section, projectId, filePath } = this._readHash(); const { section, projectId, filePath, toolId } = this._readHash();
this._section = section; this._section = section;
this._filePath = filePath; this._filePath = filePath;
this._toolId = toolId;
if (projectId) { if (projectId) {
const source = 'project-' + projectId; const source = 'project-' + projectId;
if (this._chatSource !== source) this._chatSource = source; if (this._chatSource !== source) this._chatSource = source;
@@ -198,6 +210,11 @@ class MobileApp extends LitElement {
.path=${this._filePath} .path=${this._filePath}
style=${s === 'file_viewer' ? 'flex:1;min-height:0;overflow:hidden' : 'display:none'} style=${s === 'file_viewer' ? 'flex:1;min-height:0;overflow:hidden' : 'display:none'}
></mobile-file-viewer-page> ></mobile-file-viewer-page>
<mobile-tool-detail-page
.visible=${s === 'tool_detail'}
tool-id=${this._toolId ?? nothing}
style=${s === 'tool_detail' ? 'flex:1;min-height:0;overflow:auto' : 'display:none'}
></mobile-tool-detail-page>
<settings-page <settings-page
.visible=${s === 'settings'} .visible=${s === 'settings'}
style=${s === 'settings' ? 'flex:1;min-height:0;overflow:hidden' : 'display:none'} style=${s === 'settings' ? 'flex:1;min-height:0;overflow:hidden' : 'display:none'}
@@ -0,0 +1,80 @@
import { LitElement, html, nothing } from 'lit';
import { t } from '../../lib/i18n.js';
import { fetchToolDetail, renderToolBody, STATUS_ICON } from './tool-detail-view.js';
/**
* Mobile tool-execution detail page. Same shared engine as the desktop
* `<tool-detail-page>`, but prop-driven: `<mobile-app>` binds `visible` / `toolId`
* from its hash router (`#tool_detail?id=...`) instead of the component listening to
* the hash. The back button returns to the previous mobile section via history.
*/
export class MobileToolDetailPage extends LitElement {
// No shadow DOM — inherit the app's global CSS + Bootstrap Icons.
createRenderRoot() { return this; }
static properties = {
visible: { type: Boolean },
toolId: { attribute: 'tool-id' },
_loading: { state: true },
_error: { state: true },
_tool: { state: true },
};
constructor() {
super();
this.visible = false;
this.toolId = null;
this._loading = false;
this._error = null;
this._tool = null;
}
updated(changed) {
if (changed.has('visible') || changed.has('toolId')) {
if (this.visible && this.toolId != null) this._load();
}
}
async _load() {
this._loading = true;
this._error = null;
this._tool = null;
try {
this._tool = await fetchToolDetail(this.toolId);
} catch (e) {
this._error = e.message || String(e);
} finally {
this._loading = false;
}
}
_back() { history.back(); }
render() {
if (!this.visible) return nothing;
const tl = this._tool;
const si = tl ? (STATUS_ICON[tl.status] || STATUS_ICON.done) : null;
return html`
<div class="mobile-tool-detail tool-detail-page">
<div class="mobile-section-header">
<span class="mobile-section-title">
<button class="chat-page-back" title=${t('fv.back')} @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i>
</button>
<span>
${si ? html`<i class="bi ${si.glyph} ${si.cls} me-1"></i>` : nothing}
${tl ? (tl.display_name || tl.name) : t('tool_detail.title')}
</span>
</span>
</div>
<div class="tool-detail-body">
${this._loading ? html`<div class="tool-detail-muted">${t('common.loading')}</div>` : nothing}
${this._error ? html`<div class="alert alert-danger">${this._error}</div>` : nothing}
${renderToolBody(tl)}
</div>
</div>
`;
}
}
customElements.define('mobile-tool-detail-page', MobileToolDetailPage);
+77
View File
@@ -0,0 +1,77 @@
import { html, nothing } from 'lit';
import { t } from '../../lib/i18n.js';
import { openFile } from '../../lib/open-file.js';
import { renderDiff } from '../copilot-render.js';
// Shared engine for the tool-execution detail view — the fetch + the center-panel
// body — so the desktop (`tool-detail-page.js`) and mobile
// (`shared/tool-detail-mobile.js`) surfaces render identically and only supply
// their own chrome (header / back button).
export const STATUS_ICON = {
done: { glyph: 'bi-check-circle-fill', cls: 'text-success' },
error: { glyph: 'bi-x-circle-fill', cls: 'text-danger' },
cancelled: { glyph: 'bi-slash-circle-fill', cls: 'text-secondary' },
rejected: { glyph: 'bi-shield-fill-x', cls: 'text-warning' },
pending: { glyph: 'bi-hourglass-split', cls: 'text-warning' },
};
function prettyJson(v) {
if (v == null) return '';
try { return JSON.stringify(v, null, 2); }
catch { return String(v); }
}
/** Fetches one tool call's full detail from `GET /api/tools/{id}`. Throws on error. */
export async function fetchToolDetail(id) {
const r = await fetch(`/api/tools/${encodeURIComponent(id)}`, { credentials: 'same-origin' });
if (!r.ok) throw new Error((await r.text()) || `HTTP ${r.status}`);
return r.json();
}
function renderResult(tl) {
if (tl.status === 'error' || tl.status === 'cancelled' || tl.status === 'rejected') {
return html`<pre class="tool-detail-pre tool-detail-pre--error">${tl.error ?? tl.result ?? ''}</pre>`;
}
if (tl.status === 'pending') {
return html`<div class="tool-detail-muted">${t('approval.pending')}</div>`;
}
let body = tl.result ?? '';
if (tl.result_type === 'json') {
try { body = prettyJson(JSON.parse(tl.result ?? 'null')); } catch { /* keep raw */ }
}
return html`<pre class="tool-detail-pre">${body}</pre>`;
}
/** The center-panel body: target path, input args, diff (writes), and result. */
export function renderToolBody(tl) {
if (!tl) return nothing;
const hasPreview = tl.preview_new != null || tl.preview_old != null;
return html`
${tl.path ? html`
<div class="tool-detail-section">
<span class="tool-detail-label">${t('tool_detail.target')}</span>
<button class="tool-detail-path" @click=${() => openFile(tl.path)}>
<i class="bi bi-file-earmark-text me-1"></i>${tl.path}
</button>
</div>
` : nothing}
<div class="tool-detail-section">
<span class="tool-detail-label">${t('tool_detail.input')}</span>
<pre class="tool-detail-pre">${prettyJson(tl.arguments)}</pre>
</div>
${hasPreview ? html`
<div class="tool-detail-section">
<span class="tool-detail-label">${t('copilot.changes')}</span>
<pre class="copilot-diff">${renderDiff(tl.preview_old || '', tl.preview_new || '')}</pre>
</div>
` : nothing}
<div class="tool-detail-section">
<span class="tool-detail-label">${t('copilot.result')}</span>
${renderResult(tl)}
</div>
`;
}
+1 -1
View File
@@ -219,7 +219,7 @@ export class AppSidebar extends I18nMixin(LightElement) {
return m ? `plugin/${m[1]}/${m[2]}` : 'home'; return m ? `plugin/${m[1]}/${m[2]}` : 'home';
} }
// `connector` (singular) is the per-connector detail page, `connectors` the list. // `connector` (singular) is the per-connector detail page, `connectors` the list.
return ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'shared-folders', 'connectors', 'connector', 'plugins', 'plugin-catalog', 'plugin-detail', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home'; return ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'shared-folders', 'connectors', 'connector', 'plugins', 'plugin-catalog', 'plugin-detail', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer', 'tool_detail'].includes(segment) ? segment : 'home';
} }
_tasksSectionFromHash() { _tasksSectionFromHash() {
+99
View File
@@ -0,0 +1,99 @@
import { html, nothing } from 'lit';
import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import { fetchToolDetail, renderToolBody, STATUS_ICON } from './shared/tool-detail-view.js';
const PAGE_ID = 'tool_detail';
function idFromHash() {
const h = location.hash;
const prefix = `#${PAGE_ID}?id=`;
if (!h.startsWith(prefix)) return null;
try {
return decodeURIComponent(h.slice(prefix.length));
} catch {
return null;
}
}
/**
* Desktop tool-execution detail page. Self-routes off the hash
* (`#tool_detail?id=...`), mirroring `file-viewer-page.js`: the sidebar's
* `llm-page-change` event toggles visibility and `hashchange` re-loads. It hydrates
* from `GET /api/tools/{id}` (via the shared `tool-detail-view` engine) so a tool's
* input / result / diff is readable in the center panel even after a page reload.
*/
export class ToolDetailPage extends LightElement {
static properties = {
_open: { state: true },
_loading: { state: true },
_error: { state: true },
_tool: { state: true },
};
constructor() {
super();
this._open = false;
this._loading = false;
this._error = null;
this._tool = null;
}
connectedCallback() {
super.connectedCallback();
window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === PAGE_ID;
this.style.display = this._open ? 'flex' : 'none';
if (this._open) this._loadFromHash();
});
window.addEventListener('hashchange', () => {
if (this._open) this._loadFromHash();
});
}
async _loadFromHash() {
const id = idFromHash();
if (id == null) return;
this._loading = true;
this._error = null;
this._tool = null;
try {
this._tool = await fetchToolDetail(id);
} catch (e) {
this._error = e.message || String(e);
} finally {
this._loading = false;
}
}
_back() { history.back(); }
render() {
if (!this._open) return nothing;
const tl = this._tool;
const si = tl ? (STATUS_ICON[tl.status] || STATUS_ICON.done) : null;
return html`
<div class="llm-page tool-detail-page">
<div class="llm-page-header">
<div class="llm-header-left">
<button class="btn btn-sm btn-outline-secondary back-btn" title=${t('fv.back')} @click=${() => this._back()}>
<i class="bi bi-arrow-left"></i>
</button>
<h2 class="llm-page-title">
${tl ? html`
${si ? html`<i class="bi ${si.glyph} ${si.cls} me-2"></i>` : nothing}
${tl.display_name || tl.name}
` : t('tool_detail.title')}
</h2>
</div>
</div>
<div class="tool-detail-body">
${this._loading ? html`<div class="tool-detail-muted">${t('common.loading')}</div>` : nothing}
${this._error ? html`<div class="alert alert-danger">${this._error}</div>` : nothing}
${renderToolBody(tl)}
</div>
</div>
`;
}
}
+138 -1
View File
@@ -131,13 +131,67 @@
font-size: 0.7rem; font-size: 0.7rem;
} }
/* The leading tool-type icon: a colored glyph, or an MCP connector's own icon. */
.copilot-tool-ico {
flex-shrink: 0;
font-size: 0.9rem;
line-height: 1;
color: var(--tool-generic);
}
.copilot-tool-ico-wrap {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.copilot-tool-ico-img {
width: 15px;
height: 15px;
object-fit: contain;
border-radius: 3px;
}
/* Fallback: hide the glyph unless the connector image failed to load. */
.copilot-tool-ico-wrap .copilot-tool-ico { display: none; }
.copilot-tool-ico-wrap.img-failed .copilot-tool-ico-img { display: none; }
.copilot-tool-ico-wrap.img-failed .copilot-tool-ico { display: inline; }
.tool-ico--edit { color: var(--tool-edit); }
.tool-ico--read { color: var(--tool-read); }
.tool-ico--list { color: var(--tool-list); }
.tool-ico--search { color: var(--tool-search); }
.tool-ico--shell { color: var(--tool-shell); }
.tool-ico--subagent { color: var(--tool-subagent); }
.tool-ico--image { color: var(--tool-image); }
.tool-ico--config { color: var(--tool-config); }
.tool-ico--introspection { color: var(--tool-introspection); }
.tool-ico--mcp { color: var(--tool-mcp); }
.tool-ico--file,
.tool-ico--tool { color: var(--tool-generic); }
.copilot-tool-name { .copilot-tool-name {
font-weight: 400; display: flex;
align-items: baseline;
gap: 0.4rem;
min-width: 0;
overflow: hidden;
}
/* The friendly, static tool title ("Edit File") — the primary label. */
.copilot-tool-title {
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
color: var(--msg-assistant-text);
}
/* The muted secondary detail (target path / command / primary arg). */
.copilot-tool-detail {
font-family: var(--bs-font-monospace); font-family: var(--bs-font-monospace);
font-size: 0.72rem;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
min-width: 0; min-width: 0;
color: var(--placeholder-color);
} }
.copilot-tool-name code { .copilot-tool-name code {
@@ -583,3 +637,86 @@
} }
.attach-chip-remove:hover { background: var(--sidebar-hover); color: #dc2626; } .attach-chip-remove:hover { background: var(--sidebar-hover); color: #dc2626; }
/* ── Tool card "view details" (eye) ────────────────────────────────────────── */
.copilot-tool-eye {
display: inline-flex;
align-items: center;
padding: 0 0.25rem;
color: var(--placeholder-color);
border-radius: 0.25rem;
flex-shrink: 0;
}
.copilot-tool-eye:hover { color: var(--accent); background: var(--accent-soft); }
/* ── Tool-detail page (#tool_detail) ───────────────────────────────────────── */
.tool-detail-page {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
.tool-detail-body {
flex: 1;
overflow-y: auto;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.tool-detail-section {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.tool-detail-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--placeholder-color);
}
.tool-detail-pre {
font-family: var(--bs-font-monospace);
font-size: 0.82rem;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
padding: 0.7rem 0.9rem;
background: rgba(0, 0, 0, 0.04);
border: 1px solid var(--toolbar-border);
border-radius: var(--radius-sm);
overflow-x: auto;
}
.tool-detail-pre--error { color: #dc2626; }
@media (prefers-color-scheme: dark) {
.tool-detail-pre { background: rgba(255, 255, 255, 0.04); }
}
.tool-detail-muted { color: var(--placeholder-color); font-size: 0.85rem; }
.tool-detail-path {
align-self: flex-start;
font-family: var(--bs-font-monospace);
font-size: 0.82rem;
color: var(--accent);
background: var(--accent-soft);
border: none;
border-radius: var(--radius-sm);
padding: 0.3rem 0.6rem;
cursor: pointer;
}
.tool-detail-path:hover { text-decoration: underline; }
/* The detail page's diff fills the panel rather than the compact card height. */
.tool-detail-page .copilot-diff {
max-height: none;
font-size: 0.82rem;
}
+26
View File
@@ -12,6 +12,20 @@
--accent-soft: rgba(217, 93, 78, 0.12); --accent-soft: rgba(217, 93, 78, 0.12);
--accent-ring: rgba(217, 93, 78, 0.28); --accent-ring: rgba(217, 93, 78, 0.28);
/* Tool-card accents one hue per tool category (semantic icon key).
Light mode; the dark block below lifts them for contrast. */
--tool-edit: #2563eb;
--tool-read: #0891b2;
--tool-list: #7c3aed;
--tool-search: #9333ea;
--tool-shell: #475569;
--tool-subagent: #0d9488;
--tool-image: #db2777;
--tool-config: #ca8a04;
--tool-introspection: #6b7280;
--tool-mcp: var(--accent);
--tool-generic: #8a7a66;
/* Radius scale — friendly, generous */ /* Radius scale — friendly, generous */
--radius-sm: 8px; --radius-sm: 8px;
--radius-md: 12px; --radius-md: 12px;
@@ -66,6 +80,18 @@
--accent-soft: rgba(232, 131, 111, 0.16); --accent-soft: rgba(232, 131, 111, 0.16);
--accent-ring: rgba(232, 131, 111, 0.35); --accent-ring: rgba(232, 131, 111, 0.35);
/* Tool-card accents — lifted for dark backgrounds */
--tool-edit: #60a5fa;
--tool-read: #22d3ee;
--tool-list: #a78bfa;
--tool-search: #c084fc;
--tool-shell: #94a3b8;
--tool-subagent: #2dd4bf;
--tool-image: #f472b6;
--tool-config: #eab308;
--tool-introspection: #9ca3af;
--tool-generic: #b3a28c;
--sidebar-bg: #241f1a; --sidebar-bg: #241f1a;
--sidebar-hover: rgba(236, 225, 211, 0.06); --sidebar-hover: rgba(236, 225, 211, 0.06);
--sidebar-active-bg: rgba(232, 131, 111, 0.16); --sidebar-active-bg: rgba(232, 131, 111, 0.16);
+5
View File
@@ -91,6 +91,11 @@ export default {
'copilot.not_sent_to_llm': 'This message is not sent to the LLM', 'copilot.not_sent_to_llm': 'This message is not sent to the LLM',
'copilot.remove': 'Remove', 'copilot.remove': 'Remove',
'copilot.result_json': 'result · json', 'copilot.result_json': 'result · json',
'copilot.changes': 'changes',
'copilot.view_details': 'View details',
'tool_detail.title': 'Tool call',
'tool_detail.target': 'target',
'tool_detail.input': 'input',
'copilot.agent_done': 'done', 'copilot.agent_done': 'done',
'copilot.agent_running': 'running…', 'copilot.agent_running': 'running…',
'copilot.agent_finished': 'finished', 'copilot.agent_finished': 'finished',
+5
View File
@@ -91,6 +91,11 @@ export default {
'copilot.not_sent_to_llm': 'Ce message n\'est pas envoyé au LLM', 'copilot.not_sent_to_llm': 'Ce message n\'est pas envoyé au LLM',
'copilot.remove': 'Supprimer', 'copilot.remove': 'Supprimer',
'copilot.result_json': 'résultat · json', 'copilot.result_json': 'résultat · json',
'copilot.changes': 'modifications',
'copilot.view_details': 'Voir les détails',
'tool_detail.title': 'Appel d\'outil',
'tool_detail.target': 'cible',
'tool_detail.input': 'entrée',
'copilot.agent_done': 'terminé', 'copilot.agent_done': 'terminé',
'copilot.agent_running': 'en cours…', 'copilot.agent_running': 'en cours…',
'copilot.agent_finished': 'fini', 'copilot.agent_finished': 'fini',
+5
View File
@@ -91,6 +91,11 @@ export default {
'copilot.not_sent_to_llm': 'Questo messaggio non viene inviato all\'LLM', 'copilot.not_sent_to_llm': 'Questo messaggio non viene inviato all\'LLM',
'copilot.remove': 'Rimuovi', 'copilot.remove': 'Rimuovi',
'copilot.result_json': 'risultato · json', 'copilot.result_json': 'risultato · json',
'copilot.changes': 'modifiche',
'copilot.view_details': 'Vedi dettagli',
'tool_detail.title': 'Chiamata tool',
'tool_detail.target': 'target',
'tool_detail.input': 'input',
'copilot.agent_done': 'completato', 'copilot.agent_done': 'completato',
'copilot.agent_running': 'in esecuzione…', 'copilot.agent_running': 'in esecuzione…',
'copilot.agent_finished': 'finito', 'copilot.agent_finished': 'finito',
+1
View File
@@ -116,6 +116,7 @@
<tic-sessions-page style="display:none"></tic-sessions-page> <tic-sessions-page style="display:none"></tic-sessions-page>
<projects-page style="display:none"></projects-page> <projects-page style="display:none"></projects-page>
<file-viewer-page style="display:none"></file-viewer-page> <file-viewer-page style="display:none"></file-viewer-page>
<tool-detail-page style="display:none"></tool-detail-page>
<app-copilot></app-copilot> <app-copilot></app-copilot>
</div> </div>
</div> </div>
+8 -1
View File
@@ -314,6 +314,8 @@ export class ChatSession extends LightElement {
kind: 'tool', kind: 'tool',
tool_call_id: msg.tool_call_id, tool_call_id: msg.tool_call_id,
name: msg.name, name: msg.name,
display_name: msg.display_name,
icon: msg.icon,
label_short: msg.label_short, label_short: msg.label_short,
label_full: msg.label_full, label_full: msg.label_full,
path: msg.path, path: msg.path,
@@ -327,7 +329,12 @@ export class ChatSession extends LightElement {
} }
case 'tool_done': case 'tool_done':
this._updateTool(msg.tool_call_id, { status: 'done', result: msg.result, result_type: msg.result_type }); // `preview_old`/`preview_new` are present only for a file-write; they let the
// card render the diff inline even for an auto-allowed write (no PendingWrite).
this._updateTool(msg.tool_call_id, {
status: 'done', result: msg.result, result_type: msg.result_type,
preview_old: msg.preview_old, preview_new: msg.preview_new,
});
break; break;
case 'tool_error': case 'tool_error':
+17
View File
@@ -0,0 +1,17 @@
/**
* Global tool-detail opener helper.
*
* `window.openToolDetail(id)` is the single entry point for "show this tool
* call's full input / result / diff in the dedicated detail page". It navigates
* to `#tool_detail?id=<id>`, which the hash router in `sidebar.js` resolves to
* the `<tool-detail-page>` element. Back/forward navigation works naturally.
*
* Mirrors `open-file.js` the URL format lives in one place, so a tool card's
* "details" (eye) affordance calls this rather than setting the hash directly.
*/
export function openToolDetail(id) {
if (id == null) return;
location.hash = `tool_detail?id=${encodeURIComponent(id)}`;
}
window.openToolDetail = openToolDetail;