feat: add CLI Apps settings MVP

This commit is contained in:
Xubin Ren 2026-05-22 22:25:12 +08:00
parent a5a956d9af
commit e2d00ffc8f
44 changed files with 4338 additions and 77 deletions

View File

@ -156,9 +156,14 @@ class ContextBuilder:
sender_id: str | None = None,
session_summary: str | None = None,
session_metadata: Mapping[str, Any] | None = None,
current_runtime_lines: Sequence[str] | None = None,
) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call."""
extra = goal_state_runtime_lines(session_metadata)
extra = [
*goal_state_runtime_lines(session_metadata),
]
if current_runtime_lines:
extra.extend(line for line in current_runtime_lines if line)
runtime_ctx = self._build_runtime_context(
channel,
chat_id,

View File

@ -28,6 +28,7 @@ from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.self import MyTool
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.cli_apps import utils as cli_app_utils
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.providers.base import LLMProvider
@ -59,7 +60,6 @@ if TYPE_CHECKING:
UNIFIED_SESSION_KEY = "unified:default"
class TurnState(Enum):
RESTORE = auto()
COMPACT = auto()
@ -568,7 +568,7 @@ class AgentLoop:
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
has_text = isinstance(msg.content, str) and msg.content.strip()
if has_text or media_paths:
extra: dict[str, Any] = {"media": list(media_paths)} if media_paths else {}
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | cli_app_utils.session_extra(msg.metadata)
extra.update(kwargs)
text = msg.content if isinstance(msg.content, str) else ""
session.add_message("user", text, **extra)
@ -593,7 +593,7 @@ class AgentLoop:
chat_id=self._runtime_chat_id(msg),
sender_id=msg.sender_id,
session_summary=pending_summary,
session_metadata=session.metadata,
session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace),
)
async def _dispatch_command_inline(
@ -1058,7 +1058,7 @@ class AgentLoop:
current_role=current_role,
sender_id=msg.sender_id,
session_summary=pending,
session_metadata=session.metadata,
session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace, skip=is_subagent),
)
t_wall = time.time()
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(

View File

@ -0,0 +1,127 @@
"""Controlled runner for installed CLI Apps."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.config.schema import Base
class CliAppsToolConfig(Base):
"""CLI Apps tool configuration."""
enable: bool = True
install_timeout: int = Field(default=300, ge=1, le=3600)
run_timeout: int = Field(default=60, ge=1, le=600)
catalog_ttl_seconds: int = Field(default=3600, ge=60, le=86_400)
@tool_parameters(
tool_parameters_schema(
required=["name"],
name=StringSchema("Installed CLI app registry name, for example gimp, safari, or obsidian."),
args=ArraySchema(
StringSchema("One command-line argument."),
description="Arguments to pass to the CLI entry point. Do not include the entry point itself.",
nullable=True,
),
json=BooleanSchema(
description="Whether to prepend --json when supported by the CLI.",
default=False,
nullable=True,
),
working_dir=StringSchema("Optional working directory for the CLI call.", nullable=True),
timeout=IntegerSchema(
description="Timeout in seconds for this CLI call.",
minimum=1,
maximum=600,
nullable=True,
),
)
)
class CliAppsTool(Tool):
"""Run an installed CLI-Anything or public CLI app through a controlled argv subprocess."""
config_key = "cli_apps"
_scopes = {"core", "subagent"}
@classmethod
def config_cls(cls):
return CliAppsToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
return ctx.config.cli_apps.enable
@classmethod
def create(cls, ctx: Any) -> Tool:
cfg = ctx.config.cli_apps
return cls(
workspace=Path(ctx.workspace),
restrict_to_workspace=ctx.config.restrict_to_workspace,
runtime=CliAppsRuntimeConfig(
install_timeout=cfg.install_timeout,
run_timeout=cfg.run_timeout,
catalog_ttl_seconds=cfg.catalog_ttl_seconds,
),
)
def __init__(
self,
*,
workspace: Path,
restrict_to_workspace: bool = False,
runtime: CliAppsRuntimeConfig | None = None,
) -> None:
self.workspace = workspace
self.restrict_to_workspace = restrict_to_workspace
self.runtime = runtime or CliAppsRuntimeConfig()
@property
def name(self) -> str:
return "run_cli_app"
@property
def description(self) -> str:
try:
installed = CliAppManager(workspace=self.workspace, runtime=self.runtime).installed_names()
except Exception:
installed = []
installed_note = (
f" Installed Settings CLI Apps: {', '.join(installed)}."
if installed
else " No Settings CLI Apps are currently installed."
)
return (
"Run a CLI App that the user explicitly installed in Settings or attached as @app. "
"Do not use this for ordinary system CLIs such as git, gh, python, npm, or brew; "
"unknown names are rejected. Execution uses argv, not shell."
+ installed_note
)
async def execute(
self,
name: str,
args: list[str] | None = None,
json: bool | None = False,
working_dir: str | None = None,
timeout: int | None = None,
) -> str:
manager = CliAppManager(workspace=self.workspace, runtime=self.runtime)
try:
return manager.run(
name,
args=args or [],
json_output=bool(json),
working_dir=working_dir,
timeout=timeout,
restrict_to_workspace=self.restrict_to_workspace,
)
except CliAppError as exc:
return f"Error: {exc.message}"

View File

