tool card UI redesign: semantic icons, inline diff persistence, tool detail page, MCP-friendly titles
Nightly Build / build (push) Successful in 6m38s
Nightly Build / build (push) Successful in 6m38s
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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/...
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 tabs — they'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);
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user