@ -52,6 +52,11 @@ from nanobot.webui.settings_api import (
update_provider_settings,
update_web_search_settings,
)
from nanobot.webui.cli_apps_api import (
cli_apps_action,
cli_apps_payload,
normalize_cli_app_mentions,
)
from nanobot.webui.sidebar_state import (
read_webui_sidebar_state,
write_webui_sidebar_state,
@ -653,6 +658,21 @@ class WebSocketChannel(BaseChannel):
if got == "/api/settings/image-generation/update":
return self._handle_settings_image_generation_update(request)
if got == "/api/settings/cli-apps":
return self._handle_settings_cli_apps(request)
if got == "/api/settings/cli-apps/install":
return await self._handle_settings_cli_apps_action(request, "install")
if got == "/api/settings/cli-apps/update":
return await self._handle_settings_cli_apps_action(request, "update")
if got == "/api/settings/cli-apps/uninstall":
return await self._handle_settings_cli_apps_action(request, "uninstall")
if got == "/api/settings/cli-apps/test":
return await self._handle_settings_cli_apps_action(request, "test")
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
if m:
return self._handle_session_messages(request, m.group(1))
@ -874,6 +894,32 @@ class WebSocketChannel(BaseChannel):
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
try:
payload = cli_apps_payload()
except Exception:
self.logger.exception("failed to load CLI Apps payload")
return _http_error(500, "failed to load CLI Apps")
return _http_json_response(payload)
async def _handle_settings_cli_apps_action(self, request: WsRequest, action: str) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = await asyncio.to_thread(cli_apps_action, action, query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
except Exception as e:
status = getattr(e, "status", 500)
message = getattr(e, "message", str(e))
if status >= 500:
self.logger.exception("CLI Apps action '{}' failed", action)
return _http_error(status, message)
return _http_json_response(payload)
@staticmethod
def _is_websocket_channel_session_key(key: str) -> bool:
"""True when *key* is a ``websocket:…`` session exposed on this HTTP surface."""
@ -961,6 +1007,9 @@ class WebSocketChannel(BaseChannel):
}
if media:
user_obj["media_paths"] = list(media)
cli_apps = meta.get("cli_apps")
if isinstance(cli_apps, list) and cli_apps:
user_obj["cli_apps"] = cli_apps
self._try_append_webui_transcript(chat_id, user_obj)
await super()._handle_message(
sender_id,
@ -1421,6 +1470,9 @@ class WebSocketChannel(BaseChannel):
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
if envelope.get("webui") is True:
metadata["webui"] = True
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
if cli_apps:
metadata["cli_apps"] = cli_apps
image_generation = envelope.get("image_generation")
if isinstance(image_generation, dict) and image_generation.get("enabled") is True:
aspect_ratio = image_generation.get("aspect_ratio")

View File

@ -0,0 +1,13 @@
"""CLI Apps integration helpers."""
from nanobot.cli_apps.service import (
CliAppError,
CliAppManager,
CliAppsRuntimeConfig,
)
__all__ = [
"CliAppError",
"CliAppManager",
"CliAppsRuntimeConfig",
]

828
nanobot/cli_apps/service.py Normal file
View File

@ -0,0 +1,828 @@
"""CLI-Anything catalog, install state, and safe CLI execution."""
from __future__ import annotations
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import httpx
from nanobot.config.paths import get_runtime_subdir
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
CLI_ANYTHING_RAW_SKILLS_BASE = f"{CLI_ANYTHING_RAW_BASE}/skills/"
_MAX_TOOL_OUTPUT_CHARS = 12_000
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE)
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
class CliAppError(ValueError):
"""User-facing CLI Apps failure."""
def __init__(self, message: str, *, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
@dataclass(slots=True)
class CliAppsRuntimeConfig:
"""Runtime knobs for CLI Apps."""
install_timeout: int = 300
run_timeout: int = 60
catalog_ttl_seconds: int = 3600
_BRANDS: dict[str, tuple[str, str]] = {
"1password-cli": ("1password", "#3B66BC"),
"audacity": ("audacity", "#0000CC"),
"blender": ("blender", "#E87D0D"),
"browser": ("googlechrome", "#4285F4"),
"calibre": ("calibre", "#45B29D"),
"chromadb": ("chroma", "#FFDE2D"),
"comfyui": ("comfyui", "#111827"),
"contentful": ("contentful", "#2478CC"),
"dify": ("dify", "#155EEF"),
"drawio": ("diagramsdotnet", "#F08705"),
"elevenlabs": ("elevenlabs", "#000000"),
"eth2-quickstart": ("ethereum", "#627EEA"),
"firefly-iii": ("fireflyiii", "#CD5029"),
"freecad": ("freecad", "#418FDE"),
"generate-veo-video": ("googlegemini", "#8E75B2"),
"gimp": ("gimp", "#5C5543"),
"godot": ("godotengine", "#478CBF"),
"hacker-feeds-cli": ("rss", "#FFA500"),
"inkscape": ("inkscape", "#000000"),
"intelwatch": ("intel", "#0071C5"),
"iterm2": ("iterm2", "#000000"),
"jimeng": ("bytedance", "#3C8CFF"),
"kdenlive": ("kdenlive", "#527EB2"),
"krita": ("krita", "#3BABFF"),
"libreoffice": ("libreoffice", "#18A303"),
"mailchimp": ("mailchimp", "#FFE01B"),
"mermaid": ("mermaid", "#FF3670"),
"minimax": ("minimax", "#111827"),
"musescore": ("musescore", "#1A70B8"),
"n8n": ("n8n", "#EA4B71"),
"notebooklm": ("googlenotebooklm", "#4285F4"),
"obs-studio": ("obsstudio", "#302E31"),
"obsidian": ("obsidian", "#7C3AED"),
"ollama": ("ollama", "#000000"),
"pm2": ("pm2", "#2B037A"),
"qgis": ("qgis", "#589632"),
"safari": ("safari", "#006CFF"),
"sanity": ("sanity", "#F03E2F"),
"sentry": ("sentry", "#362D59"),
"sketch": ("sketch", "#F7B500"),
"shopify": ("shopify", "#7AB55C"),
"nsight-graphics": ("nvidia", "#76B900"),
"unrealinsights": ("unrealengine", "#0E1128"),
"ueatelier": ("unrealengine", "#0E1128"),
"ve-twini": ("x", "#000000"),
"wecom": ("wechat", "#07C160"),
"suno": ("suno", "#000000"),
"lldb": ("llvm", "#262D3A"),
"android-cli": ("android", "#3DDC84"),
"adguardhome": ("adguard", "#68BC71"),
"zotero": ("zotero", "#CC2936"),
"zoom": ("zoom", "#0B5CFF"),
}
_BRAND_DOMAINS: dict[str, tuple[str, str]] = {
"3mf": ("3mf.io", "#00A1DE"),
"anygen": ("anygen.com", "#111827"),
"clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"),
"cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"),
"cloudcompare": ("cloudcompare.org", "#4D83C3"),
"deployhq": ("deployhq.com", "#00A2D9"),
"exa": ("exa.ai", "#111827"),
"feishu": ("larksuite.com", "#00A5FF"),
"inkstitch": ("inkstitch.org", "#222222"),
"macrocli": ("github.com/HKUDS/CLI-Anything/tree/main/macrocli", "#24292F"),
"mubu": ("mubu.com", "#16A085"),
"nslogger": ("github.com/fpillet/NSLogger", "#24292F"),
"novita": ("novita.ai", "#7C3AED"),
"openscreen": ("openscreen.com", "#2563EB"),
"py4csr": ("github.com/yanmingyu92/py4csr", "#24292F"),
"quietshrink": ("github.com/achiya-automation/quietshrink", "#111827"),
"renderdoc": ("renderdoc.org", "#2C7DB8"),
"rms": ("rms.teltonika-networks.com", "#0054A6"),
"sbox": ("sbox.game", "#F59E0B"),
"seaclip": ("github.com/SeaClip-Lite/SeaClip", "#0284C7"),
"shotcut": ("shotcut.org", "#3B82F6"),
"slay-the-spire-ii": ("megacrit.com", "#B91C1C"),
"stata": ("stata.com", "#1F4E79"),
"unimol-tools": ("github.com/deepmodeling/Uni-Mol", "#4F46E5"),
"videocaptioner": ("github.com/WEIFENG2333/VideoCaptioner", "#2563EB"),
"wiremock": ("wiremock.org", "#FF6A00"),
}
_BRAND_ALIASES: dict[str, str] = {
"1password": "1password-cli",
"dify-workflow": "dify",
"feishu-lark": "feishu",
"lark-cli": "feishu",
"minimax-cli": "minimax",
"obsidian-cli": "obsidian",
"slay-the-spire-2": "slay-the-spire-ii",
"slay-the-spire-ii": "slay-the-spire-ii",
"unimol-tools": "unimol-tools",
"unimol": "unimol-tools",
"veo": "generate-veo-video",
}
_BRAND_TRAILING_WORDS = ("cli", "workflow", "workflows", "app", "apps", "tool", "tools")
def _now() -> float:
return time.time()
def _safe_skill_name(name: str) -> str:
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
return f"cli-app-{clean or 'app'}"
def _has_shell_meta(command: str) -> bool:
return any(char in command for char in _SHELL_META_CHARS)
def _command_exists(command: str) -> bool:
try:
parts = shlex.split(command)
except ValueError:
return False
if not parts:
return False
return shutil.which(parts[0]) is not None
def _is_pip_install_command(command: str) -> bool:
try:
tokens = shlex.split(command)
except ValueError:
return False
return (
len(tokens) >= 3
and tokens[:2] == ["pip", "install"]
) or (
len(tokens) >= 5
and tokens[1:4] == ["-m", "pip", "install"]
and tokens[0] in {"python", "python3", sys.executable}
)
def _pip_uninstall_args_from_command(command: str) -> list[str] | None:
if not command or _has_shell_meta(command):
return None
try:
tokens = shlex.split(command)
except ValueError:
return None
if tokens[:2] == ["pip", "uninstall"]:
args = tokens[2:]
elif (
len(tokens) >= 5
and tokens[1:4] == ["-m", "pip", "uninstall"]
and tokens[0] in {"python", "python3", sys.executable}
):
args = tokens[4:]
else:
return None
packages = [arg for arg in args if arg not in {"-y", "--yes"}]
if not packages or any(arg.startswith("-") for arg in packages):
return None
return packages
def _brand_key(value: str) -> str:
return _SAFE_NAME_RE.sub("-", value.lower()).replace("_", "-").strip("-")
def _brand_candidates(app: dict[str, Any]) -> list[str]:
values = [
str(app.get("name") or ""),
str(app.get("display_name") or ""),
str(app.get("entry_point") or "").removeprefix("cli-anything-"),
]
seen: set[str] = set()
candidates: list[str] = []
for value in values:
key = _brand_key(value)
while key and key not in seen:
seen.add(key)
candidates.append(key)
parts = key.split("-")
if len(parts) <= 1 or parts[-1] not in _BRAND_TRAILING_WORDS:
break
key = "-".join(parts[:-1])
return candidates
def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
brand = None
domain_brand = None
for candidate in _brand_candidates(app):
key = _BRAND_ALIASES.get(candidate, candidate)
brand = _BRANDS.get(key)
if brand:
break
domain_brand = _BRAND_DOMAINS.get(key)
if domain_brand:
break
if not brand:
if not domain_brand:
return None, None
domain, color = domain_brand
return f"https://www.google.com/s2/favicons?domain={domain}&sz=64", color
slug, color = brand
return f"https://cdn.simpleicons.org/{slug}/{color.lstrip('#')}", color
def _read_json(path: Path) -> dict[str, Any] | None:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
def _write_json(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(data, indent=2, ensure_ascii=False)
tmp_path = path.with_name(f".{path.name}.{os.getpid()}.{int(_now() * 1_000_000)}.tmp")
try:
tmp_path.write_text(payload, encoding="utf-8")
tmp_path.replace(path)
finally:
if tmp_path.exists():
tmp_path.unlink()
def _safe_skill_path(value: str) -> str | None:
if not value.startswith("skills/"):
return None
parts = value.split("/")
if any(part in {"", ".", ".."} for part in parts):
return None
return value if parts[-1] == "SKILL.md" else None
def _skill_content_url(skill_md: str) -> str | None:
safe_path = _safe_skill_path(skill_md)
if safe_path:
return f"{CLI_ANYTHING_RAW_BASE}/{safe_path}"
parsed = urlparse(skill_md)
if parsed.scheme != "https" or parsed.netloc != "raw.githubusercontent.com":
return None
if not skill_md.startswith(CLI_ANYTHING_RAW_SKILLS_BASE):
return None
suffix = skill_md.removeprefix(f"{CLI_ANYTHING_RAW_BASE}/")
return skill_md if _safe_skill_path(suffix) else None
def _truncate(text: str, limit: int = _MAX_TOOL_OUTPUT_CHARS) -> str:
if len(text) <= limit:
return text
omitted = len(text) - limit
return text[:limit] + f"\n\n... truncated {omitted} characters ..."
class CliAppManager:
"""Manage CLI-Anything registry entries and local install state."""
def __init__(
self,
*,
workspace: Path,
data_dir: Path | None = None,
runtime: CliAppsRuntimeConfig | None = None,
) -> None:
self.workspace = Path(workspace).expanduser()
self.data_dir = Path(data_dir) if data_dir is not None else get_runtime_subdir("cli-apps")
self.runtime = runtime or CliAppsRuntimeConfig()
@property
def installed_path(self) -> Path:
return self.data_dir / "installed.json"
def _cache_path(self, source: str) -> Path:
return self.data_dir / f"{source}_registry_cache.json"
def _load_installed(self) -> dict[str, Any]:
data = _read_json(self.installed_path) or {}
apps = data.get("apps") if isinstance(data.get("apps"), dict) else data
return apps if isinstance(apps, dict) else {}
def _save_installed(self, installed: dict[str, Any]) -> None:
_write_json(self.installed_path, {"schema_version": 1, "apps": installed})
def installed_names(self) -> list[str]:
"""Return registry names explicitly installed through CLI Apps."""
return sorted(str(name) for name in self._load_installed())
def _fetch_registry(
self,
url: str,
cache_path: Path,
*,
force_refresh: bool = False,
) -> dict[str, Any]:
cached = _read_json(cache_path)
if (
not force_refresh
and cached
and _now() - float(cached.get("_cached_at", 0)) < self.runtime.catalog_ttl_seconds
):
data = cached.get("data")
if isinstance(data, dict):
return data
try:
response = httpx.get(url, timeout=15.0, follow_redirects=True)
response.raise_for_status()
data = response.json()
if not isinstance(data, dict):
raise ValueError("registry response must be an object")
except Exception:
if cached and isinstance(cached.get("data"), dict):
return cached["data"]
raise
_write_json(cache_path, {"_cached_at": _now(), "data": data})
return data
def catalog(self, *, force_refresh: bool = False) -> tuple[list[dict[str, Any]], str | None]:
registries = [
(
"harness",
self._fetch_registry(
CLI_ANYTHING_REGISTRY_URL,
self._cache_path("harness"),
force_refresh=force_refresh,
),
),
(
"public",
self._fetch_registry(
CLI_ANYTHING_PUBLIC_REGISTRY_URL,
self._cache_path("public"),
force_refresh=force_refresh,
),
),
]
apps_by_name: dict[str, dict[str, Any]] = {}
updated_values: list[str] = []
for source, registry in registries:
meta = registry.get("meta")
if isinstance(meta, dict) and isinstance(meta.get("updated"), str):
updated_values.append(meta["updated"])
for row in registry.get("clis", []):
if not isinstance(row, dict) or not row.get("name"):
continue
entry = dict(row)
entry["_source"] = source
key = str(entry["name"]).lower()
previous = apps_by_name.get(key)
if previous:
previous_source = str(previous.get("_source") or source)
merged_source = (
previous_source if previous_source == source else f"{previous_source}+{source}"
)
apps_by_name[key] = {**previous, **entry, "_source": merged_source}
else:
apps_by_name[key] = entry
return list(apps_by_name.values()), max(updated_values) if updated_values else None
def get_app(self, name: str, *, force_refresh: bool = False) -> dict[str, Any]:
wanted = name.lower()
for app in self.catalog(force_refresh=force_refresh)[0]:
if str(app.get("name", "")).lower() == wanted:
return app
raise CliAppError(f"CLI app '{name}' not found", status=404)
def mentioned_installed_apps(self, text: str) -> list[dict[str, str]]:
"""Return installed CLI Apps referenced as ``@name`` in user text."""
if "@" not in text:
return []
installed = self._load_installed()
if not installed:
return []
installed_by_name = {
str(name).lower(): (str(name), data if isinstance(data, dict) else {})
for name, data in installed.items()
}
seen: set[str] = set()
mentions: list[dict[str, str]] = []
for match in _MENTION_RE.finditer(text):
wanted = str(match.group(2)).lower()
if wanted in seen or wanted not in installed_by_name:
continue
installed_name, data = installed_by_name[wanted]
seen.add(wanted)
entry_point = str(data.get("entry_point") or "")
mentions.append(
{
"name": installed_name,
"entry_point": entry_point,
"source": str(data.get("source") or ""),
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
"tool": "run_cli_app",
}
)
return mentions
def _strategy(self, app: dict[str, Any]) -> str:
package_manager = str(app.get("package_manager") or "").lower()
install_strategy = str(app.get("install_strategy") or "").lower()
if package_manager == "bundled" or install_strategy == "bundled":
return "bundled"
if package_manager in {"npm", "brew", "uv", "pip"}:
return package_manager
if app.get("npm_package"):
return "npm"
install_cmd = str(app.get("install_cmd") or "")
if _is_pip_install_command(install_cmd):
return "pip"
return "unsupported"
def _install_supported(self, app: dict[str, Any]) -> bool:
if self._strategy(app) == "unsupported":
return False
install_cmd = str(app.get("install_cmd") or "")
return not _has_shell_meta(install_cmd)
def _skill_path(self, name: str) -> Path:
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
def _app_payload(
self,
app: dict[str, Any],
installed: dict[str, Any],
) -> dict[str, Any]:
name = str(app["name"])
entry_point = str(app.get("entry_point") or "")
install_supported = self._install_supported(app)
is_installed = name in installed
available = bool(entry_point and shutil.which(entry_point))
if is_installed and available:
status = "installed"
elif is_installed:
status = "missing"
elif not install_supported:
status = "unsupported"
elif available:
status = "available"
else:
status = "not_installed"
logo_url, brand_color = _brand_payload(app)
return {
"name": name,
"display_name": app.get("display_name") or name,
"category": app.get("category") or "uncategorized",
"description": app.get("description") or "",
"requires": app.get("requires") or "",
"source": app.get("_source") or "harness",
"entry_point": entry_point,
"install_supported": install_supported,
"installed": is_installed,
"available": available,
"status": status,
"logo_url": logo_url,
"brand_color": brand_color,
"skill_installed": self._skill_path(name).is_file(),
}
def payload(self, *, force_refresh: bool = False) -> dict[str, Any]:
apps, updated = self.catalog(force_refresh=force_refresh)
installed = self._load_installed()
rows = [self._app_payload(app, installed) for app in apps]
rows.sort(key=lambda item: (str(item["category"]), str(item["display_name"]).lower()))
return {
"apps": rows,
"installed_count": sum(1 for item in rows if item["installed"]),
"catalog_updated_at": updated,
}
def _pip_package_from_install(self, app: dict[str, Any]) -> str | None:
install_cmd = str(app.get("install_cmd") or "")
try:
tokens = shlex.split(install_cmd)
except ValueError:
return None
if tokens[:2] == ["pip", "install"]:
args = tokens[2:]
elif len(tokens) >= 5 and tokens[1:4] == ["-m", "pip", "install"]:
args = tokens[4:]
else:
return None
args = [arg for arg in args if not arg.startswith("-")]
if len(args) != 1 or args[0].startswith("git+"):
return None
return args[0]
def _pip_install_argv(self, app: dict[str, Any], *, update: bool = False) -> list[str]:
install_cmd = str(app.get("install_cmd") or "")
if not _is_pip_install_command(install_cmd) or _has_shell_meta(install_cmd):
raise CliAppError("unsupported pip install command")
tokens = shlex.split(install_cmd)
args = tokens[2:] if tokens[:2] == ["pip", "install"] else tokens[4:]
prefix = [sys.executable, "-m", "pip", "install"]
if update:
prefix.extend(["--upgrade", "--force-reinstall"])
return prefix + args
def _pip_uninstall_argv(self, app: dict[str, Any]) -> list[str]:
uninstall_cmd = str(app.get("uninstall_cmd") or "")
packages = _pip_uninstall_args_from_command(uninstall_cmd)
if packages:
return [sys.executable, "-m", "pip", "uninstall", "-y", *packages]
package = str(app.get("pip_package") or "").strip() or self._pip_package_from_install(app)
if not package:
entry_point = str(app.get("entry_point") or "").strip()
package = entry_point if entry_point.startswith("cli-anything-") else f"cli-anything-{_brand_key(str(app['name']))}"
return [sys.executable, "-m", "pip", "uninstall", "-y", package]
def _npm_argv(self, app: dict[str, Any], action: str) -> list[str]:
npm = shutil.which("npm")
if not npm:
raise CliAppError("npm is not installed")
package = str(app.get("npm_package") or "")
if not package:
raise CliAppError("registry entry has no npm_package")
if action == "install":
return [npm, "install", "-g", package]
if action == "update":
return [npm, "install", "-g", package + "@latest"]
return [npm, "uninstall", "-g", package]
def _split_safe_command(self, app: dict[str, Any], key: str, expected: str) -> list[str]:
command = str(app.get(key) or "")
if not command:
raise CliAppError(f"no {key} is defined for {app['name']}")
if _has_shell_meta(command):
raise CliAppError("script-style install commands are disabled in this MVP")
try:
argv = shlex.split(command)
except ValueError as exc:
raise CliAppError(f"invalid command: {exc}") from exc
if not argv or argv[0] != expected:
raise CliAppError(f"unsupported {expected} command")
return argv
def _argv_for_action(self, app: dict[str, Any], action: str) -> list[str] | None:
strategy = self._strategy(app)
if strategy == "pip":
if action == "install":
return self._pip_install_argv(app)
if action == "update":
return self._pip_install_argv(app, update=True)
return self._pip_uninstall_argv(app)
if strategy == "npm":
return self._npm_argv(app, action)
if strategy == "brew":
key = {"install": "install_cmd", "update": "update_cmd", "uninstall": "uninstall_cmd"}[action]
return self._split_safe_command(app, key, "brew")
if strategy == "uv":
key = {"install": "install_cmd", "update": "update_cmd", "uninstall": "uninstall_cmd"}[action]
return self._split_safe_command(app, key, "uv")
if strategy == "bundled":
return None
raise CliAppError("this CLI app uses an unsupported install strategy")
def _run_argv(self, argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
return subprocess.run(
argv,
capture_output=True,
text=True,
timeout=timeout,
)
def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]:
return {
"version": app.get("version") or "unknown",
"entry_point": app.get("entry_point") or "",
"source": app.get("_source") or "harness",
"strategy": self._strategy(app),
"installed_at": int(_now()),
}
def _fetch_skill_content(self, app: dict[str, Any]) -> str | None:
skill_md = str(app.get("skill_md") or "").strip()
if not skill_md:
return None
url = _skill_content_url(skill_md)
if not url:
return None
try:
response = httpx.get(url, timeout=15.0, follow_redirects=True)
response.raise_for_status()
text = response.text
except Exception:
return None
if "SKILL.md" not in url and not text.lstrip().startswith("---"):
return None
return text if len(text) < 250_000 else None
def _fallback_skill(self, app: dict[str, Any]) -> str:
name = str(app.get("name") or "unknown")
display = str(app.get("display_name") or name)
entry = str(app.get("entry_point") or f"cli-anything-{name}")
description = str(app.get("description") or f"Use {display} from nanobot.")
return f"""---
name: {_safe_skill_name(name)}
description: >-
{description}
---
# {display}
Use this skill when the user asks nanobot to operate {display} through its installed CLI app.
If the user attached `@{name}` in chat, treat that as the selected app for the current turn.
## Commands
```bash
{entry} --help
{entry} --json --help
```
Prefer machine-readable output when the CLI supports `--json`.
"""
def _with_nanobot_skill_note(self, content: str, app: dict[str, Any]) -> str:
marker = "<!-- nanobot-cli-app-note -->"
if marker in content:
return content
name = str(app.get("name") or "unknown")
note = f"""{marker}
## Nanobot execution
Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not invoke this CLI through shell unless the user explicitly asks. Prefer this skill when Runtime Context mentions `@{name}` as a CLI App Attachment.
"""
lines = content.splitlines(keepends=True)
if lines and lines[0].strip() == "---":
for index, line in enumerate(lines[1:], start=1):
if line.strip() == "---":
return "".join(lines[: index + 1]) + "\n" + note + "\n" + "".join(lines[index + 1 :])
return note + "\n" + content
def install_skill(self, app: dict[str, Any]) -> Path:
path = self._skill_path(str(app["name"]))
path.parent.mkdir(parents=True, exist_ok=True)
content = self._fetch_skill_content(app) or self._fallback_skill(app)
content = self._with_nanobot_skill_note(content, app)
path.write_text(content, encoding="utf-8")
return path
def remove_skill(self, name: str) -> None:
skill_dir = self._skill_path(name).parent
if skill_dir.is_dir():
shutil.rmtree(skill_dir)
def _record_installed(self, app: dict[str, Any]) -> None:
installed = self._load_installed()
installed[str(app["name"])] = self._installed_entry(app)
self._save_installed(installed)
self.install_skill(app)
def install(self, name: str) -> dict[str, Any]:
app = self.get_app(name)
if not self._install_supported(app):
raise CliAppError("this CLI app uses an unsupported install strategy")
strategy = self._strategy(app)
if strategy == "bundled":
detect_cmd = str(app.get("detect_cmd") or app.get("entry_point") or "")
if detect_cmd and _command_exists(detect_cmd):
self._record_installed(app)
return self.payload() | {"last_action": {"ok": True, "message": f"CLI for {app['display_name']} is available."}}
note = app.get("install_notes") or f"{app['display_name']} is bundled with its parent app."
raise CliAppError(str(note))
argv = self._argv_for_action(app, "install")
assert argv is not None
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
if result.returncode != 0:
raise CliAppError(_truncate(result.stderr or result.stdout or "install failed"), status=500)
self._record_installed(app)
return self.payload() | {"last_action": {"ok": True, "message": f"Installed CLI for {app['display_name']}."}}
def update(self, name: str) -> dict[str, Any]:
app = self.get_app(name, force_refresh=True)
if str(app["name"]) not in self._load_installed():
raise CliAppError("CLI app is not installed")
if self._strategy(app) == "bundled":
self._record_installed(app)
return self.payload() | {"last_action": {"ok": True, "message": f"Checked {app['display_name']}."}}
argv = self._argv_for_action(app, "update")
assert argv is not None
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
if result.returncode != 0:
raise CliAppError(_truncate(result.stderr or result.stdout or "update failed"), status=500)
self._record_installed(app)
return self.payload() | {"last_action": {"ok": True, "message": f"Updated CLI for {app['display_name']}."}}
def uninstall(self, name: str) -> dict[str, Any]:
app = self.get_app(name)
installed = self._load_installed()
if str(app["name"]) not in installed:
raise CliAppError("CLI app is not installed")
if self._strategy(app) != "bundled":
argv = self._argv_for_action(app, "uninstall")
assert argv is not None
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
if result.returncode != 0:
raise CliAppError(_truncate(result.stderr or result.stdout or "uninstall failed"), status=500)
installed.pop(str(app["name"]), None)
self._save_installed(installed)
self.remove_skill(str(app["name"]))
return self.payload() | {"last_action": {"ok": True, "message": f"Uninstalled CLI for {app['display_name']}."}}
def test(self, name: str) -> dict[str, Any]:
app = self.get_app(name)
entry = str(app.get("entry_point") or "")
resolved = shutil.which(entry)
if not entry or not resolved:
raise CliAppError(f"{entry or name} is not available on PATH")
result = self._run_argv([resolved, "--help"], timeout=min(self.runtime.run_timeout, 30))
ok = result.returncode == 0
output = _truncate((result.stdout or result.stderr or "").strip(), 3000)
return self.payload() | {
"last_action": {
"ok": ok,
"message": f"{entry} --help exited {result.returncode}",
"output": output,
}
}
def _resolve_cwd(
self,
working_dir: str | None,
*,
restrict_to_workspace: bool,
) -> Path:
cwd = Path(working_dir).expanduser() if working_dir else self.workspace
cwd = cwd.resolve(strict=False)
workspace = self.workspace.resolve(strict=False)
if restrict_to_workspace and cwd != workspace and not cwd.is_relative_to(workspace):
raise CliAppError("working_dir is outside the configured workspace")
return cwd
def run(
self,
name: str,
args: list[str] | None = None,
*,
json_output: bool = False,
working_dir: str | None = None,
timeout: int | None = None,
restrict_to_workspace: bool = False,
) -> str:
app = self.get_app(name)
installed = self._load_installed()
if str(app["name"]) not in installed:
raise CliAppError(f"CLI app '{name}' is not installed")
cwd = self._resolve_cwd(working_dir, restrict_to_workspace=restrict_to_workspace)
entry = str(installed[str(app["name"])].get("entry_point") or app.get("entry_point") or "")
resolved = shutil.which(entry)
if not entry or not resolved:
raise CliAppError(f"{entry or name} is not available on PATH")
clean_args = [str(arg) for arg in (args or [])]
if json_output and "--json" not in clean_args:
clean_args = ["--json", *clean_args]
effective_timeout = max(1, min(timeout or self.runtime.run_timeout, 600))
try:
result = subprocess.run(
[resolved, *clean_args],
cwd=str(cwd),
capture_output=True,
text=True,
timeout=effective_timeout,
env=os.environ.copy(),
)
except subprocess.TimeoutExpired:
return f"CLI app '{name}' timed out after {effective_timeout}s"
output = [
f"CLI app '{name}' exited {result.returncode}.",
f"Command: {entry} {' '.join(shlex.quote(arg) for arg in clean_args)}".rstrip(),
]
if result.stdout:
output.append("\nSTDOUT:\n" + result.stdout.rstrip())
if result.stderr:
output.append("\nSTDERR:\n" + result.stderr.rstrip())
return _truncate("\n".join(output))

62
nanobot/cli_apps/utils.py Normal file
View File

@ -0,0 +1,62 @@
"""CLI Apps helpers shared by the agent loop and settings surfaces."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Mapping
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
"""Return persisted session kwargs for CLI app attachments."""
cli_apps = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
return {"cli_apps": cli_apps} if isinstance(cli_apps, list) and cli_apps else {}
def runtime_lines(message: Any, workspace: Path, *, skip: bool = False) -> list[str]:
"""Return model-visible CLI app annotations for the current turn."""
if skip:
return []
text = message.content if isinstance(getattr(message, "content", None), str) else ""
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
return _cli_app_runtime_lines(text, metadata, workspace)
def _cli_app_runtime_lines(
text: str,
metadata: Mapping[str, Any] | None,
workspace: Path,
) -> list[str]:
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
if isinstance(structured, list):
mentions = [
item for item in structured
if isinstance(item, Mapping) and isinstance(item.get("name"), str)
]
if mentions:
return [
"CLI App Attachment: "
f"@{str(item['name']).strip().lower()} "
f"(installed; tool=run_cli_app; "
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
for item in mentions
if str(item.get("name") or "").strip()
]
if "@" not in text:
return []
try:
from nanobot.cli_apps import CliAppManager
mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text)
except Exception:
return []
return [
"CLI App Mention: "
f"@{item['name']} "
f"(installed; tool={item['tool']}; "
f"entry_point={item['entry_point'] or 'unknown'}; "
f"skill={item['skill']}). "
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
for item in mentions
]

View File

@ -11,6 +11,7 @@ from pydantic_settings import BaseSettings
from nanobot.cron.types import CronSchedule
if TYPE_CHECKING:
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
from nanobot.agent.tools.self import MyToolConfig
from nanobot.agent.tools.shell import ExecToolConfig
@ -276,6 +277,7 @@ class ToolsConfig(Base):
web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig"))
exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig"))
cli_apps: CliAppsToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.cli_apps", "CliAppsToolConfig"))
my: MyToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.self", "MyToolConfig"))
image_generation: ImageGenerationToolConfig = Field(
default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"),
@ -462,6 +464,7 @@ def _resolve_tool_config_refs() -> None:
"""
import sys
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
from nanobot.agent.tools.self import MyToolConfig
from nanobot.agent.tools.shell import ExecToolConfig
@ -470,6 +473,7 @@ def _resolve_tool_config_refs() -> None:
# Re-export into this module's namespace
mod = sys.modules[__name__]
mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined]
mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined]
mod.WebFetchConfig = WebFetchConfig # type: ignore[attr-defined]

View File

@ -165,6 +165,23 @@ class Session:
image_placeholder_text(p) for p in media if isinstance(p, str) and p
)
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
cli_apps = message.get("cli_apps")
if role == "user" and isinstance(cli_apps, list) and cli_apps and isinstance(content, str):
cli_lines: list[str] = []
for item in cli_apps[:8]:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip().lower()
if not name:
continue
entry = str(item.get("entry_point") or "unknown").strip() or "unknown"
cli_lines.append(
f"[CLI App Attachment: @{name}; tool=run_cli_app; entry_point={entry}; "
f"skill=skills/cli-app-{name}/SKILL.md]"
)
if cli_lines:
breadcrumbs = "\n".join(cli_lines)
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
if include_timestamps:
content = self._annotate_message_time(message, content)
if role == "assistant" and isinstance(content, str) and not content.strip():

View File

@ -41,6 +41,13 @@ documents the general tool contract and non-obvious usage patterns.
- Use `write_stdin` to poll, provide stdin, close stdin, wait for expected output with `wait_for`, or terminate an existing exec session.
- Use `list_exec_sessions` to recover active session IDs after context shifts.
## CLI App Attachments
- When Runtime Context lists a `CLI App Attachment` or `CLI App Mention`, treat the `@name` as an app capability the user intentionally attached to the current turn.
- If the task may need app-specific behavior, read the listed skill first, then call `run_cli_app` with that `name`.
- Do not run an attached CLI app through shell or generic process tools unless the user explicitly asks for that lower-level path.
- If the app CLI is missing, lacks local desktop/app/API prerequisites, or cannot complete the requested action, explain that concrete blocker and what was attempted.
## Web and External Information
- Use web tools when the user asks for current information, a specific URL, or information likely to have changed.

View File

@ -0,0 +1,93 @@
"""CLI Apps helpers for the WebUI HTTP and message surfaces."""
from __future__ import annotations
import re
from typing import Any
from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.config.loader import load_config
QueryParams = dict[str, list[str]]
_CLI_APP_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
_CLI_APP_ATTACHMENT_KEYS = (
"name",
"display_name",
"category",
"entry_point",
"logo_url",
"brand_color",
)
def _clip_ws_string(value: Any, limit: int = 240) -> str | None:
if not isinstance(value, str):
return None
text = value.strip()
if not text:
return None
return text[:limit]
def normalize_cli_app_mentions(raw: Any) -> list[dict[str, str]]:
"""Sanitize structured CLI app mentions sent by the WebUI."""
if not isinstance(raw, list):
return []
out: list[dict[str, str]] = []
seen: set[str] = set()
for item in raw[:8]:
if not isinstance(item, dict):
continue
name = _clip_ws_string(item.get("name"), 64)
if not name or _CLI_APP_NAME_RE.match(name) is None:
continue
key = name.lower()
if key in seen:
continue
seen.add(key)
row: dict[str, str] = {"name": key}
for field in _CLI_APP_ATTACHMENT_KEYS[1:]:
value = _clip_ws_string(item.get(field), 512 if field == "logo_url" else 160)
if value:
row[field] = value
out.append(row)
return out
def _query_first(query: QueryParams, key: str) -> str | None:
values = query.get(key)
return values[0] if values else None
def _manager() -> CliAppManager:
config = load_config()
cli_cfg = config.tools.cli_apps
return CliAppManager(
workspace=config.workspace_path,
runtime=CliAppsRuntimeConfig(
install_timeout=cli_cfg.install_timeout,
run_timeout=cli_cfg.run_timeout,
catalog_ttl_seconds=cli_cfg.catalog_ttl_seconds,
),
)
def cli_apps_payload() -> dict[str, Any]:
return _manager().payload()
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
name = (_query_first(query, "name") or "").strip()
if not name:
raise CliAppError("missing CLI app name")
manager = _manager()
if action == "install":
return manager.install(name)
if action == "update":
return manager.update(name)
if action == "uninstall":
return manager.uninstall(name)
if action == "test":
return manager.test(name)
raise CliAppError(f"unknown CLI app action '{action}'", status=404)

View File

@ -116,6 +116,55 @@ def tool_trace_lines_from_events(events: Any) -> list[str]:
return lines
_PHASE_RANK = {"start": 1, "end": 2, "error": 3}
def _normalize_tool_events(events: Any) -> list[dict[str, Any]]:
if not isinstance(events, list):
return []
out: list[dict[str, Any]] = []
for event in events:
if not event or not isinstance(event, dict):
continue
if event.get("phase") not in {"start", "end", "error"}:
continue
if not isinstance(event.get("name"), str):
fn = event.get("function")
if not (isinstance(fn, dict) and isinstance(fn.get("name"), str)):
continue
out.append(dict(event))
return out
def _tool_event_key(event: dict[str, Any]) -> str:
call_id = event.get("call_id")
if isinstance(call_id, str) and call_id:
return f"call:{call_id}"
return _format_tool_call_trace(event) or json.dumps(event, sort_keys=True, ensure_ascii=False)
def _merge_tool_events(previous: Any, incoming: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not isinstance(previous, list) or not previous:
return incoming
if not incoming:
return [dict(event) for event in previous if isinstance(event, dict)]
merged = [dict(event) for event in previous if isinstance(event, dict)]
index_by_key = {_tool_event_key(event): idx for idx, event in enumerate(merged)}
for event in incoming:
key = _tool_event_key(event)
existing_index = index_by_key.get(key)
if existing_index is None:
index_by_key[key] = len(merged)
merged.append(event)
continue
existing = merged[existing_index]
incoming_rank = _PHASE_RANK.get(str(event.get("phase")), 0)
existing_rank = _PHASE_RANK.get(str(existing.get("phase")), 0)
if incoming_rank >= existing_rank:
merged[existing_index] = {**existing, **event}
return merged
def _merge_unique_tool_trace_lines(
previous_traces: list[str],
lines: list[str],
@ -405,6 +454,9 @@ def replay_transcript_to_ui_messages(
row["media"] = media_att
if all(m.get("kind") == "image" for m in media_att):
row["images"] = [{"url": m.get("url"), "name": m.get("name")} for m in media_att]
cli_apps = rec.get("cli_apps")
if isinstance(cli_apps, list) and cli_apps:
row["cliApps"] = [dict(app) for app in cli_apps if isinstance(app, dict)]
messages.append(row)
continue
@ -486,6 +538,7 @@ def replay_transcript_to_ui_messages(
close_reasoning(messages)
continue
if kind in ("tool_hint", "progress"):
structured_events = _normalize_tool_events(rec.get("tool_events"))
structured = tool_trace_lines_from_events(rec.get("tool_events"))
text = rec.get("text")
trace_lines = structured if structured else ([text] if isinstance(text, str) and text else [])
@ -502,7 +555,7 @@ def replay_transcript_to_ui_messages(
prev_traces = list(last.get("traces") or [last.get("content")])
if structured:
merged_traces, added = _merge_unique_tool_trace_lines(prev_traces, structured)
if not added:
if not added and not structured_events:
continue
else:
merged_traces = prev_traces + trace_lines
@ -510,6 +563,9 @@ def replay_transcript_to_ui_messages(
**last,
"traces": merged_traces,
"content": merged_traces[-1],
"toolEvents": _merge_tool_events(last.get("toolEvents"), structured_events)
if structured_events
else last.get("toolEvents"),
"activitySegmentId": last.get("activitySegmentId") or segment,
}
messages[-1] = merged
@ -521,6 +577,7 @@ def replay_transcript_to_ui_messages(
"kind": "trace",
"content": trace_lines[-1],
"traces": trace_lines,
**({"toolEvents": structured_events} if structured_events else {}),
"activitySegmentId": segment,
"createdAt": _ts_base + idx,
},

View File

@ -362,6 +362,21 @@ class TestBuildMessages:
assert "Other chat goal." not in str(without_goal[-1]["content"])
assert "Goal (active):" not in str(without_goal[-1]["content"])
def test_current_runtime_lines_are_injected(self, tmp_path):
builder = _builder(tmp_path)
messages = builder.build_messages(
[],
"please use @zoom tonight",
current_runtime_lines=[
"CLI App Attachment: @zoom (installed; tool=run_cli_app; entry_point=cli-anything-zoom).",
],
)
user_msg = str(messages[-1]["content"])
assert "CLI App Attachment: @zoom" in user_msg
assert "tool=run_cli_app" in user_msg
assert "entry_point=cli-anything-zoom" in user_msg
def test_consecutive_same_role_merged(self, tmp_path):
builder = _builder(tmp_path)
history = [{"role": "user", "content": "previous user message"}]

View File

@ -359,6 +359,31 @@ def test_get_history_synthesizes_breadcrumb_for_image_only_turn():
assert history[0] == {"role": "user", "content": "[image: /m/pic.png]"}
def test_get_history_synthesizes_cli_app_attachment_breadcrumb():
session = Session(key="test:cli-app")
session.messages.append(
{
"role": "user",
"content": "please use @drawio",
"cli_apps": [{
"name": "drawio",
"entry_point": "cli-anything-drawio",
}],
}
)
history = session.get_history(max_messages=500)
assert history == [{
"role": "user",
"content": (
"please use @drawio\n"
"[CLI App Attachment: @drawio; tool=run_cli_app; "
"entry_point=cli-anything-drawio; skill=skills/cli-app-drawio/SKILL.md]"
),
}]
def test_get_history_ignores_media_kwarg_on_non_user_rows():
"""``media`` only ever appears on user entries in practice, but the
synthesizer must be defensive: assistants / tools with list content

View File

@ -105,6 +105,43 @@ async def test_message_without_media_backward_compatible() -> None:
assert call.kwargs["media"] is None
@pytest.mark.asyncio
async def test_message_forwards_normalized_cli_app_attachments() -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "please use @drawio",
"webui": True,
"cli_apps": [
{
"name": "DrawIO",
"display_name": "Draw.io",
"category": "diagram",
"entry_point": "cli-anything-drawio",
"logo_url": "https://example.invalid/drawio.svg",
"brand_color": "#F08705",
},
{"name": "bad name", "entry_point": "nope"},
],
}
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_awaited_once()
metadata = channel._handle_message.call_args.kwargs["metadata"]
assert metadata["webui"] is True
assert metadata["cli_apps"] == [{
"name": "drawio",
"display_name": "Draw.io",
"category": "diagram",
"entry_point": "cli-anything-drawio",
"logo_url": "https://example.invalid/drawio.svg",
"brand_color": "#F08705",
}]
@pytest.mark.asyncio
async def test_message_with_single_image_forwards_saved_path(tmp_path) -> None:
channel = _make_channel()

View File

@ -140,6 +140,75 @@ async def test_sessions_routes_require_bearer_token(
await server_task
@pytest.mark.asyncio
async def test_cli_apps_routes_require_token_and_return_payload(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"nanobot.channels.websocket.cli_apps_payload",
lambda: {
"apps": [
{
"name": "gimp",
"display_name": "GIMP",
"category": "image",
"description": "Image editing",
"requires": "Python",
"source": "harness",
"entry_point": "cli-anything-gimp",
"install_supported": True,
"installed": False,
"available": False,
"status": "not_installed",
"logo_url": None,
"brand_color": None,
"skill_installed": False,
}
],
"installed_count": 0,
"catalog_updated_at": "2026-04-18",
},
)
monkeypatch.setattr(
"nanobot.channels.websocket.cli_apps_action",
lambda action, query: {
"apps": [],
"installed_count": 1,
"catalog_updated_at": "2026-04-18",
"last_action": {"ok": True, "message": f"{action}:{query['name'][0]}"},
},
)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29912)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
deny = await _http_get("http://127.0.0.1:29912/api/settings/cli-apps")
assert deny.status_code == 401
boot = await _http_get("http://127.0.0.1:29912/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
catalog = await _http_get(
"http://127.0.0.1:29912/api/settings/cli-apps",
headers=auth,
)
assert catalog.status_code == 200
assert catalog.json()["apps"][0]["name"] == "gimp"
installed = await _http_get(
"http://127.0.0.1:29912/api/settings/cli-apps/install?name=gimp",
headers=auth,
)
assert installed.status_code == 200
assert installed.json()["last_action"]["message"] == "install:gimp"
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_sessions_list_only_returns_websocket_sessions_by_default(
bus: MagicMock, tmp_path: Path

View File

@ -0,0 +1,378 @@
from __future__ import annotations
import json
import os
import stat
import subprocess
import sys
import time
from pathlib import Path
import pytest
from nanobot.cli_apps.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
def _write_cache(path: Path, registry: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps({"_cached_at": time.time(), "data": registry}),
encoding="utf-8",
)
def _manager(tmp_path: Path) -> CliAppManager:
workspace = tmp_path / "workspace"
workspace.mkdir()
return CliAppManager(
workspace=workspace,
data_dir=tmp_path / "data",
runtime=CliAppsRuntimeConfig(catalog_ttl_seconds=3600, install_timeout=5, run_timeout=5),
)
def _seed_catalog(manager: CliAppManager) -> None:
harness = {
"meta": {"updated": "2026-04-16"},
"clis": [
{
"name": "gimp",
"display_name": "GIMP",
"version": "1.0.0",
"description": "Image editing",
"category": "image",
"requires": "Python 3.10+",
"install_cmd": "pip install cli-anything-gimp",
"entry_point": "cli-anything-gimp",
"skill_md": "skills/cli-anything-gimp/SKILL.md",
}
],
}
public = {
"meta": {"updated": "2026-04-18"},
"clis": [
{
"name": "gimp",
"display_name": "GIMP",
"description": "Public duplicate entry",
},
{
"name": "jimeng",
"display_name": "Jimeng",
"version": "latest",
"description": "Script install",
"category": "ai",
"install_strategy": "script",
"install_cmd": "curl -fsSL https://example.invalid/install.sh | bash",
"entry_point": "dreamina",
},
{
"name": "feishu",
"display_name": "Feishu/Lark CLI",
"version": "latest",
"description": "Official Lark CLI",
"category": "communication",
"package_manager": "npm",
"npm_package": "@larksuite/cli",
"install_cmd": "npm install -g @larksuite/cli",
"entry_point": "lark-cli",
},
{
"name": "dify-workflow",
"display_name": "Dify Workflow",
"version": "latest",
"description": "Run Dify workflows",
"category": "ai",
"install_cmd": "pip install cli-anything-dify-workflow",
"entry_point": "cli-anything-dify-workflow",
},
{
"name": "shopify",
"display_name": "Shopify CLI",
"version": "latest",
"description": "Shopify",
"category": "web",
"package_manager": "npm",
"npm_package": "@shopify/cli",
"install_cmd": "npm install -g @shopify/cli",
"entry_point": "shopify",
},
{
"name": "clibrowser",
"display_name": "clibrowser",
"version": "latest",
"description": "Cargo install",
"category": "web",
"install_cmd": "cargo install --git https://example.invalid/clibrowser.git",
"entry_point": "clibrowser",
},
{
"name": "suno",
"display_name": "Suno CLI",
"version": "latest",
"description": "python3 pip install",
"category": "music",
"package_manager": "pip",
"install_strategy": "command",
"install_cmd": "python3 -m pip install git+https://example.invalid/suno-cli.git",
"uninstall_cmd": "python3 -m pip uninstall -y suno-cli",
"entry_point": "suno",
},
],
}
_write_cache(manager._cache_path("harness"), harness)
_write_cache(manager._cache_path("public"), public)
def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
payload = manager.payload()
assert payload["catalog_updated_at"] == "2026-04-18"
apps = {app["name"]: app for app in payload["apps"]}
assert set(apps) == {
"clibrowser",
"dify-workflow",
"feishu",
"gimp",
"jimeng",
"shopify",
"suno",
}
assert apps["gimp"]["install_supported"] is True
assert apps["gimp"]["source"] == "harness+public"
assert apps["gimp"]["description"] == "Public duplicate entry"
assert apps["clibrowser"]["install_supported"] is False
assert apps["jimeng"]["install_supported"] is False
assert apps["suno"]["install_supported"] is True
assert apps["gimp"]["logo_url"]
assert apps["dify-workflow"]["logo_url"] == "https://cdn.simpleicons.org/dify/155EEF"
assert apps["feishu"]["logo_url"] == (
"https://www.google.com/s2/favicons?domain=larksuite.com&sz=64"
)
assert apps["jimeng"]["logo_url"] == "https://cdn.simpleicons.org/bytedance/3C8CFF"
assert apps["clibrowser"]["logo_url"] == (
"https://www.google.com/s2/favicons?domain=github.com/allthingssecurity/clibrowser&sz=64"
)
def test_install_dispatches_safe_pip_and_installs_skill(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
calls: list[list[str]] = []
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
calls.append(argv)
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
monkeypatch.setattr(manager, "_run_argv", fake_run)
monkeypatch.setattr(
manager,
"_fetch_skill_content",
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
)
payload = manager.install("gimp")
assert calls == [[sys.executable, "-m", "pip", "install", "cli-anything-gimp"]]
assert payload["last_action"]["ok"] is True
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
assert skill.is_file()
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
def test_installed_state_writes_atomically_without_temp_leftovers(tmp_path: Path) -> None:
manager = _manager(tmp_path)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
manager._save_installed({"zoom": {"entry_point": "cli-anything-zoom"}})
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert set(installed) == {"zoom"}
assert not list(manager.installed_path.parent.glob(".installed.json.*.tmp"))
def test_fetch_skill_content_rejects_untrusted_urls(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
def fail_get(*args, **kwargs):
raise AssertionError("untrusted skill URL should not be fetched")
monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fail_get)
assert manager._fetch_skill_content({
"name": "evil",
"skill_md": "https://example.com/SKILL.md",
}) is None
assert manager._fetch_skill_content({
"name": "evil",
"skill_md": "skills/../evil/SKILL.md",
}) is None
def test_fetch_skill_content_allows_cli_anything_raw_skill_url(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
seen: list[str] = []
class Response:
text = "---\nname: cli-app-test\ndescription: Test\n---\n# Test\n"
@staticmethod
def raise_for_status() -> None:
return None
def fake_get(url: str, **kwargs):
seen.append(url)
return Response()
monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fake_get)
content = manager._fetch_skill_content({
"name": "gimp",
"skill_md": "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main/skills/cli-anything-gimp/SKILL.md",
})
assert content and "# Test" in content
assert seen == [
"https://raw.githubusercontent.com/HKUDS/CLI-Anything/main/skills/cli-anything-gimp/SKILL.md"
]
def test_uninstall_removes_installed_state_and_generated_skill(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
skill_dir = manager.workspace / "skills" / "cli-app-gimp"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
monkeypatch.setattr(
manager,
"_run_argv",
lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""),
)
payload = manager.uninstall("gimp")
assert payload["last_action"]["ok"] is True
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert not skill_dir.exists()
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"suno": {"entry_point": "suno"}})
calls: list[list[str]] = []
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
calls.append(argv)
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
monkeypatch.setattr(manager, "_run_argv", fake_run)
payload = manager.uninstall("suno")
assert calls == [[sys.executable, "-m", "pip", "uninstall", "-y", "suno-cli"]]
assert payload["last_action"]["ok"] is True
def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path) -> None:
manager = _manager(tmp_path)
manager._save_installed(
{
"gimp": {"entry_point": "cli-anything-gimp", "source": "harness"},
"zoom": {"entry_point": "cli-anything-zoom", "source": "public"},
}
)
mentions = manager.mentioned_installed_apps("use @zoom and @krita, then @GIMP")
assert mentions == [
{
"name": "zoom",
"entry_point": "cli-anything-zoom",
"source": "public",
"skill": "skills/cli-app-zoom/SKILL.md",
"tool": "run_cli_app",
},
{
"name": "gimp",
"entry_point": "cli-anything-gimp",
"source": "harness",
"skill": "skills/cli-app-gimp/SKILL.md",
"tool": "run_cli_app",
},
]
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
with pytest.raises(CliAppError, match="not found"):
manager.install("missing")
with pytest.raises(CliAppError, match="unsupported"):
manager.install("jimeng")
def test_run_installed_cli_uses_argv_without_shell(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
cli = bin_dir / "cli-anything-gimp"
cli.write_text(
"#!/usr/bin/env python3\n"
"import sys\n"
"print('ARGS=' + repr(sys.argv[1:]))\n",
encoding="utf-8",
)
cli.chmod(cli.stat().st_mode | stat.S_IEXEC)
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}")
manager._save_installed(
{
"gimp": {
"version": "1.0.0",
"entry_point": "cli-anything-gimp",
"source": "harness",
"strategy": "pip",
}
}
)
result = manager.run("gimp", ["project", "list"], json_output=True)
assert "CLI app 'gimp' exited 0" in result
assert "['--json', 'project', 'list']" in result
def test_run_blocks_working_dir_outside_workspace(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
with pytest.raises(CliAppError, match="outside the configured workspace"):
manager.run("gimp", working_dir="/etc", restrict_to_workspace=True)

121
tests/cli_apps/test_tool.py Normal file
View File

@ -0,0 +1,121 @@
from __future__ import annotations
import asyncio
import json
import os
import stat
import time
from pathlib import Path
from nanobot.agent.tools.cli_apps import CliAppsTool
from nanobot.cli_apps.service import CliAppManager, CliAppsRuntimeConfig
def _write_cache(path: Path, registry: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps({"_cached_at": time.time(), "data": registry}),
encoding="utf-8",
)
def test_run_cli_app_uses_installed_registry_app(
tmp_path: Path,
monkeypatch,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
data_dir = tmp_path / "data"
registry = {
"meta": {"updated": "2026-04-16"},
"clis": [
{
"name": "gimp",
"display_name": "GIMP",
"version": "1.0.0",
"description": "Image editing",
"category": "image",
"install_cmd": "pip install cli-anything-gimp",
"entry_point": "cli-anything-gimp",
}
],
}
_write_cache(data_dir / "harness_registry_cache.json", registry)
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed(
{"gimp": {"entry_point": "cli-anything-gimp"}}
)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
cli = bin_dir / "cli-anything-gimp"
cli.write_text(
"#!/usr/bin/env python3\n"
"import sys\n"
"print('tool:' + ' '.join(sys.argv[1:]))\n",
encoding="utf-8",
)
cli.chmod(cli.stat().st_mode | stat.S_IEXEC)
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}")
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
tool = CliAppsTool(
workspace=workspace,
restrict_to_workspace=True,
runtime=CliAppsRuntimeConfig(run_timeout=5),
)
assert tool.name == "run_cli_app"
result = asyncio.run(
tool.execute(
name="gimp",
args=["project", "list"],
json=True,
working_dir=str(workspace),
)
)
assert "CLI app 'gimp' exited 0" in result
assert "tool:--json project list" in result
def test_run_cli_app_rejects_uninstalled_app(tmp_path: Path, monkeypatch) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
data_dir = tmp_path / "data"
registry = {
"meta": {"updated": "2026-04-16"},
"clis": [
{
"name": "gimp",
"display_name": "GIMP",
"version": "1.0.0",
"description": "Image editing",
"category": "image",
"install_cmd": "pip install cli-anything-gimp",
"entry_point": "cli-anything-gimp",
}
],
}
_write_cache(data_dir / "harness_registry_cache.json", registry)
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
tool = CliAppsTool(workspace=workspace, restrict_to_workspace=True)
result = asyncio.run(tool.execute(name="gimp"))
assert "not installed" in result
def test_run_cli_app_description_names_only_settings_installed_apps(tmp_path: Path, monkeypatch) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
data_dir = tmp_path / "data"
CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed(
{"drawio": {"entry_point": "cli-anything-drawio"}}
)
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
tool = CliAppsTool(workspace=workspace)
assert "Settings CLI Apps: drawio" in tool.description
assert "ordinary system CLIs such as git, gh" in tool.description

View File

@ -0,0 +1,64 @@
"""Tests for CLI Apps loop helpers."""
from types import SimpleNamespace
from nanobot.cli_apps.service import CliAppManager
from nanobot.cli_apps.utils import runtime_lines, session_extra
def test_session_extra_returns_cli_apps_only_when_present() -> None:
cli_apps = [{"name": "zoom"}]
assert session_extra({"cli_apps": cli_apps}) == {"cli_apps": cli_apps}
assert session_extra({}) == {}
assert session_extra(None) == {}
def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
data_dir = tmp_path / "data"
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
manager = CliAppManager(workspace=tmp_path)
manager._save_installed(
{
"zoom": {
"entry_point": "cli-anything-zoom",
"source": "harness",
},
"krita": {
"entry_point": "cli-anything-krita",
"source": "harness",
},
}
)
lines = runtime_lines(
SimpleNamespace(content="please use @zoom tonight; ignore @krita?", metadata={}),
tmp_path,
)
joined = "\n".join(lines)
assert "CLI App Mention: @zoom" in joined
assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
lines = runtime_lines(
SimpleNamespace(
content="please use @zoom tonight",
metadata={
"cli_apps": [{
"name": "zoom",
"entry_point": "cli-anything-zoom",
"display_name": "Zoom",
}],
},
),
tmp_path,
)
joined = "\n".join(lines)
assert "CLI App Attachment: @zoom" in joined
assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined
assert "skill=skills/cli-app-zoom/SKILL.md" in joined

View File

@ -91,6 +91,7 @@ def test_discover_finds_concrete_tools():
class_names = {cls.__name__ for cls in discovered}
assert "ApplyPatchTool" in class_names
assert "ExecTool" in class_names
assert "CliAppsTool" in class_names
assert "MessageTool" in class_names
assert "SpawnTool" in class_names
assert "WriteStdinTool" in class_names
@ -366,6 +367,7 @@ def test_config_defaults():
assert config.tools.my.enable is True
assert config.tools.my.allow_set is False
assert config.tools.image_generation.enabled is False
assert config.tools.cli_apps.enable is True
assert config.tools.restrict_to_workspace is False

View File

@ -143,6 +143,50 @@ def test_replay_tool_events_dedupes_finish_after_start() -> None:
'exec({"cmd": "ls"})',
'read_file({"path": "notes.md"})',
]
assert msgs[0]["toolEvents"][0]["phase"] == "end"
assert msgs[0]["toolEvents"][0]["call_id"] == "call-exec"
def test_replay_tool_events_keeps_phase_update_when_trace_is_deduped() -> None:
args = {"name": "github", "args": ["repo", "view"], "json": "true"}
msgs = replay_transcript_to_ui_messages([
{
"event": "message",
"chat_id": "t-tool",
"text": "",
"kind": "tool_hint",
"tool_events": [
{
"phase": "start",
"call_id": "call-cli",
"name": "run_cli_app",
"arguments": args,
},
],
},
{
"event": "message",
"chat_id": "t-tool",
"text": "",
"kind": "progress",
"tool_events": [
{
"phase": "error",
"call_id": "call-cli",
"name": "run_cli_app",
"arguments": args,
"error": "Error: CLI app 'github' not found",
},
],
},
])
assert len(msgs) == 1
assert msgs[0]["traces"] == [
'run_cli_app({"name": "github", "args": ["repo", "view"], "json": "true"})',
]
assert msgs[0]["toolEvents"][0]["phase"] == "error"
assert msgs[0]["toolEvents"][0]["error"] == "Error: CLI app 'github' not found"
def test_replay_file_edit_progress_merges_after_interleaved_activity(tmp_path, monkeypatch) -> None:

View File

@ -0,0 +1,148 @@
import { useState } from "react";
import type { CliAppInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
export type CliAppMentionSegment =
| { kind: "text"; text: string }
| { kind: "cli"; text: string; app: CliAppInfo };
export function cliAppInitials(app: CliAppInfo): string {
const value = app.display_name || app.name;
return (
value
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || app.name.slice(0, 2).toUpperCase()
);
}
export function splitCliAppMentionSegments(
value: string,
cliApps: CliAppInfo[],
): CliAppMentionSegment[] {
if (!value || cliApps.length === 0) return value ? [{ kind: "text", text: value }] : [];
const appsByName = new Map(
cliApps
.filter((app) => app.installed)
.map((app) => [app.name.toLowerCase(), app]),
);
if (appsByName.size === 0) return [{ kind: "text", text: value }];
const segments: CliAppMentionSegment[] = [];
const mentionRe = /(^|[\s([{])@([a-z0-9_-]+)\b/gi;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = mentionRe.exec(value)) !== null) {
const prefix = match[1] ?? "";
const name = match[2] ?? "";
const app = appsByName.get(name.toLowerCase());
if (!app) continue;
const mentionStart = match.index + prefix.length;
const mentionEnd = mentionStart + name.length + 1;
if (mentionStart > cursor) {
segments.push({ kind: "text", text: value.slice(cursor, mentionStart) });
}
segments.push({ kind: "cli", text: value.slice(mentionStart, mentionEnd), app });
cursor = mentionEnd;
}
if (cursor < value.length) {
segments.push({ kind: "text", text: value.slice(cursor) });
}
return segments.length ? segments : [{ kind: "text", text: value }];
}
export function CliAppMentionText({
text,
cliApps,
}: {
text: string;
cliApps: CliAppInfo[];
}) {
const segments = splitCliAppMentionSegments(text, cliApps);
if (!segments.some((segment) => segment.kind === "cli")) return <>{text}</>;
return (
<>
{segments.map((segment, index) => {
if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>;
}
return (
<CliAppMentionToken
key={`cli-${segment.app.name}-${index}`}
app={segment.app}
label={segment.text}
variant="message"
/>
);
})}
</>
);
}
export function CliAppMentionToken({
app,
label,
variant,
isHero = false,
}: {
app: CliAppInfo;
label: string;
variant: "composer" | "message";
isHero?: boolean;
}) {
const [failed, setFailed] = useState(false);
const color = app.brand_color || "hsl(var(--primary))";
const mentionName = label.startsWith("@") ? label.slice(1) : label;
const showLogo = Boolean(app.logo_url) && !failed;
const testIdPrefix = variant === "composer" ? "composer" : "message";
return (
<span
data-testid={`${testIdPrefix}-cli-mention-${app.name}`}
className="relative inline transition-[color,text-shadow] duration-150"
style={{
color,
textShadow: `0 0 10px ${alphaColor(color, 24)}`,
}}
>
<span
className={cn("relative inline-block", showLogo && "text-transparent")}
style={{ lineHeight: "inherit" }}
>
@
{showLogo ? (
<span
data-testid={`${testIdPrefix}-cli-mention-logo-${app.name}`}
className={cn(
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-[3px]",
"-translate-x-1/2 -translate-y-1/2",
isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]",
)}
>
<img
src={app.logo_url ?? ""}
alt=""
className="h-full w-full object-contain"
onError={() => setFailed(true)}
/>
</span>
) : null}
</span>
{mentionName}
</span>
);
}
function alphaColor(color: string, percent: number): string {
if (/^#[0-9a-f]{6}$/i.test(color)) {
const alpha = Math.round((percent / 100) * 255)
.toString(16)
.padStart(2, "0");
return `${color}${alpha}`;
}
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
}

View File

@ -1,6 +1,7 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
@ -8,16 +9,18 @@ import {
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import { CliAppMentionText } from "@/components/CliAppMentionText";
import { ImageLightbox } from "@/components/ImageLightbox";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils";
import { formatTurnLatency } from "@/lib/format";
import type { UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
import type { CliAppInfo, UICliAppAttachment, UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
interface MessageBubbleProps {
message: UIMessage;
/** When false, hide the assistant reply copy button (mid-turn text before more agent activity). Default true. */
showAssistantCopyAction?: boolean;
cliApps?: CliAppInfo[];
}
/**
@ -32,11 +35,16 @@ interface MessageBubbleProps {
export function MessageBubble({
message,
showAssistantCopyAction = true,
cliApps = [],
}: MessageBubbleProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<number | null>(null);
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
const mentionCliApps = useMemo(
() => mergeCliMentionApps(cliApps, message.cliApps),
[cliApps, message.cliApps],
);
useEffect(() => {
return () => {
@ -88,7 +96,7 @@ export function MessageBubble({
"text-left text-[16px]/[1.75] whitespace-pre-wrap break-words",
)}
>
{message.content}
<CliAppMentionText text={message.content} cliApps={mentionCliApps} />
</p>
) : null}
</div>
@ -158,6 +166,36 @@ export function MessageBubble({
);
}
function mergeCliMentionApps(
cliApps: CliAppInfo[],
attachments: UICliAppAttachment[] | undefined,
): CliAppInfo[] {
if (!attachments?.length) return cliApps;
const byName = new Map(cliApps.map((app) => [app.name.toLowerCase(), app]));
for (const attachment of attachments) {
const name = attachment.name?.trim();
if (!name) continue;
const existing = byName.get(name.toLowerCase());
byName.set(name.toLowerCase(), {
name,
display_name: attachment.display_name || existing?.display_name || name,
category: attachment.category || existing?.category || "cli",
description: existing?.description || "",
requires: existing?.requires || "",
source: existing?.source || "attached",
entry_point: attachment.entry_point || existing?.entry_point || "",
install_supported: existing?.install_supported ?? true,
installed: true,
available: existing?.available ?? true,
status: existing?.status || "installed",
logo_url: attachment.logo_url ?? existing?.logo_url ?? null,
brand_color: attachment.brand_color ?? existing?.brand_color ?? null,
skill_installed: existing?.skill_installed ?? true,
});
}
return Array.from(byName.values());
}
function MessageMedia({
media,
align,

View File

@ -32,6 +32,8 @@ import {
Loader2,
LogOut,
Moon,
Package,
PlayCircle,
Orbit,
Palette,
Pencil,
@ -41,6 +43,7 @@ import {
ShieldCheck,
SlidersHorizontal,
Sparkles,
Trash2,
Triangle,
Waves,
Zap,
@ -59,6 +62,8 @@ import {
import { Input } from "@/components/ui/input";
import {
fetchSettings,
fetchCliApps,
runCliAppAction,
updateImageGenerationSettings,
updateProviderSettings,
updateSettings,
@ -67,6 +72,8 @@ import {
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
import type {
CliAppInfo,
CliAppsPayload,
ImageGenerationSettingsUpdate,
SettingsPayload,
WebSearchSettingsUpdate,
@ -79,6 +86,7 @@ type SettingsSectionKey =
| "providers"
| "image"
| "web"
| "cliApps"
| "runtime"
| "advanced";
@ -89,6 +97,7 @@ interface LocalPreferences {
density: LocalDensity;
activityMode: LocalActivityMode;
codeWrap: boolean;
brandLogos: boolean;
}
interface AgentSettingsDraft {
@ -110,6 +119,7 @@ const DEFAULT_LOCAL_PREFS: LocalPreferences = {
density: "comfortable",
activityMode: "auto",
codeWrap: true,
brandLogos: true,
};
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
@ -146,6 +156,7 @@ function readLocalPreferences(): LocalPreferences {
density: parsed.density === "compact" ? "compact" : "comfortable",
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
codeWrap: parsed.codeWrap !== false,
brandLogos: parsed.brandLogos !== false,
};
} catch {
return DEFAULT_LOCAL_PREFS;
@ -177,8 +188,11 @@ export function SettingsView({
const { t } = useTranslation();
const { token } = useClient();
const [settings, setSettings] = useState<SettingsPayload | null>(null);
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
const [loading, setLoading] = useState(true);
const [cliAppsLoading, setCliAppsLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [cliAppsAction, setCliAppsAction] = useState<string | null>(null);
const [providerSaving, setProviderSaving] = useState<string | null>(null);
const [webSearchSaving, setWebSearchSaving] = useState(false);
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
@ -186,6 +200,12 @@ export function SettingsView({
const [activeSection, setActiveSection] = useState<SettingsSectionKey>("overview");
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
const [providerQuery, setProviderQuery] = useState("");
const [cliAppsQuery, setCliAppsQuery] = useState("");
const [cliAppsCategory, setCliAppsCategory] = useState("all");
const [cliAppsInstallFilter, setCliAppsInstallFilter] = useState<"all" | "installed" | "notInstalled">("all");
const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null);
const [cliAppsError, setCliAppsError] = useState<string | null>(null);
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
const [providerForms, setProviderForms] = useState<Record<string, { apiKey: string; apiBase: string }>>({});
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
@ -285,6 +305,27 @@ export function SettingsView({
};
}, [applyPayload, token]);
useEffect(() => {
let cancelled = false;
setCliAppsLoading(true);
fetchCliApps(token)
.then((payload) => {
if (!cancelled) {
setCliApps(payload);
setCliAppsError(null);
}
})
.catch((err) => {
if (!cancelled) setCliAppsError((err as Error).message);
})
.finally(() => {
if (!cancelled) setCliAppsLoading(false);
});
return () => {
cancelled = true;
};
}, [token]);
useEffect(() => {
try {
window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(localPrefs));
@ -574,6 +615,26 @@ export function SettingsView({
});
};
const handleCliAppAction = async (
action: "install" | "update" | "uninstall" | "test",
name: string,
) => {
const key = `${action}:${name}`;
setCliAppsAction(key);
setCliAppsMessage(null);
setCliAppsError(null);
try {
const payload = await runCliAppAction(token, action, name);
setCliApps(payload);
setCliAppsMessage(payload.last_action?.message ?? null);
setCliAppsFocusName(action === "uninstall" ? null : name);
} catch (err) {
setCliAppsError((err as Error).message);
} finally {
setCliAppsAction(null);
}
};
const renderSection = () => {
if (!settings) return null;
switch (activeSection) {
@ -618,6 +679,7 @@ export function SettingsView({
editingProviderKeys={editingProviderKeys}
providerSaving={providerSaving}
query={providerQuery}
showBrandLogos={localPrefs.brandLogos}
onQueryChange={setProviderQuery}
onToggleProvider={handleToggleProvider}
onToggleProviderKey={toggleProviderKeyVisibility}
@ -677,6 +739,26 @@ export function SettingsView({
requiresRestartPending={pendingRestartSections.web}
/>
);
case "cliApps":
return (
<CliAppsSettings
payload={cliApps}
loading={cliAppsLoading}
query={cliAppsQuery}
category={cliAppsCategory}
installFilter={cliAppsInstallFilter}
actionKey={cliAppsAction}
message={cliAppsMessage}
error={cliAppsError}
focusName={cliAppsFocusName}
showBrandLogos={localPrefs.brandLogos}
onQueryChange={setCliAppsQuery}
onCategoryChange={setCliAppsCategory}
onInstallFilterChange={setCliAppsInstallFilter}
onAction={handleCliAppAction}
onBackToChat={onBackToChat}
/>
);
case "runtime":
return (
<RuntimeSettings
@ -752,6 +834,7 @@ const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fal
{ key: "providers", icon: KeyRound, fallback: "Providers" },
{ key: "image", icon: ImageIcon, fallback: "Image" },
{ key: "web", icon: Globe2, fallback: "Web" },
{ key: "cliApps", icon: Package, fallback: "CLI Apps" },
{ key: "runtime", icon: Server, fallback: "Runtime" },
{ key: "advanced", icon: ShieldCheck, fallback: "Advanced" },
];
@ -1077,6 +1160,16 @@ function AppearanceSettings({
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.brandLogos", "Brand logos")}
description={tx("settings.help.brandLogos", "Show third-party provider and CLI logos in Settings.")}
>
<ToggleButton
checked={localPrefs.brandLogos}
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
</SettingsGroup>
</section>
</div>
@ -1211,6 +1304,7 @@ function ProvidersSettings({
editingProviderKeys,
providerSaving,
query,
showBrandLogos,
onQueryChange,
onToggleProvider,
onToggleProviderKey,
@ -1229,6 +1323,7 @@ function ProvidersSettings({
editingProviderKeys: Record<string, boolean>;
providerSaving: string | null;
query: string;
showBrandLogos: boolean;
onQueryChange: (query: string) => void;
onToggleProvider: (provider: string) => void;
onToggleProviderKey: (provider: string) => void;
@ -1272,7 +1367,10 @@ function ProvidersSettings({
className="flex min-h-[70px] w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors hover:bg-muted/35 sm:px-5"
>
<span className="flex min-w-0 items-center gap-3">
<ProviderIcon provider={provider.name} />
<ProviderIcon
provider={provider.name}
showBrandLogos={showBrandLogos}
/>
<span className="min-w-0">
<span className="block truncate text-[15px] font-semibold leading-5 text-foreground">
{provider.label}
@ -1437,6 +1535,7 @@ function ProvidersSettings({
>
{filteredUnconfigured.map(renderProviderRow)}
</ProviderSection>
<ThirdPartyBrandNotice />
</div>
);
}
@ -1831,6 +1930,383 @@ function WebSettings({
);
}
function CliAppsSettings({
payload,
loading,
query,
category,
installFilter,
actionKey,
message,
error,
focusName,
showBrandLogos,
onQueryChange,
onCategoryChange,
onInstallFilterChange,
onAction,
onBackToChat,
}: {
payload: CliAppsPayload | null;
loading: boolean;
query: string;
category: string;
installFilter: "all" | "installed" | "notInstalled";
actionKey: string | null;
message: string | null;
error: string | null;
focusName: string | null;
showBrandLogos: boolean;
onQueryChange: (value: string) => void;
onCategoryChange: (value: string) => void;
onInstallFilterChange: (value: "all" | "installed" | "notInstalled") => void;
onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
onBackToChat: () => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const apps = payload?.apps ?? [];
const categories = useMemo(
() => ["all", ...Array.from(new Set(apps.map((app) => app.category))).sort()],
[apps],
);
const normalizedQuery = query.trim().toLowerCase();
const filteredApps = apps.filter((app) => {
const categoryMatch = category === "all" || app.category === category;
if (!categoryMatch) return false;
if (installFilter === "installed" && !app.installed) return false;
if (installFilter === "notInstalled" && app.installed) return false;
if (!normalizedQuery) return true;
return (
app.display_name.toLowerCase().includes(normalizedQuery) ||
app.name.toLowerCase().includes(normalizedQuery) ||
app.description.toLowerCase().includes(normalizedQuery) ||
app.category.toLowerCase().includes(normalizedQuery)
);
});
const categoryLabel =
category === "all"
? tx("settings.cliApps.allCategories", "All categories")
: category;
const installFilterOptions = [
{ value: "all", label: tx("settings.cliApps.filterAll", "All") },
{ value: "installed", label: tx("settings.cliApps.filterInstalled", "Installed CLIs") },
{ value: "notInstalled", label: tx("settings.cliApps.filterNotInstalled", "Not installed") },
];
const focusedApp = focusName
? apps.find((app) => app.name === focusName && app.installed)
: null;
const visibleStatusMessage = error || (!focusedApp ? message : null);
return (
<div className="space-y-5">
<section className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<SettingsSectionTitle>{tx("settings.sections.cliApps", "CLI Apps")}</SettingsSectionTitle>
<p className="mt-1 text-[13px] text-muted-foreground">
{tx("settings.cliApps.summary", "{{installed}} of {{total}} CLIs installed")
.replace("{{installed}}", String(payload?.installed_count ?? 0))
.replace("{{total}}", String(apps.length))}
</p>
</div>
<SegmentedControl
value={installFilter}
options={installFilterOptions}
onChange={(value) => onInstallFilterChange(value as "all" | "installed" | "notInstalled")}
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.cliApps.searchPlaceholder", "Search CLIs")}
className="h-10 w-full rounded-full border-border/65 bg-card/80 pl-9 text-[13px] shadow-sm sm:max-w-[320px]"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-10 justify-between rounded-full bg-card/80 px-4">
<span className="max-w-[180px] truncate">{categoryLabel}</span>
<ChevronDown className="ml-2 h-3.5 w-3.5" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-[320px] overflow-y-auto">
{categories.map((item) => (
<DropdownMenuItem key={item} onClick={() => onCategoryChange(item)}>
{item === "all" ? tx("settings.cliApps.allCategories", "All categories") : item}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</section>
{visibleStatusMessage ? (
<div
className={cn(
"rounded-[10px] border px-3.5 py-2.5 text-[12.5px]",
error
? "border-destructive/20 bg-destructive/5 text-destructive"
: "border-border/55 bg-muted/35 text-muted-foreground",
)}
>
{visibleStatusMessage}
</div>
) : null}
{focusedApp ? (
<CliAppReadyPanel
app={focusedApp}
showBrandLogos={showBrandLogos}
onBackToChat={onBackToChat}
/>
) : null}
{loading ? (
<div className="flex h-36 items-center justify-center rounded-[8px] border border-border/45 bg-card/82 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
{tx("settings.cliApps.loading", "Loading CLI Apps...")}
</div>
) : (
<section>
<div className="grid gap-2">
{filteredApps.map((app) => (
<CliAppCard
key={app.name}
app={app}
actionKey={actionKey}
showBrandLogos={showBrandLogos}
onAction={onAction}
/>
))}
</div>
{!filteredApps.length ? (
<div className="rounded-[8px] border border-border/45 bg-card/82 px-4 py-8 text-center text-sm text-muted-foreground">
{tx("settings.cliApps.empty", "No CLI Apps match this filter.")}
</div>
) : null}
</section>
)}
<ThirdPartyBrandNotice />
</div>
);
}
function CliAppReadyPanel({
app,
showBrandLogos,
onBackToChat,
}: {
app: CliAppInfo;
showBrandLogos: boolean;
onBackToChat: () => void;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const prompt = t("settings.cliApps.readyPrompt", {
name: app.name,
defaultValue: "Use @{{name}} to inspect what this CLI can do.",
});
const copyPrompt = () => {
if (!navigator.clipboard) return;
void navigator.clipboard.writeText(prompt).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1400);
});
};
return (
<section
className={cn(
"rounded-[12px] border border-border/55 bg-card/88 px-4 py-3",
"shadow-[0_8px_26px_rgba(15,23,42,0.055)]",
)}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
{app.display_name}
</h3>
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10.5px] font-medium text-muted-foreground">
<Check className="h-3 w-3 text-emerald-600 dark:text-emerald-300" aria-hidden />
{t("settings.cliApps.readyStatus", { defaultValue: "Ready" })}
</span>
</div>
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
<span className="font-mono">@{app.name}</span>
<span aria-hidden>·</span>
<span className="truncate font-mono">{app.entry_point || app.name}</span>
<span aria-hidden>·</span>
<span>{app.category}</span>
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={copyPrompt}
className="h-8 rounded-full px-3 text-[12px] font-medium text-muted-foreground hover:bg-muted/65 hover:text-foreground"
>
{copied ? <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden /> : null}
{copied
? t("settings.cliApps.readyCopied", { defaultValue: "Copied" })
: t("settings.cliApps.readyTry", { name: app.name, defaultValue: "Try @{{name}}" })}
</Button>
<Button
type="button"
size="sm"
onClick={onBackToChat}
className="h-8 rounded-full px-3 text-[12px] font-semibold"
>
{t("settings.cliApps.openChat", { defaultValue: "Open chat" })}
<ChevronRight className="ml-1.5 h-3.5 w-3.5" aria-hidden />
</Button>
</div>
</div>
</section>
);
}
function CliAppCard({
app,
actionKey,
showBrandLogos,
onAction,
}: {
app: CliAppInfo;
actionKey: string | null;
showBrandLogos: boolean;
onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const installBusy = actionKey === `install:${app.name}`;
const updateBusy = actionKey === `update:${app.name}`;
const uninstallBusy = actionKey === `uninstall:${app.name}`;
const testBusy = actionKey === `test:${app.name}`;
const busy = installBusy || updateBusy || uninstallBusy || testBusy;
return (
<article className="flex min-w-0 items-center gap-3 rounded-[8px] border border-border/45 bg-card/82 px-4 py-3 shadow-[0_6px_22px_rgba(15,23,42,0.045)]">
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
{app.display_name}
</h3>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10.5px] font-medium text-muted-foreground">
{app.category}
</span>
</div>
<div className="mt-0.5 truncate text-[12px] text-muted-foreground">
{app.entry_point || app.name}
</div>
<p className="mt-1 truncate text-[12px] leading-5 text-muted-foreground">
{app.requires
? `${tx("settings.cliApps.requires", "Requires")}: ${app.requires}`
: app.description || tx("settings.cliApps.noDescription", "No description available.")}
</p>
</div>
<div className="shrink-0">
{app.installed ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
disabled={busy}
className="h-8 rounded-full border-emerald-500/20 bg-emerald-500/10 px-3 text-[12px] font-semibold text-emerald-700 hover:bg-emerald-500/12 dark:text-emerald-300"
>
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
{tx("settings.cliApps.statusInstalled", "CLI installed")}
<ChevronDown className="ml-1.5 h-3 w-3" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", app.name)}>
<PlayCircle className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.cliApps.test", "Test CLI")}
</DropdownMenuItem>
<DropdownMenuItem disabled={busy} onClick={() => onAction("update", app.name)}>
<RotateCcw className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.cliApps.update", "Update CLI")}
</DropdownMenuItem>
<DropdownMenuItem disabled={busy} onClick={() => onAction("uninstall", app.name)}>
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.cliApps.uninstall", "Uninstall CLI")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : app.install_supported ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={busy}
onClick={() => onAction("install", app.name)}
className="h-8 rounded-full px-4 text-[12px] font-semibold"
>
{installBusy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
{tx("settings.cliApps.install", "Install CLI")}
</Button>
) : (
<Button
type="button"
size="sm"
variant="outline"
disabled
className="h-8 rounded-full px-3 text-[12px] font-semibold"
>
{tx("settings.cliApps.unavailable", "Unavailable")}
</Button>
)}
</div>
</article>
);
}
function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos: boolean }) {
const [failed, setFailed] = useState(false);
const bg = app.brand_color || "hsl(var(--muted))";
const initials = app.display_name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || app.name.slice(0, 2).toUpperCase();
if (showBrandLogos && app.logo_url && !failed) {
return (
<span
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
style={{ boxShadow: `inset 0 0 0 1px ${app.brand_color ?? "transparent"}22` }}
>
<img
src={app.logo_url}
alt=""
className="h-6 w-6 object-contain"
onError={() => setFailed(true)}
/>
</span>
);
}
return (
<span
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] text-[13px] font-semibold text-white"
style={{ backgroundColor: bg }}
>
{initials}
</span>
);
}
function RuntimeSettings({
form,
setForm,
@ -2079,6 +2555,18 @@ function ByokEmptyState({ children }: { children: ReactNode }) {
);
}
function ThirdPartyBrandNotice() {
const { t } = useTranslation();
return (
<p className="px-1 text-[11.5px] leading-5 text-muted-foreground/75">
{t("settings.legal.thirdPartyBrands", {
defaultValue:
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
})}
</p>
);
}
function orderUnconfiguredProviders(
providers: SettingsPayload["providers"],
): SettingsPayload["providers"] {
@ -2126,6 +2614,63 @@ function providerLabel(
return providers.find((provider) => provider.name === value)?.label ?? value;
}
interface ProviderBrand {
logoUrl: string;
color: string;
initials: string;
}
function faviconUrl(domain: string): string {
return `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
}
const PROVIDER_BRAND_ALIASES: Record<string, string> = {
byteplus_coding_plan: "byteplus",
minimax_anthropic: "minimax",
openai_codex: "openai",
volcengine_coding_plan: "volcengine",
};
const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
aihubmix: { logoUrl: faviconUrl("aihubmix.com"), color: "#111827", initials: "AH" },
ant_ling: { logoUrl: faviconUrl("ant-ling.com"), color: "#7C3AED", initials: "AL" },
anthropic: { logoUrl: faviconUrl("anthropic.com"), color: "#D97757", initials: "A" },
atomic_chat: { logoUrl: faviconUrl("atomic.chat"), color: "#111827", initials: "AC" },
azure_openai: { logoUrl: faviconUrl("azure.microsoft.com"), color: "#0078D4", initials: "AZ" },
bedrock: { logoUrl: faviconUrl("aws.amazon.com"), color: "#FF9900", initials: "AWS" },
byteplus: { logoUrl: faviconUrl("byteplus.com"), color: "#325CFF", initials: "BP" },
dashscope: { logoUrl: faviconUrl("dashscope.aliyun.com"), color: "#FF6A00", initials: "DS" },
deepseek: { logoUrl: faviconUrl("deepseek.com"), color: "#4D6BFE", initials: "DS" },
gemini: { logoUrl: faviconUrl("gemini.google.com"), color: "#4285F4", initials: "G" },
github_copilot: { logoUrl: faviconUrl("github.com"), color: "#24292F", initials: "GH" },
groq: { logoUrl: faviconUrl("groq.com"), color: "#F55036", initials: "GQ" },
huggingface: { logoUrl: faviconUrl("huggingface.co"), color: "#FF9D00", initials: "HF" },
lm_studio: { logoUrl: faviconUrl("lmstudio.ai"), color: "#111827", initials: "LM" },
longcat: { logoUrl: faviconUrl("longcat.chat"), color: "#111827", initials: "LC" },
minimax: { logoUrl: faviconUrl("minimax.io"), color: "#111827", initials: "MM" },
mistral: { logoUrl: faviconUrl("mistral.ai"), color: "#FA520F", initials: "M" },
moonshot: { logoUrl: faviconUrl("moonshot.ai"), color: "#111827", initials: "MS" },
novita: { logoUrl: faviconUrl("novita.ai"), color: "#7C3AED", initials: "N" },
nvidia: { logoUrl: faviconUrl("nvidia.com"), color: "#76B900", initials: "NV" },
ollama: { logoUrl: faviconUrl("ollama.com"), color: "#111827", initials: "O" },
openai: { logoUrl: faviconUrl("openai.com"), color: "#111827", initials: "AI" },
openrouter: { logoUrl: faviconUrl("openrouter.ai"), color: "#111827", initials: "OR" },
ovms: { logoUrl: faviconUrl("openvino.ai"), color: "#0071C5", initials: "OV" },
qianfan: { logoUrl: faviconUrl("cloud.baidu.com"), color: "#2932E1", initials: "QF" },
siliconflow: { logoUrl: faviconUrl("siliconflow.cn"), color: "#111827", initials: "SF" },
skywork: { logoUrl: faviconUrl("skywork.ai"), color: "#5B5BF6", initials: "SW" },
stepfun: { logoUrl: faviconUrl("stepfun.com"), color: "#2F6BFF", initials: "SF" },
volcengine: { logoUrl: faviconUrl("volcengine.com"), color: "#1664FF", initials: "VE" },
vllm: { logoUrl: faviconUrl("vllm.ai"), color: "#2563EB", initials: "VL" },
xiaomi_mimo: { logoUrl: faviconUrl("xiaomimimo.com"), color: "#FF6900", initials: "MI" },
zhipu: { logoUrl: faviconUrl("bigmodel.cn"), color: "#155EEF", initials: "Z" },
};
function providerBrand(provider: string): ProviderBrand | null {
const key = PROVIDER_BRAND_ALIASES[provider] ?? provider;
return PROVIDER_BRANDS[key] ?? null;
}
const PROVIDER_ICONS: Record<string, LucideIcon> = {
custom: Hexagon,
openrouter: Sparkles,
@ -2160,8 +2705,44 @@ const PROVIDER_ICONS: Record<string, LucideIcon> = {
nvidia: Zap,
};
function ProviderIcon({ provider }: { provider: string }) {
function ProviderIcon({
provider,
showBrandLogos,
}: {
provider: string;
showBrandLogos: boolean;
}) {
const [failed, setFailed] = useState(false);
const brand = providerBrand(provider);
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
if (showBrandLogos && brand?.logoUrl && !failed) {
return (
<span
data-testid={`provider-logo-${provider}`}
className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-[14px] border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]"
style={{ boxShadow: `inset 0 0 0 1px ${brand.color}22` }}
>
<img
src={brand.logoUrl}
alt=""
className="h-6 w-6 object-contain"
onError={() => setFailed(true)}
/>
</span>
);
}
if (showBrandLogos && brand) {
return (
<span
data-testid={`provider-logo-fallback-${provider}`}
className="grid h-10 w-10 shrink-0 place-items-center rounded-[14px] text-[11px] font-semibold text-white shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)]"
style={{ backgroundColor: brand.color }}
aria-hidden
>
{brand.initials}
</span>
);
}
return (
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-muted text-foreground/82 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)] dark:bg-muted/70">
<Icon className="h-5 w-5" strokeWidth={2} aria-hidden />

View File

@ -1,11 +1,12 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { AlertCircle, ChevronRight, Layers } from "lucide-react";
import { AlertCircle, ChevronRight, Layers, Terminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cliAppInitials } from "@/components/CliAppMentionText";
import { FileReferenceChip } from "@/components/FileReferenceChip";
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
import { cn } from "@/lib/utils";
import type { UIFileEdit, UIMessage } from "@/lib/types";
import type { CliAppInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types";
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
@ -24,6 +25,7 @@ export function isAgentActivityMember(m: UIMessage): boolean {
interface ActivityCounts {
reasoningSteps: number;
toolCalls: number;
cliCount: number;
fileCount: number;
added: number;
deleted: number;
@ -32,6 +34,8 @@ interface ActivityCounts {
hasFailedFiles: boolean;
primaryFilePath?: string;
primaryFileTooltipPath?: string;
primaryCliName?: string;
primaryCliStatus?: CliRunStatus;
}
interface FileEditSummary {
@ -47,17 +51,41 @@ interface FileEditSummary {
error?: string;
}
function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): ActivityCounts {
interface CliRunSummary {
key: string;
name: string;
args: string[];
json: boolean;
workingDir?: string;
status: CliRunStatus;
error?: string;
}
type CliRunStatus = "running" | "done" | "error";
function countActivity(
messages: UIMessage[],
fileEdits: FileEditSummary[],
cliRuns: CliRunSummary[],
): ActivityCounts {
let reasoningSteps = 0;
let toolCalls = 0;
const cliCount = cliRuns.length;
const primaryCli = cliRuns[cliRuns.length - 1];
const primaryCliName = primaryCli?.name;
const primaryCliStatus = primaryCli?.status;
for (const m of messages) {
if (isReasoningOnlyAssistant(m)) {
reasoningSteps += 1;
continue;
}
if (m.kind === "trace") {
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
toolCalls += lines;
const lines = traceLines(m);
for (const line of lines) {
if (!isCliRunTraceLine(line)) {
toolCalls += 1;
}
}
}
}
let added = 0;
@ -89,6 +117,7 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
return {
reasoningSteps,
toolCalls,
cliCount,
fileCount: fileEdits.length,
added,
deleted,
@ -97,6 +126,8 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
primaryFilePath,
primaryFileTooltipPath,
primaryCliName,
primaryCliStatus,
};
}
@ -105,6 +136,7 @@ interface AgentActivityClusterProps {
/** True while the session turn is still running (drives “Working…” copy + header sheen). */
isTurnStreaming: boolean;
hasBodyBelow: boolean;
cliApps?: CliAppInfo[];
}
/**
@ -115,15 +147,22 @@ export function AgentActivityCluster({
messages,
isTurnStreaming,
hasBodyBelow,
cliApps = [],
}: AgentActivityClusterProps) {
const { t } = useTranslation();
const fileEdits = useMemo(
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
[messages, isTurnStreaming],
);
const cliRuns = useMemo(() => collectCliRuns(messages), [messages]);
const cliAppsByName = useMemo(
() => new Map(cliApps.map((app) => [app.name.toLowerCase(), app])),
[cliApps],
);
const {
reasoningSteps,
toolCalls,
cliCount,
fileCount,
added,
deleted,
@ -132,7 +171,9 @@ export function AgentActivityCluster({
hasFailedFiles,
primaryFilePath,
primaryFileTooltipPath,
} = countActivity(messages, fileEdits);
primaryCliName,
primaryCliStatus,
} = countActivity(messages, fileEdits, cliRuns);
const hasPendingFileEdit = fileEdits.some((edit) => edit.pending);
const [userToggledOuter, setUserToggledOuter] = useState(false);
@ -148,7 +189,7 @@ export function AgentActivityCluster({
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || fileCount > 0;
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || fileCount > 0;
const fileActivitySummary = fileCount > 0
? hasPendingFileEdit && !singleFilePath
@ -164,8 +205,22 @@ export function AgentActivityCluster({
})
: "";
const cliActivitySummary = cliCount > 0
? cliCount === 1 && primaryCliName
? t(cliActivitySummaryKey(primaryCliStatus, isTurnStreaming), {
name: primaryCliName,
defaultValue: cliActivitySummaryDefault(primaryCliStatus, isTurnStreaming),
})
: t(cliActivityManySummaryKey(cliRuns, isTurnStreaming), {
count: cliCount,
defaultValue: cliActivityManySummaryDefault(cliRuns, isTurnStreaming),
})
: "";
const summary = fileCount > 0
? fileActivitySummary
: cliCount > 0
? cliActivitySummary
: isTurnStreaming
? reasoningSteps > 0
? t("message.agentActivityLiveSummary", {
@ -254,6 +309,8 @@ export function AgentActivityCluster({
if (!hasVisibleActivity) return null;
const HeaderIcon = cliCount > 0 && fileCount === 0 && toolCalls === 0 ? Terminal : Layers;
return (
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
<button
@ -266,7 +323,7 @@ export function AgentActivityCluster({
aria-expanded={outerExpanded}
aria-label={summary}
>
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
<HeaderIcon className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left">
{singleFilePath ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
@ -337,15 +394,25 @@ export function AgentActivityCluster({
);
}
if (m.kind === "trace") {
const hasTraceLines = (m.traces?.length ?? 0) > 0 || m.content.trim().length > 0;
return hasTraceLines ? (
const normalLines = traceLines(m).filter((line) => !parseCliRunTrace(line));
return normalLines.length > 0 ? (
<div key={m.id} className="flex flex-col gap-1">
<TraceGroup message={m} animClass="" />
<TraceGroup
message={{
...m,
traces: normalLines,
content: normalLines[normalLines.length - 1],
}}
animClass=""
/>
</div>
) : null;
}
return null;
})}
{cliRuns.length ? (
<CliRunGroup runs={cliRuns} active={isTurnStreaming} cliAppsByName={cliAppsByName} />
) : null}
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
</div>
</div>
@ -359,6 +426,181 @@ function shortFileName(path: string): string {
return path.split(/[\\/]/).pop() || path;
}
function traceLines(message: UIMessage): string[] {
if (message.traces?.length) return message.traces;
return message.content.trim() ? [message.content] : [];
}
const CLI_RUN_TOOL_NAMES = new Set(["run_cli_app", "cli_anything_run"]);
const CLI_RUN_STATUS_RANK: Record<CliRunStatus, number> = { running: 1, done: 2, error: 3 };
function isCliRunTraceLine(line: string): boolean {
return /^(run_cli_app|cli_anything_run)\(/.test(line.trim());
}
function parseCliRunTrace(line: string, status: CliRunStatus = "running"): CliRunSummary | null {
const match = /^(run_cli_app|cli_anything_run)\((.*)\)$/.exec(line.trim());
if (!match) return null;
const argsText = match[2].trim();
let argsObject: unknown = {};
if (argsText) {
try {
argsObject = JSON.parse(argsText);
} catch {
return {
key: line,
name: "cli",
args: [argsText],
json: false,
status,
};
}
}
return cliRunFromArguments(argsObject, { key: line, status });
}
function parseToolEventArguments(event: ToolProgressEvent): unknown {
const fnArgs = (event as { function?: { arguments?: unknown } }).function?.arguments;
const raw = fnArgs ?? event.arguments;
if (typeof raw !== "string") return raw ?? {};
if (!raw.trim()) return {};
try {
return JSON.parse(raw);
} catch {
return { args: [raw] };
}
}
function cliRunStatusFromPhase(phase: unknown): CliRunStatus {
if (phase === "error") return "error";
if (phase === "end") return "done";
return "running";
}
function cliRunError(event: ToolProgressEvent): string | undefined {
const error = event.error;
if (typeof error === "string") return error;
if (error && typeof error === "object") return JSON.stringify(error);
return undefined;
}
function cliRunFromArguments(
argsObject: unknown,
options: { key: string; status: CliRunStatus; error?: string },
): CliRunSummary {
if (!argsObject || typeof argsObject !== "object" || Array.isArray(argsObject)) {
return {
key: options.key,
name: "cli",
args: [],
json: false,
status: options.status,
error: options.error,
};
}
const record = argsObject as Record<string, unknown>;
const appName = typeof record.name === "string" && record.name.trim()
? record.name.trim()
: "cli";
const rawArgs = Array.isArray(record.args) ? record.args : [];
const cliArgs = rawArgs.filter((item): item is string => typeof item === "string");
return {
key: options.key,
name: appName,
args: cliArgs,
json: record.json === true || record.json === "true",
workingDir: typeof record.working_dir === "string" ? record.working_dir : undefined,
status: options.status,
error: options.error,
};
}
function cliRunFromEvent(event: ToolProgressEvent): CliRunSummary | null {
const name =
typeof (event as { function?: { name?: unknown } }).function?.name === "string"
? String((event as { function?: { name?: unknown } }).function?.name)
: typeof event.name === "string"
? event.name
: "";
if (!CLI_RUN_TOOL_NAMES.has(name)) return null;
const argsObject = parseToolEventArguments(event);
const key = event.call_id ? `call:${event.call_id}` : `${name}:${JSON.stringify(argsObject)}`;
return cliRunFromArguments(argsObject, {
key,
status: cliRunStatusFromPhase(event.phase),
error: cliRunError(event),
});
}
function mergeCliRun(existing: CliRunSummary | undefined, incoming: CliRunSummary): CliRunSummary {
if (!existing) return incoming;
return CLI_RUN_STATUS_RANK[incoming.status] >= CLI_RUN_STATUS_RANK[existing.status]
? { ...existing, ...incoming }
: existing;
}
function collectCliRuns(messages: UIMessage[]): CliRunSummary[] {
const runsByKey = new Map<string, CliRunSummary>();
for (const message of messages) {
if (message.kind !== "trace") continue;
let hasStructuredCliRun = false;
for (const event of message.toolEvents ?? []) {
const run = cliRunFromEvent(event);
if (!run) continue;
hasStructuredCliRun = true;
runsByKey.set(run.key, mergeCliRun(runsByKey.get(run.key), run));
}
if (hasStructuredCliRun) continue;
for (const line of traceLines(message)) {
const run = parseCliRunTrace(line);
if (!run || runsByKey.has(run.key)) continue;
runsByKey.set(run.key, run);
}
}
return [...runsByKey.values()];
}
function displayCliArg(arg: string): string {
return /\s/.test(arg) ? JSON.stringify(arg) : arg;
}
function formatCliArgs(run: CliRunSummary): string {
const args = [...(run.json ? ["--json"] : []), ...run.args].map(displayCliArg);
return args.join(" ");
}
function cliActivitySummaryKey(status: CliRunStatus | undefined, active: boolean): string {
if (status === "error") return "message.cliActivityFailedOne";
return active && status === "running" ? "message.cliActivityRunningOne" : "message.cliActivityRanOne";
}
function cliActivitySummaryDefault(status: CliRunStatus | undefined, active: boolean): string {
if (status === "error") return "CLI failed @{{name}}";
return `${active && status === "running" ? "Running" : "Ran"} CLI @{{name}}`;
}
function cliActivityManySummaryKey(runs: CliRunSummary[], active: boolean): string {
if (runs.some((run) => run.status === "error")) return "message.cliActivityFailedMany";
return active && runs.some((run) => run.status === "running")
? "message.cliActivityRunningMany"
: "message.cliActivityRanMany";
}
function cliActivityManySummaryDefault(runs: CliRunSummary[], active: boolean): string {
if (runs.some((run) => run.status === "error")) return "{{count}} CLI failed";
return `${active && runs.some((run) => run.status === "running") ? "Running" : "Ran"} {{count}} CLIs`;
}
function cliRunLabelKey(run: CliRunSummary, active: boolean): string {
if (run.status === "error") return "message.cliRunFailed";
return active && run.status === "running" ? "message.cliRunRunning" : "message.cliRunRan";
}
function cliRunLabelDefault(run: CliRunSummary, active: boolean): string {
if (run.status === "error") return "CLI failed";
return active && run.status === "running" ? "Running CLI" : "Ran CLI";
}
function fileActivityVerb(editing: boolean, failed: boolean): string {
if (failed) return "Failed";
return editing ? "Editing" : "Edited";
@ -519,6 +761,120 @@ function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">):
return edit.added > 0 || edit.deleted > 0;
}
function CliRunGroup({
runs,
active,
cliAppsByName,
}: {
runs: CliRunSummary[];
active: boolean;
cliAppsByName: Map<string, CliAppInfo>;
}) {
if (runs.length === 0) return null;
return (
<ul className="space-y-1 border-l border-cyan-500/20 pl-3" data-testid="activity-cli-runs">
{runs.map((run) => (
<CliRunRow
key={run.key}
run={run}
active={active}
app={cliAppsByName.get(run.name.toLowerCase())}
/>
))}
</ul>
);
}
function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean; app?: CliAppInfo }) {
const { t } = useTranslation();
const [logoFailed, setLogoFailed] = useState(false);
const args = formatCliArgs(run);
const failed = run.status === "error";
const rowActive = active && run.status === "running";
const color = failed ? "#DC2626" : app?.brand_color || "#0891B2";
const logoUrl = app?.logo_url && !logoFailed ? app.logo_url : null;
return (
<li
className={cn(
"grid min-w-0 grid-cols-[minmax(0,1fr)] rounded-[10px] border px-2.5 py-2 text-xs",
"shadow-[0_6px_18px_rgba(15,23,42,0.045)] transition-colors",
)}
style={{
borderColor: alphaColor(color, rowActive ? 34 : failed ? 28 : 22),
backgroundColor: alphaColor(color, rowActive ? 9 : failed ? 7 : 6),
}}
>
<div className="flex min-w-0 items-center gap-2">
<span
data-testid={`activity-cli-logo-${run.name.toLowerCase()}`}
className={cn(
"grid h-7 w-7 shrink-0 place-items-center overflow-hidden rounded-[8px] border text-[10px] font-semibold text-white",
rowActive && "animate-pulse",
)}
style={{
borderColor: alphaColor(color, 26),
backgroundColor: logoUrl ? "hsl(var(--background))" : color,
boxShadow: `0 0 0 3px ${alphaColor(color, rowActive ? 10 : 6)}`,
}}
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
className="h-[70%] w-[70%] object-contain"
onError={() => setLogoFailed(true)}
/>
) : app ? (
cliAppInitials(app).slice(0, 2)
) : (
<Terminal className="h-3.5 w-3.5" aria-hidden />
)}
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<StreamingLabelSheen active={rowActive} className="shrink-0 text-[12px]">
{t(cliRunLabelKey(run, active), {
defaultValue: cliRunLabelDefault(run, active),
})}
</StreamingLabelSheen>
<span className="min-w-0 truncate font-mono text-[12px] font-semibold text-foreground/90">
@{run.name}
</span>
{failed ? (
<AlertCircle className="h-3 w-3 shrink-0 text-destructive/75" aria-hidden />
) : null}
</span>
{args ? (
<span className="mt-0.5 block truncate font-mono text-[11px] leading-relaxed text-muted-foreground/82">
{args}
</span>
) : null}
{run.error ? (
<span className="mt-0.5 block truncate text-[10.5px] leading-relaxed text-destructive/70">
{run.error}
</span>
) : null}
{run.workingDir ? (
<span className="mt-0.5 block truncate text-[10.5px] leading-relaxed text-muted-foreground/58">
{run.workingDir}
</span>
) : null}
</span>
</div>
</li>
);
}
function alphaColor(color: string, percent: number): string {
if (/^#[0-9a-f]{6}$/i.test(color)) {
const alpha = Math.round((percent / 100) * 255)
.toString(16)
.padStart(2, "0");
return `${color}${alpha}`;
}
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
}
function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
if (edits.length === 0) return null;
return (

View File

@ -9,9 +9,16 @@ import {
} from "react";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import {
CliAppMentionToken,
cliAppInitials,
splitCliAppMentionSegments,
type CliAppMentionSegment,
} from "@/components/CliAppMentionText";
import {
Activity,
ArrowUp,
AtSign,
BookOpen,
Check,
ChevronDown,
@ -41,7 +48,7 @@ import {
} from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
import type { SlashCommand, GoalStateWsPayload } from "@/lib/types";
import type { CliAppInfo, GoalStateWsPayload, OutboundCliAppMention, SlashCommand } from "@/lib/types";
import { cn } from "@/lib/utils";
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
@ -62,6 +69,7 @@ interface ThreadComposerProps {
modelLabel?: string | null;
variant?: "thread" | "hero";
slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[];
imageMode?: boolean;
onImageModeChange?: (enabled: boolean) => void;
onStop?: () => void;
@ -98,6 +106,12 @@ interface SlashPaletteLayout {
maxHeight: number;
}
interface CliAppMentionQuery {
query: string;
start: number;
end: number;
}
function slashCommandI18nKey(command: string): string {
return command.replace(/^\//, "").replace(/-/g, "_");
}
@ -167,6 +181,17 @@ function buildGoalMarkdownBody(summary: string, objective: string): string {
return o || s;
}
function cliAppMentionPayload(app: CliAppInfo): OutboundCliAppMention {
return {
name: app.name,
display_name: app.display_name,
category: app.category,
entry_point: app.entry_point,
logo_url: app.logo_url ?? null,
brand_color: app.brand_color ?? null,
};
}
function RunElapsedStrip({
startedAt,
goalState,
@ -371,6 +396,7 @@ export function ThreadComposer({
modelLabel = null,
variant = "thread",
slashCommands = [],
cliApps = [],
imageMode: controlledImageMode,
onImageModeChange,
onStop,
@ -382,6 +408,9 @@ export function ThreadComposer({
const [inlineError, setInlineError] = useState<string | null>(null);
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
const [cliAppMenuDismissed, setCliAppMenuDismissed] = useState(false);
const [selectedCliAppIndex, setSelectedCliAppIndex] = useState(0);
const [cursorPosition, setCursorPosition] = useState(0);
const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false);
const [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
@ -491,6 +520,52 @@ export function ThreadComposer({
}, [slashCommands, slashQuery, t]);
const showSlashMenu = filteredSlashCommands.length > 0;
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
if (disabled || cliAppMenuDismissed) return null;
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
const beforeCaret = value.slice(0, caret);
const match = /(?:^|\s)@([a-z0-9_-]*)$/i.exec(beforeCaret);
if (!match) return null;
const query = match[1].toLowerCase();
return {
query,
start: caret - query.length - 1,
end: caret,
};
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
const filteredCliApps = useMemo(() => {
if (!cliAppMention) return [];
return cliApps
.filter((app) => app.installed)
.filter((app) => {
const haystack = [
app.name,
app.display_name,
app.category,
app.description,
app.entry_point,
].join(" ").toLowerCase();
return haystack.includes(cliAppMention.query);
})
.slice(0, 8);
}, [cliAppMention, cliApps]);
const showCliAppMenu = filteredCliApps.length > 0;
const showAnyPalette = showSlashMenu || showCliAppMenu;
const mentionSegments = useMemo(
() => splitCliAppMentionSegments(value, cliApps),
[cliApps, value],
);
const hasCliMentionDecorations = mentionSegments.some((segment) => segment.kind === "cli");
const activeCliMentionApps = useMemo(() => {
const seen = new Set<string>();
return mentionSegments.flatMap((segment) => {
if (segment.kind !== "cli" || seen.has(segment.app.name)) return [];
seen.add(segment.app.name);
return [segment.app];
});
}, [mentionSegments]);
const [slashPaletteLayout, setSlashPaletteLayout] = useState<SlashPaletteLayout>({
placement: "above",
maxHeight: SLASH_PALETTE_MAX_HEIGHT_PX,
@ -500,6 +575,10 @@ export function ThreadComposer({
setSelectedCommandIndex(0);
}, [slashQuery]);
useEffect(() => {
setSelectedCliAppIndex(0);
}, [cliAppMention?.query]);
useEffect(() => {
if (selectedCommandIndex >= filteredSlashCommands.length) {
setSelectedCommandIndex(0);
@ -507,22 +586,29 @@ export function ThreadComposer({
}, [filteredSlashCommands.length, selectedCommandIndex]);
useEffect(() => {
if (!showSlashMenu) return;
if (selectedCliAppIndex >= filteredCliApps.length) {
setSelectedCliAppIndex(0);
}
}, [filteredCliApps.length, selectedCliAppIndex]);
useEffect(() => {
if (!showAnyPalette) return;
const dismissOnPointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && formRef.current?.contains(target)) return;
setSlashMenuDismissed(true);
setCliAppMenuDismissed(true);
};
document.addEventListener("pointerdown", dismissOnPointerDown, true);
return () => {
document.removeEventListener("pointerdown", dismissOnPointerDown, true);
};
}, [showSlashMenu]);
}, [showAnyPalette]);
useLayoutEffect(() => {
if (!showSlashMenu) return;
if (!showAnyPalette) return;
const updateLayout = () => {
const form = formRef.current;
@ -554,7 +640,7 @@ export function ThreadComposer({
window.removeEventListener("resize", updateLayout);
document.removeEventListener("scroll", updateLayout, true);
};
}, [filteredSlashCommands.length, showSlashMenu]);
}, [filteredCliApps.length, filteredSlashCommands.length, showAnyPalette]);
useEffect(() => {
if (!aspectMenuOpen) return;
@ -602,12 +688,36 @@ export function ThreadComposer({
(command: SlashCommand) => {
setValue(command.argHint ? `${command.command} ` : command.command);
setSlashMenuDismissed(true);
setCliAppMenuDismissed(false);
setInlineError(null);
resizeTextarea();
},
[resizeTextarea],
);
const chooseCliApp = useCallback(
(app: CliAppInfo) => {
if (!cliAppMention) return;
const suffix = value.slice(cliAppMention.end);
const mention = `@${app.name}${suffix.startsWith(" ") ? "" : " "}`;
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
const nextCursor = cliAppMention.start + mention.length;
setValue(next);
setCursorPosition(nextCursor);
setCliAppMenuDismissed(true);
setSlashMenuDismissed(false);
setInlineError(null);
resizeTextarea();
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(nextCursor, nextCursor);
});
},
[cliAppMention, resizeTextarea, value],
);
const submit = useCallback(() => {
if (!canSend) return;
const trimmed = value.trim();
@ -625,14 +735,21 @@ export function ThreadComposer({
preview: { url: img.dataUrl, name: img.file.name },
}))
: undefined;
const options: SendOptions | undefined = imageMode
? {
imageGeneration: {
enabled: true,
aspect_ratio: imageAspectRatio === "auto" ? null : imageAspectRatio,
},
}
: undefined;
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
const options: SendOptions | undefined =
imageMode || attachedCliApps.length > 0
? {
...(imageMode
? {
imageGeneration: {
enabled: true,
aspect_ratio: imageAspectRatio === "auto" ? null : imageAspectRatio,
},
}
: {}),
...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}),
}
: undefined;
onSend(trimmed, payload, options);
setValue("");
setInlineError(null);
@ -640,10 +757,36 @@ export function ThreadComposer({
// preview here without affecting the rendered message.
clear();
setSlashMenuDismissed(false);
setCliAppMenuDismissed(false);
setCursorPosition(0);
resizeTextarea();
}, [canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
}, [activeCliMentionApps, canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (showCliAppMenu) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedCliAppIndex((idx) => (idx + 1) % filteredCliApps.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedCliAppIndex(
(idx) => (idx - 1 + filteredCliApps.length) % filteredCliApps.length,
);
return;
}
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
e.preventDefault();
chooseCliApp(filteredCliApps[selectedCliAppIndex]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setCliAppMenuDismissed(true);
return;
}
}
if (showSlashMenu) {
if (e.key === "ArrowDown") {
e.preventDefault();
@ -719,6 +862,12 @@ export function ThreadComposer({
const attachButtonDisabled = disabled || full;
const showStopButton = isStreaming && !!onStop;
const inputTextClasses = cn(
"w-full resize-none bg-transparent",
isHero
? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6"
: "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
);
return (
<form
@ -743,6 +892,16 @@ export function ThreadComposer({
onChoose={chooseSlashCommand}
/>
) : null}
{showCliAppMenu ? (
<CliAppMentionPalette
apps={filteredCliApps}
selectedIndex={selectedCliAppIndex}
layout={slashPaletteLayout}
isHero={isHero}
onHover={setSelectedCliAppIndex}
onChoose={chooseCliApp}
/>
) : null}
<div
className={cn(
"relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
@ -787,30 +946,42 @@ export function ThreadComposer({
{runStartedAt != null || goalState?.active ? (
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
) : null}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
setValue(e.target.value);
setSlashMenuDismissed(false);
}}
onInput={onInput}
onKeyDown={onKeyDown}
onPaste={onPaste}
rows={1}
placeholder={resolvedPlaceholder}
disabled={disabled}
aria-label={t("thread.composer.inputAria")}
className={cn(
"w-full resize-none bg-transparent",
isHero
? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6"
: "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
"placeholder:text-muted-foreground/70",
"focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
)}
/>
<div className="relative">
{hasCliMentionDecorations ? (
<ComposerCliMentionOverlay
segments={mentionSegments}
isHero={isHero}
className={inputTextClasses}
/>
) : null}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
setValue(e.target.value);
setSlashMenuDismissed(false);
setCliAppMenuDismissed(false);
setCursorPosition(e.target.selectionStart ?? e.target.value.length);
}}
onInput={onInput}
onKeyDown={onKeyDown}
onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
onSelect={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
onPaste={onPaste}
rows={1}
placeholder={resolvedPlaceholder}
disabled={disabled}
aria-label={t("thread.composer.inputAria")}
className={cn(
inputTextClasses,
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
"focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
hasCliMentionDecorations && "text-transparent selection:bg-primary/20",
)}
/>
</div>
{inlineError ? (
<div
role="alert"
@ -962,6 +1133,40 @@ export function ThreadComposer({
);
}
function ComposerCliMentionOverlay({
segments,
isHero,
className,
}: {
segments: CliAppMentionSegment[];
isHero: boolean;
className: string;
}) {
return (
<div
aria-hidden
className={cn(
className,
"pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap break-words text-foreground",
)}
>
{segments.map((segment, index) => {
if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>;
}
return (
<CliAppMentionToken
key={`cli-${segment.app.name}-${index}`}
app={segment.app}
label={segment.text}
variant="composer"
isHero={isHero}
/>
);
})}
</div>
);
}
interface SlashCommandPaletteProps {
commands: SlashCommand[];
selectedIndex: number;
@ -971,6 +1176,15 @@ interface SlashCommandPaletteProps {
onChoose: (command: SlashCommand) => void;
}
interface CliAppMentionPaletteProps {
apps: CliAppInfo[];
selectedIndex: number;
layout: SlashPaletteLayout;
isHero: boolean;
onHover: (index: number) => void;
onChoose: (app: CliAppInfo) => void;
}
function ImageAspectMenu({
selected,
isHero,
@ -1024,6 +1238,121 @@ function ImageAspectMenu({
);
}
function CliAppMentionPalette({
apps,
selectedIndex,
layout,
isHero,
onHover,
onChoose,
}: CliAppMentionPaletteProps) {
const { t } = useTranslation();
const listMaxHeight = Math.max(
0,
layout.maxHeight - SLASH_PALETTE_CHROME_PX,
);
return (
<div
role="listbox"
aria-label={t("thread.composer.mentions.ariaLabel")}
style={{ maxHeight: layout.maxHeight }}
className={cn(
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
>
<div className="flex items-center gap-1.5 px-2 pb-1 pt-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70">
<AtSign className="h-3 w-3" aria-hidden />
<span>{t("thread.composer.mentions.label")}</span>
</div>
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
{apps.map((app, index) => {
const selected = index === selectedIndex;
return (
<button
key={app.name}
type="button"
role="option"
aria-selected={selected}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
e.preventDefault();
onChoose(app);
}}
className={cn(
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
selected
? "bg-primary/10 text-foreground"
: "text-foreground/86 hover:bg-accent/55",
)}
>
<CliAppMentionLogo app={app} selected={selected} />
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-baseline gap-2">
<span className="font-mono text-[13px] font-semibold text-foreground">
@{app.name}
</span>
<span className="truncate text-[13px] font-medium">
{app.display_name}
</span>
</span>
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
{app.category}
{app.entry_point ? ` · ${app.entry_point}` : ""}
</span>
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-2 px-2 pt-1.5 text-[10.5px] text-muted-foreground/70">
<span>{t("thread.composer.slash.navigateHint")}</span>
<span>{t("thread.composer.slash.selectHint")}</span>
<span>{t("thread.composer.slash.closeHint")}</span>
</div>
</div>
);
}
function CliAppMentionLogo({
app,
selected,
}: {
app: CliAppInfo;
selected: boolean;
}) {
const [failed, setFailed] = useState(false);
const color = app.brand_color || "hsl(var(--primary))";
if (app.logo_url && !failed) {
return (
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] border bg-background",
selected ? "border-primary/25" : "border-border/65",
)}
>
<img
src={app.logo_url}
alt=""
className="h-4.5 w-4.5 object-contain"
onError={() => setFailed(true)}
/>
</span>
);
}
return (
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] text-[10.5px] font-semibold text-white"
style={{ backgroundColor: color }}
>
{cliAppInitials(app)}
</span>
);
}
function SlashCommandPalette({
commands,
selectedIndex,

View File

@ -6,7 +6,7 @@ import {
AgentActivityCluster,
isAgentActivityMember,
} from "@/components/thread/AgentActivityCluster";
import type { UIMessage } from "@/lib/types";
import type { CliAppInfo, UIMessage } from "@/lib/types";
interface ThreadMessagesProps {
messages: UIMessage[];
@ -14,6 +14,7 @@ interface ThreadMessagesProps {
isStreaming?: boolean;
hiddenMessageCount?: number;
onLoadEarlier?: () => void;
cliApps?: CliAppInfo[];
}
export type DisplayUnit =
@ -164,6 +165,7 @@ export function ThreadMessages({
isStreaming = false,
hiddenMessageCount = 0,
onLoadEarlier,
cliApps = [],
}: ThreadMessagesProps) {
const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
@ -208,6 +210,7 @@ export function ThreadMessages({
messages={unit.messages}
isTurnStreaming={index === liveActivityClusterIndex}
hasBodyBelow={hasBodyBelow}
cliApps={cliApps}
/>
) : (
<MessageBubble
@ -217,6 +220,7 @@ export function ThreadMessages({
? copyFlags[index]
: true
}
cliApps={cliApps}
/>
)}
</div>

View File

@ -19,8 +19,8 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import { listSlashCommands } from "@/lib/api";
import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types";
import { fetchCliApps, listSlashCommands } from "@/lib/api";
import type { ChatSummary, CliAppInfo, SlashCommand, UIMessage } from "@/lib/types";
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
import { useClient } from "@/providers/ClientProvider";
@ -97,6 +97,7 @@ export function ThreadShell({
const { client, modelName, token } = useClient();
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
const [heroImageMode, setHeroImageMode] = useState(false);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
@ -247,6 +248,40 @@ export function ThreadShell({
};
}, [token]);
const refreshCliApps = useCallback(async () => {
try {
const payload = await fetchCliApps(token);
setCliApps(payload.apps.filter((app) => app.installed));
} catch {
setCliApps([]);
}
}, [token]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const payload = await fetchCliApps(token);
if (!cancelled) setCliApps(payload.apps.filter((app) => app.installed));
} catch {
if (!cancelled) setCliApps([]);
}
};
load();
const refreshOnFocus = () => {
if (document.visibilityState === "hidden") return;
void refreshCliApps();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
};
}, [refreshCliApps, token]);
const handleWelcomeSend = useCallback(
async (content: string, images?: SendImage[], options?: SendOptions) => {
if (booting) return;
@ -332,6 +367,7 @@ export function ThreadShell({
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
cliApps={cliApps}
imageMode={showHeroComposer ? heroImageMode : undefined}
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
onStop={stop}
@ -351,6 +387,7 @@ export function ThreadShell({
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
slashCommands={slashCommands}
cliApps={cliApps}
imageMode={heroImageMode}
onImageModeChange={setHeroImageMode}
runStartedAt={runStartedAt}
@ -391,6 +428,7 @@ export function ThreadShell({
scrollToBottomSignal={scrollToBottomSignal}
conversationKey={historyKey}
showScrollToBottomButton={!!session}
cliApps={cliApps}
/>
</section>
);

View File

@ -14,7 +14,7 @@ import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
import type { CliAppInfo, UIMessage } from "@/lib/types";
interface ThreadViewportProps {
messages: UIMessage[];
@ -24,6 +24,7 @@ interface ThreadViewportProps {
scrollToBottomSignal?: number;
conversationKey?: string | null;
showScrollToBottomButton?: boolean;
cliApps?: CliAppInfo[];
}
const NEAR_BOTTOM_PX = 48;
@ -53,6 +54,7 @@ export function ThreadViewport({
scrollToBottomSignal = 0,
conversationKey = null,
showScrollToBottomButton = true,
cliApps = [],
}: ThreadViewportProps) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
@ -249,6 +251,7 @@ export function ThreadViewport({
isStreaming={isStreaming}
hiddenMessageCount={hiddenMessageCount}
onLoadEarlier={loadEarlierMessages}
cliApps={cliApps}
/>
</div>
</div>

View File

@ -216,6 +216,52 @@
}
}
@keyframes cli-app-linked-sheen {
0% {
transform: translateX(-140%) skewX(-14deg);
opacity: 0;
}
18% {
opacity: 0.7;
}
72%,
100% {
transform: translateX(140%) skewX(-14deg);
opacity: 0;
}
}
.cli-app-linked-chip::after {
content: "";
position: absolute;
inset: -1px;
pointer-events: none;
background: linear-gradient(
90deg,
transparent 0%,
hsl(var(--foreground) / 0.14) 46%,
hsl(var(--background) / 0.7) 50%,
hsl(var(--foreground) / 0.12) 54%,
transparent 100%
);
animation: cli-app-linked-sheen 1.25s ease-out 1;
}
.dark .cli-app-linked-chip::after {
background: linear-gradient(
90deg,
transparent 0%,
hsl(var(--foreground) / 0.12) 46%,
hsl(var(--background) / 0.5) 50%,
hsl(var(--foreground) / 0.1) 54%,
transparent 100%
);
}
@media (prefers-reduced-motion: reduce) {
.cli-app-linked-chip::after {
animation: none;
content: none;
}
}
/* Subtle scrollbar that doesn't fight the dark background. */
.scrollbar-thin {
scrollbar-width: thin;

View File

@ -2,10 +2,16 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useClient } from "@/providers/ClientProvider";
import { toMediaAttachment } from "@/lib/media";
import { mergeUniqueToolTraceLines, toolTraceLinesFromEvents } from "@/lib/tool-traces";
import {
mergeToolProgressEvents,
mergeUniqueToolTraceLines,
normalizeToolProgressEvents,
toolTraceLinesFromEvents,
} from "@/lib/tool-traces";
import type { StreamError } from "@/lib/nanobot-client";
import type {
InboundEvent,
OutboundCliAppMention,
OutboundImageGeneration,
OutboundMedia,
GoalStateWsPayload,
@ -297,6 +303,7 @@ export interface SendImage {
export interface SendOptions {
imageGeneration?: OutboundImageGeneration;
cliApps?: OutboundCliAppMention[];
}
export function useNanobotStream(
@ -657,6 +664,7 @@ export function useNanobotStream(
// Attach them to the last trace row if it was the last emitted item
// so a sequence of calls collapses into one compact trace group.
if (ev.kind === "tool_hint" || ev.kind === "progress") {
const structuredEvents = normalizeToolProgressEvents(ev.tool_events);
const structuredLines = toolTraceLinesFromEvents(ev.tool_events);
const lines = structuredLines.length > 0
? structuredLines
@ -681,13 +689,15 @@ export function useNanobotStream(
const mergedLines = structuredLines.length > 0
? mergeUniqueToolTraceLines(previousTraces, structuredLines)
: null;
if (mergedLines && !mergedLines.added) return prev;
const merged: UIMessage = {
...last,
traces: mergedLines ? mergedLines.traces : [...previousTraces, ...lines],
content: mergedLines
? mergedLines.traces[mergedLines.traces.length - 1]
: lines[lines.length - 1],
toolEvents: structuredEvents.length
? mergeToolProgressEvents(last.toolEvents, structuredEvents)
: last.toolEvents,
activitySegmentId: last.activitySegmentId ?? segmentId,
};
return [...prev.slice(0, -1), merged];
@ -700,6 +710,7 @@ export function useNanobotStream(
kind: "trace",
content: lines[lines.length - 1],
traces: lines,
...(structuredEvents.length ? { toolEvents: structuredEvents } : {}),
activitySegmentId: segmentId,
createdAt: Date.now(),
},
@ -836,6 +847,7 @@ export function useNanobotStream(
content,
createdAt: Date.now(),
...(previews ? { images: previews } : {}),
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
},
];
});

View File

@ -80,6 +80,7 @@
"providers": "Providers",
"image": "Image",
"web": "Web",
"cliApps": "CLI Apps",
"runtime": "Runtime",
"advanced": "Advanced"
},
@ -94,6 +95,7 @@
"imageDefaults": "Defaults",
"webSearch": "Web search",
"webBehavior": "Behavior",
"cliApps": "CLI Apps",
"identity": "Identity",
"safety": "Safety",
"capabilities": "Capabilities",
@ -115,6 +117,7 @@
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"brandLogos": "Brand logos",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
@ -141,6 +144,8 @@
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"cliAppsCatalog": "Catalog",
"cliAppsFilter": "Filter",
"configurationDocs": "Configuration docs"
},
"help": {
@ -154,6 +159,7 @@
"density": "Stored only in this browser.",
"activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "Keep long code lines readable on smaller screens.",
"brandLogos": "Show third-party provider and CLI logos in Settings.",
"maxResults": "Results returned by each web_search call.",
"timeout": "Seconds before a search provider request times out.",
"jinaReader": "Use Jina Reader for web_fetch when available.",
@ -168,8 +174,41 @@
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"cliAppsCatalog": "Install only the app-specific CLI adapters nanobot can run locally; desktop apps stay untouched.",
"cliAppsFilter": "Search by app, category, or capability.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"cliApps": {
"allCategories": "All categories",
"availableCount": "{{count}} apps",
"installedCount": "{{count}} CLIs installed",
"summary": "{{installed}} of {{total}} CLIs installed",
"filterAll": "All",
"filterInstalled": "Installed CLIs",
"filterNotInstalled": "Not installed",
"searchPlaceholder": "Search CLIs",
"loading": "Loading CLI Apps...",
"empty": "No CLI Apps match this filter.",
"statusInstalled": "CLI installed",
"statusMissing": "Missing",
"statusAvailable": "Available",
"statusUnsupported": "Unsupported",
"statusNotInstalled": "CLI not installed",
"requires": "Requires",
"test": "Test CLI",
"update": "Update CLI",
"uninstall": "Uninstall CLI",
"install": "Install CLI",
"readyTitle": "@{{name}} is ready",
"readyStatus": "Ready",
"readyTry": "Try @{{name}}",
"readyCopied": "Copied",
"readyPrompt": "Use @{{name}} to inspect what this CLI can do.",
"openChat": "Open chat",
"unsupported": "Unsupported",
"unavailable": "Unavailable",
"noDescription": "No description available."
},
"values": {
"light": "Light",
"dark": "Dark",
@ -259,6 +298,9 @@
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search."
},
"legal": {
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."
},
"image": {
"selectProvider": "Select provider",
"selectAspect": "Select aspect",
@ -462,6 +504,10 @@
}
}
},
"mentions": {
"ariaLabel": "CLI Apps",
"label": "CLI APPS"
},
"encoding": "Encoding…",
"remove": "Remove attachment",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
@ -493,6 +539,15 @@
"agentActivityToolsOnly": "{{tools}} tool calls",
"agentActivityLiveSummary": "Working… · {{reasoning}} steps · {{tools}} tool calls",
"agentActivityLiveToolsOnly": "Working… · {{tools}} tool calls",
"cliActivityRunningOne": "Running CLI @{{name}}",
"cliActivityRanOne": "Ran CLI @{{name}}",
"cliActivityFailedOne": "CLI failed @{{name}}",
"cliActivityRunningMany": "Running {{count}} CLIs",
"cliActivityRanMany": "Ran {{count}} CLIs",
"cliActivityFailedMany": "{{count}} CLI failed",
"cliRunRunning": "Running CLI",
"cliRunRan": "Ran CLI",
"cliRunFailed": "CLI failed",
"imageAttachment": "Image attachment",
"copyReply": "Copy reply",
"copiedReply": "Copied reply",

View File

@ -68,6 +68,7 @@
"providers": "提供商",
"image": "图片",
"web": "网页",
"cliApps": "CLI 应用",
"runtime": "运行时",
"advanced": "高级"
},
@ -82,6 +83,7 @@
"imageDefaults": "默认值",
"webSearch": "网页搜索",
"webBehavior": "行为",
"cliApps": "CLI 应用",
"identity": "身份",
"safety": "安全",
"capabilities": "能力",
@ -103,6 +105,7 @@
"density": "密度",
"activityMode": "活动细节",
"codeWrap": "代码换行",
"brandLogos": "品牌 Logo",
"maxResults": "最大结果数",
"timeout": "超时",
"jinaReader": "Jina Reader",
@ -129,6 +132,8 @@
"ssrfWhitelist": "SSRF 白名单",
"mcpServers": "MCP 服务器",
"pathAppend": "PATH 追加",
"cliAppsCatalog": "目录",
"cliAppsFilter": "筛选",
"configurationDocs": "配置文档"
},
"help": {
@ -142,6 +147,7 @@
"density": "仅保存在当前浏览器。",
"activityMode": "选择默认显示多少 agent 活动细节。",
"codeWrap": "让较小屏幕上的长代码行更易读。",
"brandLogos": "在设置页显示第三方服务商和 CLI 的 Logo。",
"maxResults": "每次 web_search 返回的结果数量。",
"timeout": "搜索服务商请求超时秒数。",
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
@ -156,8 +162,41 @@
"botIcon": "显示在 bot 名称旁的短 emoji 或文本。",
"timezone": "运行时上下文和计划任务使用的 IANA 时区。",
"toolHintMaxLength": "工具进度提示显示的最大字符数。",
"cliAppsCatalog": "只安装 nanobot 在本机调用应用时需要的 CLI 适配层,不触碰应用本体。",
"cliAppsFilter": "按应用、分类或能力搜索。",
"advancedReadOnly": "高级安全控制在 WebUI 中只读;需要时请谨慎编辑 config.json。"
},
"cliApps": {
"allCategories": "全部分类",
"availableCount": "{{count}} 个应用",
"installedCount": "已安装 {{count}} 个 CLI",
"summary": "CLI 已安装 {{installed}} / {{total}}",
"filterAll": "全部",
"filterInstalled": "已装 CLI",
"filterNotInstalled": "未装 CLI",
"searchPlaceholder": "搜索 CLI",
"loading": "正在加载 CLI 应用...",
"empty": "没有匹配的 CLI 应用。",
"statusInstalled": "CLI 已安装",
"statusMissing": "缺失",
"statusAvailable": "可用",
"statusUnsupported": "暂不支持",
"statusNotInstalled": "CLI 未安装",
"requires": "依赖",
"test": "测试 CLI",
"update": "更新 CLI",
"uninstall": "卸载 CLI",
"install": "安装 CLI",
"readyTitle": "@{{name}} 已就绪",
"readyStatus": "就绪",
"readyTry": "试试 @{{name}}",
"readyCopied": "已复制",
"readyPrompt": "用 @{{name}} 看看这个 CLI 能做什么。",
"openChat": "回到对话",
"unsupported": "暂不支持",
"unavailable": "不可用",
"noDescription": "暂无描述。"
},
"values": {
"light": "浅色",
"dark": "深色",
@ -247,6 +286,9 @@
"searchPlaceholder": "搜索服务商",
"noMatches": "没有匹配的服务商。"
},
"legal": {
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
},
"image": {
"selectProvider": "选择服务商",
"selectAspect": "选择比例",
@ -449,6 +491,10 @@
}
}
},
"mentions": {
"ariaLabel": "CLI 应用",
"label": "CLI 应用"
},
"encoding": "处理中…",
"remove": "移除附件",
"normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)",
@ -481,6 +527,15 @@
"agentActivityToolsOnly": "{{tools}} 次工具调用",
"agentActivityLiveSummary": "进行中… · {{reasoning}} 步 · {{tools}} 次工具调用",
"agentActivityLiveToolsOnly": "进行中… · {{tools}} 次工具调用",
"cliActivityRunningOne": "正在运行 CLI @{{name}}",
"cliActivityRanOne": "已运行 CLI @{{name}}",
"cliActivityFailedOne": "CLI 调用失败 @{{name}}",
"cliActivityRunningMany": "正在运行 {{count}} 个 CLI",
"cliActivityRanMany": "已运行 {{count}} 个 CLI",
"cliActivityFailedMany": "{{count}} 个 CLI 调用失败",
"cliRunRunning": "正在运行 CLI",
"cliRunRan": "已运行 CLI",
"cliRunFailed": "CLI 调用失败",
"imageAttachment": "图片附件",
"copyReply": "复制回复",
"copiedReply": "已复制回复",

View File

@ -1,5 +1,6 @@
import type {
ChatSummary,
CliAppsPayload,
ImageGenerationSettingsUpdate,
ProviderSettingsUpdate,
SettingsPayload,
@ -106,6 +107,24 @@ export async function fetchSettings(
return request<SettingsPayload>(`${base}/api/settings`, token);
}
export async function fetchCliApps(
token: string,
base: string = "",
): Promise<CliAppsPayload> {
return request<CliAppsPayload>(`${base}/api/settings/cli-apps`, token);
}
export async function runCliAppAction(
token: string,
action: "install" | "update" | "uninstall" | "test",
name: string,
base: string = "",
): Promise<CliAppsPayload> {
const query = new URLSearchParams();
query.set("name", name);
return request<CliAppsPayload>(`${base}/api/settings/cli-apps/${action}?${query}`, token);
}
export async function listSlashCommands(
token: string,
base: string = "",

View File

@ -2,6 +2,7 @@ import type {
ConnectionStatus,
InboundEvent,
Outbound,
OutboundCliAppMention,
OutboundImageGeneration,
OutboundMedia,
GoalStateWsPayload,
@ -304,7 +305,7 @@ export class NanobotClient {
chatId: string,
content: string,
media?: OutboundMedia[],
options?: { imageGeneration?: OutboundImageGeneration },
options?: { imageGeneration?: OutboundImageGeneration; cliApps?: OutboundCliAppMention[] },
): void {
this.knownChats.add(chatId);
const frame: Outbound = {
@ -313,6 +314,7 @@ export class NanobotClient {
content,
...(media && media.length > 0 ? { media } : {}),
...(options?.imageGeneration ? { image_generation: options.imageGeneration } : {}),
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
webui: true,
};
this.queueSend(frame);

View File

@ -1,3 +1,5 @@
import type { ToolProgressEvent } from "@/lib/types";
/** Drop duplicate tool_call objects (same id or identical formatted trace). */
export function dedupeToolCallsForUi(calls: unknown): unknown[] {
if (!Array.isArray(calls) || calls.length === 0) return [];
@ -40,15 +42,60 @@ export function formatToolCallTrace(call: unknown): string | null {
}
const VALID_PHASES = new Set(["start", "end", "error"]);
const PHASE_RANK: Record<string, number> = { start: 1, end: 2, error: 3 };
export function toolTraceLinesFromEvents(events: unknown): string[] {
export function normalizeToolProgressEvents(events: unknown): ToolProgressEvent[] {
if (!Array.isArray(events)) return [];
const seen = new Set<string>();
const lines: string[] = [];
const out: ToolProgressEvent[] = [];
for (const event of events) {
if (!event || typeof event !== "object") continue;
const phase = (event as { phase?: unknown }).phase;
const record = event as ToolProgressEvent;
const phase = record.phase;
if (!(phase && typeof phase === "string" && VALID_PHASES.has(phase))) continue;
const name = typeof record.name === "string" ? record.name : "";
const functionName =
typeof (record as { function?: { name?: unknown } }).function?.name === "string"
? String((record as { function?: { name?: unknown } }).function?.name)
: "";
if (!name && !functionName) continue;
out.push(record);
}
return out;
}
function toolEventKey(event: ToolProgressEvent): string {
if (event.call_id) return `call:${event.call_id}`;
return formatToolCallTrace(event) ?? JSON.stringify(event);
}
export function mergeToolProgressEvents(
previous: ToolProgressEvent[] | undefined,
incoming: ToolProgressEvent[],
): ToolProgressEvent[] {
if (!previous?.length) return incoming;
if (!incoming.length) return previous;
const next = [...previous];
const indexByKey = new Map(next.map((event, index) => [toolEventKey(event), index]));
for (const event of incoming) {
const key = toolEventKey(event);
const existingIndex = indexByKey.get(key);
if (existingIndex === undefined) {
indexByKey.set(key, next.length);
next.push(event);
continue;
}
const existing = next[existingIndex];
const incomingRank = PHASE_RANK[String(event.phase)] ?? 0;
const existingRank = PHASE_RANK[String(existing.phase)] ?? 0;
next[existingIndex] = incomingRank >= existingRank ? { ...existing, ...event } : existing;
}
return next;
}
export function toolTraceLinesFromEvents(events: unknown): string[] {
const seen = new Set<string>();
const lines: string[] = [];
for (const event of normalizeToolProgressEvents(events)) {
const callId = (event as { call_id?: unknown }).call_id;
if (callId && typeof callId === "string") {
if (seen.has(callId)) continue;

View File

@ -40,6 +40,9 @@ export interface UIMessage {
/** For trace rows: each individual hint line, so consecutive hints can
* render as a single collapsible group. */
traces?: string[];
/** Structured tool events behind trace rows. Kept so activity cards can
* distinguish running, completed, and failed tool phases. */
toolEvents?: ToolProgressEvent[];
/** Activity rows: explicit file edits emitted by edit tools. */
fileEdits?: UIFileEdit[];
/** Activity rows created during the same agent phase share one collapsible block. */
@ -48,6 +51,8 @@ export interface UIMessage {
images?: UIImage[];
/** Signed or local UI-renderable media attachments. */
media?: UIMediaAttachment[];
/** App-specific CLI adapters explicitly attached to this user turn. */
cliApps?: UICliAppAttachment[];
/** Assistant turn: accumulated model reasoning / thinking text. Built up
* incrementally from ``reasoning_delta`` frames; finalized when
* ``reasoning_end`` arrives. */
@ -59,6 +64,15 @@ export interface UIMessage {
latencyMs?: number;
}
export interface UICliAppAttachment {
name: string;
display_name?: string;
category?: string;
entry_point?: string;
logo_url?: string | null;
brand_color?: string | null;
}
/** Structured UI blob on ``progress`` WS frames; channels may add more ``kind`` values later. */
export interface AgentUIBlob {
kind: string;
@ -252,6 +266,34 @@ export interface SettingsPayload {
restart_required_sections?: Array<"runtime" | "web" | "image">;
}
export interface CliAppInfo {
name: string;
display_name: string;
category: string;
description: string;
requires: string;
source: string;
entry_point: string;
install_supported: boolean;
installed: boolean;
available: boolean;
status: "installed" | "missing" | "available" | "unsupported" | "not_installed" | string;
logo_url?: string | null;
brand_color?: string | null;
skill_installed: boolean;
}
export interface CliAppsPayload {
apps: CliAppInfo[];
installed_count: number;
catalog_updated_at?: string | null;
last_action?: {
ok: boolean;
message: string;
output?: string | null;
};
}
export interface SettingsUpdate {
model?: string;
provider?: string;
@ -394,6 +436,15 @@ export interface OutboundImageGeneration {
aspect_ratio?: string | null;
}
export interface OutboundCliAppMention {
name: string;
display_name?: string;
category?: string;
entry_point?: string;
logo_url?: string | null;
brand_color?: string | null;
}
/** Response shape for ``GET .../webui-thread`` (server-built transcript replay). */
export interface WebuiThreadPersistedPayload {
schemaVersion: number;
@ -411,6 +462,7 @@ export type Outbound =
content: string;
media?: OutboundMedia[];
image_generation?: OutboundImageGeneration;
cli_apps?: OutboundCliAppMention[];
/** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */
webui?: true;

View File

@ -2,7 +2,24 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it } from "vitest";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import type { UIMessage } from "@/lib/types";
import type { CliAppInfo, UIMessage } from "@/lib/types";
const BLENDER_CLI_APP: CliAppInfo = {
name: "blender",
display_name: "Blender",
category: "3d",
description: "3D creation",
requires: "",
source: "harness",
entry_point: "cli-anything-blender",
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: "https://example.invalid/blender.svg",
brand_color: "#E87D0D",
skill_installed: true,
};
function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] {
const rows: UIMessage[] = [
@ -271,6 +288,67 @@ describe("AgentActivityCluster", () => {
}
});
it("renders CLI app runs as dedicated activity rows", () => {
const line = 'run_cli_app({"name":"blender","args":["--background","scene.blend"],"json":true})';
render(
<AgentActivityCluster
messages={[{
id: "t-cli",
role: "tool",
kind: "trace",
content: line,
traces: [line],
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
cliApps={[BLENDER_CLI_APP]}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /running cli @blender/i }));
const cliRuns = screen.getByTestId("activity-cli-runs");
expect(cliRuns).toHaveTextContent("Running CLI");
expect(cliRuns).toHaveTextContent("@blender");
expect(cliRuns).toHaveTextContent("--json --background scene.blend");
expect(screen.getByTestId("activity-cli-logo-blender")).toBeInTheDocument();
expect(screen.queryByText(/run_cli_app/)).not.toBeInTheDocument();
});
it("labels rejected CLI app calls as failed instead of ran", () => {
render(
<AgentActivityCluster
messages={[{
id: "t-cli-fail",
role: "tool",
kind: "trace",
content: 'run_cli_app({"name":"github","args":["repo","view"],"json":"true"})',
traces: ['run_cli_app({"name":"github","args":["repo","view"],"json":"true"})'],
toolEvents: [
{
phase: "error",
call_id: "call-github",
name: "run_cli_app",
arguments: { name: "github", args: ["repo", "view"], json: "true" },
error: "Error: CLI app 'github' not found",
},
],
createdAt: 1,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /cli failed @github/i }));
expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("CLI failed");
expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("@github");
expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("Error: CLI app 'github' not found");
expect(screen.queryByText("Ran CLI")).not.toBeInTheDocument();
});
it("does not render zero diff counters for completed edits", () => {
render(
<AgentActivityCluster

View File

@ -2,10 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
deleteSession,
fetchCliApps,
fetchSidebarState,
fetchWebuiThread,
listSessions,
listSlashCommands,
runCliAppAction,
updateSidebarState,
updateImageGenerationSettings,
updateProviderSettings,
@ -116,6 +118,33 @@ describe("webui API helpers", () => {
);
});
it("reads CLI Apps catalog and serializes actions", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
apps: [],
installed_count: 0,
catalog_updated_at: "2026-04-18",
}),
} as Response);
await expect(fetchCliApps("tok")).resolves.toMatchObject({ apps: [] });
expect(fetch).toHaveBeenCalledWith(
"/api/settings/cli-apps",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await runCliAppAction("tok", "install", "gimp");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/cli-apps/install?name=gimp",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("reads and writes persisted sidebar state", async () => {
const state = {
schema_version: 1,

View File

@ -727,6 +727,8 @@ describe("App layout", () => {
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Advanced" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Appearance" }));
expect(screen.getByText("Brand logos")).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
expect(screen.getByText("AI")).toBeInTheDocument();
const modelInput = screen.getByDisplayValue("openai/gpt-4o");
@ -739,6 +741,8 @@ describe("App layout", () => {
fireEvent.click(within(settingsNav).getByRole("button", { name: "Providers" }));
expect(screen.getByText("OpenRouter")).toBeInTheDocument();
expect(screen.getByText("Ant Ling")).toBeInTheDocument();
expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument();
expect(screen.getByText(/Product names, logos, and brands/)).toBeInTheDocument();
expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0);
fireEvent.click(screen.getByText("OpenAI"));
fireEvent.click(screen.getByRole("button", { name: "Edit" }));

View File

@ -2,7 +2,42 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest";
import { MessageBubble } from "@/components/MessageBubble";
import type { UIMessage } from "@/lib/types";
import type { CliAppInfo, UIMessage } from "@/lib/types";
const CLI_APPS: CliAppInfo[] = [
{
name: "zoom",
display_name: "Zoom",
category: "productivity",
description: "Meetings",
requires: "",
source: "harness",
entry_point: "cli-anything-zoom",
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: "https://example.invalid/zoom.svg",
brand_color: "#0B5CFF",
skill_installed: true,
},
{
name: "krita",
display_name: "Krita",
category: "image",
description: "Painting",
requires: "",
source: "harness",
entry_point: "cli-anything-krita",
install_supported: true,
installed: false,
available: false,
status: "not_installed",
logo_url: null,
brand_color: "#3BABFF",
skill_installed: false,
},
];
describe("MessageBubble", () => {
it("renders user messages as right-aligned pills", () => {
@ -22,6 +57,53 @@ describe("MessageBubble", () => {
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
});
it("renders installed CLI app mentions inside sent user messages", () => {
const message: UIMessage = {
id: "u-cli",
role: "user",
content: "Hi nano, please use @zoom to book a meeting, not @krita",
createdAt: Date.now(),
};
render(<MessageBubble message={message} cliApps={CLI_APPS} />);
const token = screen.getByTestId("message-cli-mention-zoom");
expect(token).toHaveTextContent("@zoom");
expect(token.className).not.toContain("rounded");
expect(token.className).not.toContain("px-");
expect(token.getAttribute("style")).toContain("color: #0B5CFF");
expect(token.getAttribute("style")).toContain("text-shadow");
expect(screen.getByTestId("message-cli-mention-logo-zoom")).toBeInTheDocument();
expect(screen.queryByTestId("message-cli-mention-krita")).not.toBeInTheDocument();
expect(screen.getByText(/not @krita/)).toBeInTheDocument();
});
it("renders structured CLI app attachments even without the installed catalog", () => {
const message: UIMessage = {
id: "u-cli-attached",
role: "user",
content: "Please use @drawio for the diagram",
createdAt: Date.now(),
cliApps: [{
name: "drawio",
display_name: "Draw.io",
category: "diagram",
entry_point: "cli-anything-drawio",
logo_url: "https://example.invalid/drawio.svg",
brand_color: "#F08705",
}],
};
render(<MessageBubble message={message} cliApps={[]} />);
const token = screen.getByTestId("message-cli-mention-drawio");
expect(token).toHaveTextContent("@drawio");
expect(token.className).not.toContain("rounded");
expect(token.className).not.toContain("px-");
expect(token.getAttribute("style")).toContain("color: #F08705");
expect(screen.getByTestId("message-cli-mention-logo-drawio")).toBeInTheDocument();
});
it("copies completed assistant replies from the action row", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {

View File

@ -332,6 +332,49 @@ describe("NanobotClient", () => {
);
});
it("includes CLI app attachments in outbound messages", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage(
"chat-cli",
"@drawio please make this diagram",
undefined,
{
cliApps: [{
name: "drawio",
display_name: "Draw.io",
category: "diagrams",
entry_point: "cli-anything-drawio",
logo_url: null,
brand_color: "#F08705",
}],
},
);
expect(lastSocket().sent).toContain(
JSON.stringify({
type: "message",
chat_id: "chat-cli",
content: "@drawio please make this diagram",
cli_apps: [{
name: "drawio",
display_name: "Draw.io",
category: "diagrams",
entry_point: "cli-anything-drawio",
logo_url: null,
brand_color: "#F08705",
}],
webui: true,
}),
);
});
it("re-attaches known chats after a reconnect", async () => {
const client = new NanobotClient({
url: "ws://test",

View File

@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import type { SlashCommand } from "@/lib/types";
import type { CliAppInfo, SlashCommand } from "@/lib/types";
const COMMANDS: SlashCommand[] = [
{
@ -19,6 +19,57 @@ const COMMANDS: SlashCommand[] = [
argHint: "[n]",
},
];
const CLI_APPS: CliAppInfo[] = [
{
name: "gimp",
display_name: "GIMP",
category: "image",
description: "Image editing",
requires: "",
source: "harness",
entry_point: "cli-anything-gimp",
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: "https://example.invalid/gimp.svg",
brand_color: "#5C5543",
skill_installed: true,
},
{
name: "blender",
display_name: "Blender",
category: "3d",
description: "3D creation",
requires: "",
source: "harness",
entry_point: "cli-anything-blender",
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: null,
brand_color: "#E87D0D",
skill_installed: true,
},
{
name: "krita",
display_name: "Krita",
category: "image",
description: "Painting",
requires: "",
source: "harness",
entry_point: "cli-anything-krita",
install_supported: true,
installed: false,
available: false,
status: "not_installed",
logo_url: null,
brand_color: "#3BABFF",
skill_installed: false,
},
];
const ORIGINAL_INNER_HEIGHT = window.innerHeight;
afterEach(() => {
@ -66,7 +117,7 @@ describe("ThreadComposer", () => {
const input = screen.getByPlaceholderText("Ask anything...");
expect(input).toBeInTheDocument();
expect(input.className).toContain("min-h-[78px]");
expect(input.parentElement?.className).toContain("max-w-[58rem]");
expect(input.parentElement?.parentElement?.className).toContain("max-w-[58rem]");
});
it("keeps the thread composer compact while matching the hero style", () => {
@ -81,9 +132,9 @@ describe("ThreadComposer", () => {
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
const input = screen.getByPlaceholderText("Type your message...");
expect(input.className).toContain("min-h-[50px]");
expect(input.parentElement?.className).toContain("max-w-[49.5rem]");
expect(input.parentElement?.className).toContain("rounded-[22px]");
expect(input.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]");
expect(input.parentElement?.parentElement?.className).toContain("max-w-[49.5rem]");
expect(input.parentElement?.parentElement?.className).toContain("rounded-[22px]");
expect(input.parentElement?.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]");
expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card");
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
});
@ -163,6 +214,123 @@ describe("ThreadComposer", () => {
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
});
it("opens the CLI app mention palette and inserts the selected app", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
cliApps={CLI_APPS}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
const palette = screen.getByRole("listbox", { name: "CLI Apps" });
expect(palette).toBeInTheDocument();
expect(screen.getByRole("option", { name: /@gimp/i })).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.queryByRole("option", { name: /@krita/i })).not.toBeInTheDocument();
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(screen.getByRole("option", { name: /@blender/i })).toHaveAttribute(
"aria-selected",
"true",
);
fireEvent.keyDown(input, { key: "Enter" });
expect(input).toHaveValue("@blender ");
expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender");
expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument();
expect(onSend).not.toHaveBeenCalled();
expect(screen.queryByRole("listbox", { name: "CLI Apps" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith("@blender", undefined, {
cliApps: [{
name: "blender",
display_name: "Blender",
category: "3d",
entry_point: "cli-anything-blender",
logo_url: null,
brand_color: "#E87D0D",
}],
});
});
it("completes a CLI app mention with Tab and adds exactly one trailing space", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
cliApps={CLI_APPS}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, {
target: { value: "use @ble", selectionStart: 8 },
});
fireEvent.keyDown(input, { key: "Tab" });
expect(input).toHaveValue("use @blender ");
expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender");
});
it("does not duplicate the next word separator when completing a CLI app mention", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
cliApps={CLI_APPS}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, {
target: { value: "use @ble tonight", selectionStart: 8 },
});
fireEvent.keyDown(input, { key: "Tab" });
expect(input).toHaveValue("use @blender tonight");
});
it("renders a CLI app mention logo inline without moving the text cursor slot", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
cliApps={CLI_APPS}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, {
target: { value: "meeting in @gimp", selectionStart: 16 },
});
expect(input).toHaveValue("meeting in @gimp");
const token = screen.getByTestId("composer-cli-mention-gimp");
expect(token).toHaveTextContent("@gimp");
expect(token.className).not.toContain("font-semibold");
expect(token.className).not.toContain("zoom-in");
expect(token.className).not.toContain("px-");
expect(token.className).not.toContain("mx-");
expect(token.getAttribute("style")).toContain("color: #5C5543");
expect(token.getAttribute("style")).toContain("text-shadow");
expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument();
const logo = screen.getByTestId("composer-cli-mention-logo-gimp");
expect(logo.className).toContain("top-1/2");
expect(logo.className).toContain("left-1/2");
expect(logo.className).not.toContain("-top-");
});
it("opens the slash command palette downward when there is more room below", async () => {
vi.spyOn(HTMLFormElement.prototype, "getBoundingClientRect").mockReturnValue(
rect({ top: 40, bottom: 160, width: 800, height: 120 }),

View File

@ -356,6 +356,58 @@ describe("useNanobotStream", () => {
'exec({"cmd":"ls"})',
'read_file({"path":"notes.md"})',
]);
expect(result.current.messages[0].toolEvents).toMatchObject([
{ phase: "end", call_id: "call-exec", name: "exec" },
{ phase: "error", call_id: "call-read", name: "read_file", error: "missing" },
]);
});
it("keeps phase updates when a tool event trace line is deduped", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-tool-phase", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
const args = { name: "github", args: ["repo", "view"], json: "true" };
act(() => {
fake.emit("chat-tool-phase", {
event: "message",
chat_id: "chat-tool-phase",
text: "",
kind: "tool_hint",
tool_events: [{
phase: "start",
call_id: "call-cli",
name: "run_cli_app",
arguments: args,
}],
});
fake.emit("chat-tool-phase", {
event: "message",
chat_id: "chat-tool-phase",
text: "",
kind: "progress",
tool_events: [{
phase: "error",
call_id: "call-cli",
name: "run_cli_app",
arguments: args,
error: "Error: CLI app 'github' not found",
}],
});
});
expect(result.current.messages[0].traces).toEqual([
'run_cli_app({"name":"github","args":["repo","view"],"json":"true"})',
]);
expect(result.current.messages[0].toolEvents).toMatchObject([
{
phase: "error",
call_id: "call-cli",
name: "run_cli_app",
error: "Error: CLI app 'github' not found",
},
]);
});
it("renders live file_edit events as their own activity trace", () => {