diff --git a/src/holoviz_mcp/apps/hv_list.py b/src/holoviz_mcp/apps/hv_list.py new file mode 100644 index 0000000..e0b1c85 --- /dev/null +++ b/src/holoviz_mcp/apps/hv_list.py @@ -0,0 +1,98 @@ +"""An app to demo the usage and responses of the hv_list tool.""" + +import panel as pn +import panel_material_ui as pmui + +from holoviz_mcp.client import call_tool + +ABOUT = """ +# HoloViews List Elements Tool + +The `hv_list` tool lists all available HoloViews visualization elements. + +## Purpose + +Discover what visualization elements you can create with HoloViews. Elements are the +building blocks for composing complex visualizations. + +## Use Cases + +- Explore available visualization options before creating plots +- Understand what element types are supported in your environment +- Find the right element name to use with HoloViews + +## Returns + +A sorted list of all HoloViews element names available in the current environment. + +**Examples:** `['Annotation', 'Area', 'Arrow', 'Bars', 'Curve', 'Scatter', ...]` + +## Next Steps + +After discovering elements with this tool, use: + +- [`hv_get`](./hv_get) - Get detailed documentation for a specific element +""" + + +@pn.cache +async def hv_list_elements() -> list[str]: + """Fetch the list of HoloViews elements via the hv_list tool.""" + response = await call_tool( + tool_name="hv_list", + parameters={}, + ) + return response.data + + +async def create_content(): + """Create the styled content displaying HoloViews elements as chips.""" + items = await hv_list_elements() + count = pmui.Typography( + f"Found {len(items)} elements", + variant="subtitle1", + sx={"color": "text.secondary", "mb": 2}, + ) + chips = pmui.FlexBox( + *[pmui.Chip(item, variant="outlined", color="primary", size="small") for item in items], + sizing_mode="stretch_width", + ) + return pmui.Column(count, chips, sizing_mode="stretch_width") + + +def create_app(): + """Create the Panel Material UI app for demoing the hv_list tool.""" + about_button = pmui.IconButton( + label="About", + icon="info", + description="Click to learn about the HoloViews List Elements Tool.", + sizing_mode="fixed", + color="light", + margin=(10, 0), + ) + about = pmui.Dialog(ABOUT, close_on_click=True, width=0) + about_button.js_on_click(args={"about": about}, code="about.data.open = true") + + github_button = pmui.IconButton( + label="Github", + icon="star", + description="Give HoloViz-MCP a star on GitHub", + sizing_mode="fixed", + color="light", + margin=(10, 0), + href="https://github.com/MarcSkovMadsen/holoviz-mcp", + target="_blank", + ) + + content = pn.panel(create_content, loading_indicator=True) + main = pmui.Container(about, content) + + return pmui.Page( + title="HoloViz-MCP: hv_list Tool Demo", + header=[pmui.Row(pn.HSpacer(), about_button, github_button, sizing_mode="stretch_width")], + main=[main], + ) + + +if pn.state.served: + create_app().servable() diff --git a/src/holoviz_mcp/apps/hvplot_list.py b/src/holoviz_mcp/apps/hvplot_list.py index e378e8b..7038011 100644 --- a/src/holoviz_mcp/apps/hvplot_list.py +++ b/src/holoviz_mcp/apps/hvplot_list.py @@ -44,6 +44,21 @@ async def hvplot_list_plot_types() -> list[str]: return response.data +async def create_content(): + """Create the styled content displaying hvPlot plot types as chips.""" + items = await hvplot_list_plot_types() + count = pmui.Typography( + f"Found {len(items)} plot types", + variant="subtitle1", + sx={"color": "text.secondary", "mb": 2}, + ) + chips = pmui.FlexBox( + *[pmui.Chip(item, variant="outlined", color="primary", size="small") for item in items], + sizing_mode="stretch_width", + ) + return pmui.Column(count, chips, sizing_mode="stretch_width") + + def create_app(): """Create the Panel Material UI app for demoing the hvplot_list tool.""" about_button = pmui.IconButton( @@ -57,7 +72,6 @@ def create_app(): about = pmui.Dialog(ABOUT, close_on_click=True, width=0) about_button.js_on_click(args={"about": about}, code="about.data.open = true") - # GitHub button github_button = pmui.IconButton( label="Github", icon="star", @@ -69,7 +83,8 @@ def create_app(): target="_blank", ) - main = pmui.Container(about, pn.pane.JSON(hvplot_list_plot_types, theme="dark", depth=3, sizing_mode="stretch_width")) + content = pn.panel(create_content, loading_indicator=True) + main = pmui.Container(about, content) return pmui.Page( title="HoloViz-MCP: hvplot_list Tool Demo", diff --git a/src/holoviz_mcp/apps/pn_packages.py b/src/holoviz_mcp/apps/pn_packages.py index 2083b02..58ad4f5 100644 --- a/src/holoviz_mcp/apps/pn_packages.py +++ b/src/holoviz_mcp/apps/pn_packages.py @@ -33,6 +33,21 @@ async def panel_list_packages() -> list[str]: return response.data +async def create_content(): + """Create the styled content displaying Panel packages as chips.""" + items = await panel_list_packages() + count = pmui.Typography( + f"Found {len(items)} package{'s' if len(items) != 1 else ''}", + variant="subtitle1", + sx={"color": "text.secondary", "mb": 2}, + ) + chips = pmui.FlexBox( + *[pmui.Chip(item, variant="outlined", color="primary", size="small") for item in items], + sizing_mode="stretch_width", + ) + return pmui.Column(count, chips, sizing_mode="stretch_width") + + def create_app(): """Create the Panel Material UI app for demoing the pn_packages tool.""" about_button = pmui.IconButton( @@ -46,7 +61,6 @@ def create_app(): about = pmui.Dialog(ABOUT, close_on_click=True, width=0) about_button.js_on_click(args={"about": about}, code="about.data.open = true") - # GitHub button github_button = pmui.IconButton( label="Github", icon="star", @@ -58,7 +72,8 @@ def create_app(): target="_blank", ) - main = pmui.Container(about, pn.pane.JSON(panel_list_packages, theme="dark", depth=3, sizing_mode="stretch_width")) + content = pn.panel(create_content, loading_indicator=True) + main = pmui.Container(about, content) return pmui.Page( title="HoloViz-MCP: pn_packages Tool Demo", diff --git a/src/holoviz_mcp/apps/search.py b/src/holoviz_mcp/apps/search.py index 3edea23..7ccc670 100644 --- a/src/holoviz_mcp/apps/search.py +++ b/src/holoviz_mcp/apps/search.py @@ -105,7 +105,7 @@ class SearchConfiguration(param.Parameterized): doc="Filter results to a specific project. Select 'all' for all projects.", ) - max_results = param.Integer(default=2, bounds=(1, 50), doc="Maximum number of search results to return") + max_results = param.Integer(default=5, bounds=(1, 50), doc="Maximum number of search results to return") content = param.Selector( default="truncated", diff --git a/src/holoviz_mcp/apps/skill_get.py b/src/holoviz_mcp/apps/skill_get.py index d8a3645..a4ec536 100644 --- a/src/holoviz_mcp/apps/skill_get.py +++ b/src/holoviz_mcp/apps/skill_get.py @@ -7,8 +7,8 @@ import panel_material_ui as pmui import param -from holoviz_mcp.holoviz_mcp.data import get_skill -from holoviz_mcp.holoviz_mcp.data import list_skills +from holoviz_mcp.core.skills import get_skill +from holoviz_mcp.core.skills import list_skills pn.extension() @@ -71,12 +71,13 @@ def __init__(self, **params): pn.state.location.sync(self, parameters=["name"]) def _load_skills(self): - """Load available skills½.""" + """Load available skills.""" try: - skills = list_skills() - self.param.skill.objects = skills - if skills and self.skill is None: - self.skill = skills[0] # Default to first skill + skill_entries = list_skills() + skill_names = [entry["name"] for entry in skill_entries] + self.param.skill.objects = skill_names + if skill_names and self.skill is None: + self.skill = skill_names[0] except Exception as e: self.param.skill.objects = [] self.content = f"**Error loading skills:** {e}" diff --git a/src/holoviz_mcp/cli.py b/src/holoviz_mcp/cli.py index dc29097..a09d880 100644 --- a/src/holoviz_mcp/cli.py +++ b/src/holoviz_mcp/cli.py @@ -20,44 +20,54 @@ app = typer.Typer( name="holoviz-mcp", - help="HoloViz Model Context Protocol (MCP) server and utilities.", + help=( + "AI-powered documentation and tooling for the HoloViz ecosystem.\n\n" + "Connect AI assistants (Claude, Copilot, ...) to Panel, hvPlot, and HoloViews\n" + "docs, components, and best-practice guides — or use directly from the terminal.\n\n" + "Quick start:\n\n" + " holoviz-mcp Start the MCP server\n" + " holoviz-mcp update index Build the search index\n" + " holoviz-mcp search responsive layout Search the docs\n" + " holoviz-mcp pn get Button --package panel Look up a component" + ), no_args_is_help=False, # Allow running without args to start the server + rich_markup_mode="markdown", ) # ══════════════════════════════════════════════════════════════════════════════ # Infrastructure subcommand groups (existing) # ══════════════════════════════════════════════════════════════════════════════ -update_app = typer.Typer(name="update", help="Update HoloViz MCP resources.", no_args_is_help=True) -app.add_typer(update_app) +update_app = typer.Typer(name="update", help="Update the documentation search index.", no_args_is_help=True) +app.add_typer(update_app, rich_help_panel="Getting Started") -install_app = typer.Typer(name="install", help="Install HoloViz MCP resources.", no_args_is_help=True) -app.add_typer(install_app) +install_app = typer.Typer(name="install", help="Set up HoloViz MCP for Claude Code, Copilot, or Playwright.", no_args_is_help=True) +app.add_typer(install_app, rich_help_panel="Getting Started") # ══════════════════════════════════════════════════════════════════════════════ # Tool subcommand groups (new) # ══════════════════════════════════════════════════════════════════════════════ -pn_app = typer.Typer(name="pn", help="Panel component tools (import panel as pn).", no_args_is_help=True) -app.add_typer(pn_app) +pn_app = typer.Typer(name="pn", help="Explore Panel widgets, panes, and layouts.", no_args_is_help=True) +app.add_typer(pn_app, rich_help_panel="Library Introspection") -hv_app = typer.Typer(name="hv", help="HoloViews element tools (import holoviews as hv).", no_args_is_help=True) -app.add_typer(hv_app) +hv_app = typer.Typer(name="hv", help="Explore HoloViews visualization elements.", no_args_is_help=True) +app.add_typer(hv_app, rich_help_panel="Library Introspection") -hvplot_app = typer.Typer(name="hvplot", help="hvPlot plot type tools (import hvplot).", no_args_is_help=True) -app.add_typer(hvplot_app) +hvplot_app = typer.Typer(name="hvplot", help="Explore hvPlot chart types and signatures.", no_args_is_help=True) +app.add_typer(hvplot_app, rich_help_panel="Library Introspection") -skill_app = typer.Typer(name="skill", help="Best-practice skill documents.", no_args_is_help=True) -app.add_typer(skill_app) +skill_app = typer.Typer(name="skill", help="Browse best-practice guides for Panel, hvPlot, and more.", no_args_is_help=True) +app.add_typer(skill_app, rich_help_panel="Search & Browse") -doc_app = typer.Typer(name="doc", help="Documentation documents.", no_args_is_help=True) -app.add_typer(doc_app) +doc_app = typer.Typer(name="doc", help="Fetch a specific documentation page by project and path.", no_args_is_help=True) +app.add_typer(doc_app, rich_help_panel="Search & Browse") -project_app = typer.Typer(name="project", help="Indexed documentation projects.", no_args_is_help=True) -app.add_typer(project_app) +project_app = typer.Typer(name="project", help="List documentation projects available for search.", no_args_is_help=True) +app.add_typer(project_app, rich_help_panel="Search & Browse") -ref_app = typer.Typer(name="ref", help="Reference guides for components.", no_args_is_help=True) -app.add_typer(ref_app) +ref_app = typer.Typer(name="ref", help="Look up reference guides for a named component.", no_args_is_help=True) +app.add_typer(ref_app, rich_help_panel="Search & Browse") # ══════════════════════════════════════════════════════════════════════════════ @@ -151,7 +161,7 @@ def main( # ══════════════════════════════════════════════════════════════════════════════ -@app.command() +@app.command(rich_help_panel="Search & Browse") def search( query: Annotated[list[str], typer.Argument(help="Search query (space-separated words, no quotes needed).")], project: Annotated[Optional[str], typer.Option("--project", "-p", help="Filter by project name.")] = None, @@ -163,7 +173,7 @@ def search( max_chars: Annotated[int, typer.Option("--max-chars", help="Max content chars per result.")] = 10000, output: OutputFlag = OutputFormat.pretty, ) -> None: - """Search indexed documentation using semantic similarity.""" + """Search documentation by meaning, not just keywords.""" from holoviz_mcp.core.docs import search as _search query_str = " ".join(query) @@ -190,7 +200,7 @@ def search( _echo_output("\n".join(lines), output) -@app.command() +@app.command(rich_help_panel="Dev Tools") def inspect( url: Annotated[str, typer.Argument(help="URL to inspect.")] = "http://localhost:5006/", width: Annotated[int, typer.Option("--width", help="Viewport width.")] = 1920, @@ -203,7 +213,7 @@ def inspect( log_level: Annotated[Optional[str], typer.Option("--log-level", help="Filter console logs by level.")] = None, output: OutputFlag = OutputFormat.pretty, ) -> None: - """Inspect a web app by capturing screenshot and/or console logs.""" + """Capture a screenshot and console logs from a running web app.""" from holoviz_mcp.core.inspect import inspect_app # Resolve save_screenshot parameter @@ -274,7 +284,7 @@ def pn_list_cmd( package: Annotated[Optional[str], typer.Option("--package", "-P", help="Filter by package.")] = None, output: OutputFlag = OutputFormat.pretty, ) -> None: - """List Panel components (summary without parameter details).""" + """List all Panel components with name and description.""" from holoviz_mcp.core.pn import list_components components = list_components(name=name, module_path=module, package=package) @@ -299,7 +309,7 @@ def pn_get_cmd( module: Annotated[Optional[str], typer.Option("--module", "-m", help="Module path.")] = None, output: OutputFlag = OutputFormat.pretty, ) -> None: - """Get full details for a single Panel component.""" + """Show full docstring and parameters for a Panel component.""" from holoviz_mcp.core.pn import get_component try: @@ -327,7 +337,7 @@ def pn_params_cmd( module: Annotated[Optional[str], typer.Option("--module", "-m", help="Module path.")] = None, output: OutputFlag = OutputFormat.pretty, ) -> None: - """Get parameter details for a single Panel component.""" + """Show parameter details for a Panel component.""" from holoviz_mcp.core.pn import get_component_parameters try: @@ -357,7 +367,7 @@ def pn_search_cmd( limit: Annotated[int, typer.Option("--limit", "-l", help="Maximum results.")] = 10, output: OutputFlag = OutputFormat.pretty, ) -> None: - """Search Panel components by keyword.""" + """Find Panel components by name or description.""" from holoviz_mcp.core.pn import search_components query_str = " ".join(query) @@ -397,7 +407,7 @@ def pn_packages_cmd(output: OutputFlag = OutputFormat.markdown) -> None: @hv_app.command("list") def hv_list_cmd(output: OutputFlag = OutputFormat.markdown) -> None: - """List all available HoloViews visualization elements.""" + """List all HoloViews element types (Area, Bars, Curve, ...).""" from holoviz_mcp.core.hv import list_elements elements = list_elements() @@ -415,7 +425,7 @@ def hv_get_cmd( backend: Annotated[str, typer.Option("--backend", "-b", help="Plotting backend.")] = "bokeh", output: OutputFlag = OutputFormat.pretty, ) -> None: - """Get element docstring, parameters, style and plot options.""" + """Show docstring, parameters, and style options for an element.""" from holoviz_mcp.core.hv import get_element try: @@ -438,7 +448,7 @@ def hv_get_cmd( @hvplot_app.command("list") def hvplot_list_cmd(output: OutputFlag = OutputFormat.markdown) -> None: - """List all available hvPlot plot types.""" + """List all hvPlot chart types (bar, scatter, line, ...).""" from holoviz_mcp.core.hvplot import list_plot_types plot_types = list_plot_types() @@ -458,7 +468,7 @@ def hvplot_get_cmd( style: Annotated[Optional[str], typer.Option("--style", help="Backend for style options (matplotlib, bokeh, plotly).")] = None, output: OutputFlag = OutputFormat.pretty, ) -> None: - """Get docstring or signature for an hvPlot plot type.""" + """Show docstring or function signature for a chart type.""" from holoviz_mcp.core.hvplot import get_plot_type style_val: str | bool = style if style is not None else False @@ -483,7 +493,7 @@ def hvplot_get_cmd( @skill_app.command("list") def skill_list_cmd(output: OutputFlag = OutputFormat.markdown) -> None: - """List all available skills with descriptions.""" + """List all available best-practice guides.""" from holoviz_mcp.core.skills import list_skills skills = list_skills() @@ -500,7 +510,7 @@ def skill_get_cmd( name: Annotated[str, typer.Argument(help="Skill name (e.g., 'panel', 'hvplot').")], output: OutputFlag = OutputFormat.pretty, ) -> None: - """Get skill content (always Markdown).""" + """Show the content of a best-practice guide.""" from holoviz_mcp.core.skills import get_skill try: @@ -516,7 +526,7 @@ def skill_files_cmd( name: Annotated[str, typer.Argument(help="Skill name (e.g., 'panel-custom-components').")], output: OutputFlag = OutputFormat.pretty, ) -> None: - """List supporting files in a skill directory (excludes SKILL.md).""" + """List supporting files bundled with a guide.""" from holoviz_mcp.core.skills import list_skill_files try: @@ -546,7 +556,7 @@ def skill_file_get_cmd( path: Annotated[str, typer.Argument(help="Relative file path within the skill directory.")], output: OutputFlag = OutputFormat.pretty, ) -> None: - """Read a supporting file from a skill directory.""" + """Read a supporting file from a guide.""" from holoviz_mcp.core.skills import get_skill_file try: @@ -571,7 +581,7 @@ def doc_list_cmd( project: Annotated[str, typer.Argument(help="Project name (e.g., 'panel', 'hvplot').")], output: OutputFlag = OutputFormat.pretty, ) -> None: - """List all documents available for a project.""" + """List all pages available in a documentation project.""" from holoviz_mcp.core.docs import list_documents try: @@ -603,7 +613,7 @@ def doc_get_cmd( path: Annotated[str, typer.Argument(help="Document path (e.g., 'index.md').")], output: OutputFlag = OutputFormat.pretty, ) -> None: - """Retrieve a specific document by path and project.""" + """Show the content of a specific documentation page.""" from holoviz_mcp.core.docs import get_document try: @@ -633,7 +643,7 @@ def doc_get_cmd( @project_app.command("list") def project_list_cmd(output: OutputFlag = OutputFormat.markdown) -> None: - """List all projects with indexed documentation.""" + """List all projects with searchable documentation.""" from holoviz_mcp.core.docs import list_projects projects = asyncio.run(list_projects()) @@ -657,7 +667,7 @@ def ref_get_cmd( no_content: Annotated[bool, typer.Option("--no-content", help="Metadata only.")] = False, output: OutputFlag = OutputFormat.pretty, ) -> None: - """Find reference guides for a specific component.""" + """Look up the reference guide for a component (e.g., Button, scatter).""" from holoviz_mcp.core.docs import get_reference_guide results = asyncio.run(get_reference_guide(component, project, content=not no_content)) @@ -694,11 +704,10 @@ def update_index( typer.Option("--full", "-f", help="Force full rebuild, ignoring cached hashes."), ] = False, ) -> None: - """Update the documentation index. + """Build or update the documentation search index (required before first use). - This command clones/updates HoloViz repositories and builds the vector database - for documentation search. First run may take 2-6 minutes. Subsequent runs - are incremental and only re-index changed files. + Downloads HoloViz docs and builds a semantic search database. + First run: 2-6 min. Subsequent runs are incremental. """ from holoviz_mcp.holoviz_mcp.data import DocumentationIndexer @@ -712,7 +721,7 @@ def install_copilot( skills: bool = False, scope: Annotated[str, typer.Option("--scope", help="Installation scope: 'project' for .github/, 'user' for ~/.copilot/")] = "project", ) -> None: - """Install HoloViz MCP resources for GitHub Copilot. + """Set up HoloViz MCP agents and skills for GitHub Copilot. \f @@ -789,7 +798,7 @@ def install_claude( skills: bool = False, scope: Annotated[str, typer.Option("--scope", help="Installation scope: 'project' for .claude/agents/, 'user' for ~/.claude/agents/")] = "project", ) -> None: - """Install HoloViz MCP resources for Claude Code. + """Set up HoloViz MCP agents and skills for Claude Code. \f @@ -862,21 +871,18 @@ def install_claude( @install_app.command(name="chromium") def install_chromium() -> None: - """Install Chromium browser for Playwright. - - This command installs the Chromium browser required for taking screenshots. - """ + """Install Chromium for the inspect command (Playwright).""" subprocess.run([str(sys.executable), "-m", "playwright", "install", "chromium"], check=True) -@app.command() +@app.command(rich_help_panel="Dev Tools") def serve( port: Annotated[int, typer.Option(help="Port number to serve on.")] = 5006, address: Annotated[str, typer.Option(help="Address to bind to.")] = "0.0.0.0", allow_websocket_origin: Annotated[str, typer.Option(help="Allowed websocket origins.")] = "*", num_procs: Annotated[int, typer.Option(help="Number of worker processes.")] = 1, ) -> None: - """Serve Panel apps from the apps directory. + """Launch the built-in developer UI apps (component browser, doc search). \f diff --git a/src/holoviz_mcp/client.py b/src/holoviz_mcp/client.py index 19a918a..b4352d3 100644 --- a/src/holoviz_mcp/client.py +++ b/src/holoviz_mcp/client.py @@ -91,4 +91,37 @@ async def call_tool(tool_name: str, parameters: dict[str, Any]) -> CallToolResul _CLIENT = await _create_client() async with _CLIENT: - return await _CLIENT.call_tool(tool_name, parameters) + result = await _CLIENT.call_tool(tool_name, parameters) + _normalize_root_objects(result) + return result + + +def _normalize_root_objects(result: CallToolResult) -> None: + """Convert FastMCP Root objects in result.data to plain dicts. + + FastMCP deserializes structured tool results into dynamically-generated + ``Root`` objects that are not JSON-serializable. This function walks + ``result.data`` and replaces any such objects with their ``__dict__`` + representation so downstream consumers (Panel JSON panes, DataFrames, + etc.) can serialize them without errors. + """ + + def _normalize(obj: Any) -> Any: + """Recursively replace FastMCP Root instances with plain dicts.""" + # FastMCP dynamically generates a class named "Root" for structured results. + if type(obj).__name__ == "Root": + return vars(obj) + if isinstance(obj, list): + return [_normalize(item) for item in obj] + if isinstance(obj, dict): + return {key: _normalize(value) for key, value in obj.items()} + return obj + + # Normalize the primary data payload. + result.data = _normalize(result.data) + + # Normalize structured_content if present on the result, without assuming + # that every CallToolResult implementation defines it. + structured_content = getattr(result, "structured_content", None) + if structured_content is not None: + result.structured_content = _normalize(structured_content) diff --git a/src/holoviz_mcp/display_mcp/pages/add_page.py b/src/holoviz_mcp/display_mcp/pages/add_page.py index cb85f40..2b9d3bd 100644 --- a/src/holoviz_mcp/display_mcp/pages/add_page.py +++ b/src/holoviz_mcp/display_mcp/pages/add_page.py @@ -7,13 +7,35 @@ import logging import panel as pn +import panel_material_ui as pmui from holoviz_mcp.display_mcp.database import get_db -from holoviz_mcp.display_mcp.ui import banner from holoviz_mcp.display_mcp.utils import get_relative_view_url logger = logging.getLogger(__name__) +ABOUT = """ +## Add Visualization + +This page allows you to create new visualizations by writing Python code. + +### How to Use + +1. **Write Code**: Enter your Python visualization code in the editor +2. **Configure**: Set a name, description, and execution method in the sidebar +3. **Submit**: Click the Submit button to create the visualization + +### Execution Methods + +- **jupyter**: The last expression in the code is displayed (like a Jupyter cell) +- **panel**: Objects marked with `.servable()` are displayed as a Panel app + +### Learn More + +For more information about this project, visit: +[HoloViz MCP](https://marcskovmadsen.github.io/holoviz-mcp/). +""" + DEFAULT_SNIPPET = """\ import pandas as pd import hvplot.pandas @@ -40,27 +62,26 @@ def add_page(): sizing_mode="stretch_both", ) - name_input = pn.widgets.TextInput( - name="Name", + name_input = pmui.TextInput( + label="Name", placeholder="Enter name", sizing_mode="stretch_width", description="The name of the visualization.", ) - description_input = pn.widgets.TextAreaInput( - name="Description", + description_input = pmui.TextAreaInput( + label="Description", placeholder="Enter description", sizing_mode="stretch_width", max_length=500, description="A brief description of the visualization.", ) - method_select = pn.widgets.RadioBoxGroup( - name="Execution Method", + method_select = pmui.RadioButtonGroup( + label="Execution Method", options=["jupyter", "panel"], value="jupyter", sizing_mode="stretch_width", - inline=True, ) @pn.depends(name_input.param.value_input, description_input.param.value_input) @@ -68,12 +89,25 @@ def cannot_submit(name, description): """Determine if the form can be submitted.""" return not (name and description) - submit_button = pn.widgets.Button( - name="Submit", button_type="primary", sizing_mode="stretch_width", description="Click to create the visualization.", disabled=cannot_submit + submit_button = pmui.Button( + label="Submit", + color="primary", + variant="contained", + sizing_mode="stretch_width", + description="Click to create the visualization.", + disabled=cannot_submit, ) - # Status indicator in sidebar - status_pane = pn.pane.Alert("", alert_type="info", sizing_mode="stretch_width", visible=False) + # Status indicators in sidebar + status_alert = pmui.Alert("", alert_type="info", sizing_mode="stretch_width", visible=False, margin=(5, 0)) + view_link = pmui.Button( + label="Open Visualization", + icon="open_in_new", + color="success", + variant="outlined", + sizing_mode="stretch_width", + visible=False, + ) def on_submit(event): """Handle submit button click.""" @@ -82,6 +116,8 @@ def on_submit(event): description = description_input.value method = method_select.value + view_link.visible = False + try: # Call shared business logic directly (no HTTP roundtrip) result = get_db().create_visualization( @@ -91,84 +127,74 @@ def on_submit(event): method=method, ) - # Show success message + # Show success message with clickable link viz_id = result.id url = get_relative_view_url(viz_id) - status_pane.object = f""" -### ✅ Success! Visualization created. - -**Name:** {name or 'Unnamed'} -**ID:** `{viz_id}` -**URL:** [{url}]({url}) + status_alert.object = f"Visualization '{name or 'Unnamed'}' created successfully." + status_alert.alert_type = "success" + status_alert.visible = True -Click the URL link to view your visualization. -""" - status_pane.alert_type = "success" - status_pane.visible = True + view_link.href = url + view_link.target = "_blank" + view_link.visible = True except ValueError as e: - # Handle validation errors (e.g., empty code) - status_pane.object = f""" -### ❌ ValueError - -``` -{str(e)} -``` - -Please provide valid code. -""" - status_pane.alert_type = "danger" - status_pane.visible = True + status_alert.object = f"ValueError: {e}" + status_alert.alert_type = "error" + status_alert.visible = True except SyntaxError as e: - # Handle syntax errors - status_pane.object = f""" -### ❌ SyntaxError - -``` -{str(e)} -``` - -Please check your code syntax and try again. -""" - status_pane.alert_type = "danger" - status_pane.visible = True + status_alert.object = f"SyntaxError: {e}" + status_alert.alert_type = "error" + status_alert.visible = True except Exception as e: - # Handle all other errors logger.exception("Error creating visualization") - status_pane.object = f""" -### ❌ Error - -An unexpected error occurred: - -``` -{str(e)} -``` - -Please check the server logs for more details. -""" - status_pane.alert_type = "danger" - status_pane.visible = True + status_alert.object = f"Unexpected error: {e}" + status_alert.alert_type = "error" + status_alert.visible = True submit_button.on_click(on_submit) - return pn.template.FastListTemplate( + # About button and dialog + about_button = pmui.IconButton( + label="About", + icon="info", + description="Click to learn about the Add Visualization page.", + sizing_mode="fixed", + color="light", + margin=(10, 0), + ) + about = pmui.Dialog(ABOUT, close_on_click=True, width=0) + about_button.js_on_click(args={"about": about}, code="about.data.open = true") + + # GitHub button + github_button = pmui.IconButton( + label="Github", + icon="star", + description="Give HoloViz-MCP a star on GitHub", + sizing_mode="fixed", + color="light", + margin=(10, 0), + href="https://github.com/MarcSkovMadsen/holoviz-mcp", + target="_blank", + ) + + return pmui.Page( title="Add Visualization", + site_url="./", sidebar=[ - pn.pane.Markdown("### Configuration"), + pmui.Typography("## Configuration", variant="h6"), name_input, description_input, pn.pane.Markdown("Display Method", margin=(-10, 10, -10, 10)), method_select, submit_button, - pn.pane.Markdown("### Status"), - status_pane, - ], - main=[ - "## Code", - code_editor, + pmui.Typography("## Status", variant="h6"), + status_alert, + view_link, ], - header=[banner()], + header=[pn.Row(pn.Spacer(), about_button, github_button, align="end")], + main=[about, pmui.Container("## Code", code_editor, width_option="xl")], ) diff --git a/src/holoviz_mcp/display_mcp/pages/admin_page.py b/src/holoviz_mcp/display_mcp/pages/admin_page.py index 21cafdb..9a44a2c 100644 --- a/src/holoviz_mcp/display_mcp/pages/admin_page.py +++ b/src/holoviz_mcp/display_mcp/pages/admin_page.py @@ -6,12 +6,31 @@ import pandas as pd import panel as pn +import panel_material_ui as pmui from bokeh.models.widgets.tables import HTMLTemplateFormatter from holoviz_mcp.display_mcp.database import get_db -from holoviz_mcp.display_mcp.ui import banner from holoviz_mcp.display_mcp.utils import get_relative_view_url +ABOUT = """ +## Snippet Manager + +This page provides an administrative interface for managing all visualizations +stored in the database. + +### Features + +- **View All Snippets**: See all visualizations with their name, description, method, status, and creation date +- **View Code**: Expand any row to see the full Python code for that visualization +- **Delete Snippets**: Remove visualizations you no longer need +- **Direct Links**: Click the link icon to view any visualization + +### Learn More + +For more information about this project, visit: +[HoloViz MCP](https://marcskovmadsen.github.io/holoviz-mcp/). +""" + def admin_page(): """Create the /admin page. @@ -35,42 +54,102 @@ def admin_page(): "Method": req.method, "Status": req.status, "Created": req.created_at.isoformat(), - "View URL": view_url, - "App": req.app, # Add code for row_content display + "View": view_url, + "App": req.app, + "Error": req.error_message or "", } ) df = pd.DataFrame(data) - # Create tabulator with formatters for the URL column - formatters = {"View URL": HTMLTemplateFormatter(template='')} + # Formatters: styled status + clickable view link + status_template = """ +<% if (value === 'error') { %> + ✗ error +<% } else if (value === 'success') { %> + ✓ success +<% } else { %> + • <%- value %> +<% } %> +""" + description_template = """ +
<%- value %>
+""" + formatters = { + "View": HTMLTemplateFormatter(template='Open'), + "Status": HTMLTemplateFormatter(template=status_template), + "Description": HTMLTemplateFormatter(template=description_template), + } + + def make_row_content(row): + """Create expandable row content with code and optional error.""" + items = [pn.pane.Markdown(f"```python\n{row['App']}\n```", sizing_mode="stretch_width")] + if row.get("Error"): + items.append( + pn.pane.Markdown( + f"**Error**\n```\n{row['Error']}\n```", + sizing_mode="stretch_width", + styles={"border-left": "3px solid #d9534f", "padding-left": "8px", "margin-top": "8px"}, + ) + ) + return pn.Column(*items, sizing_mode="stretch_width") # Define delete callback def on_delete(event): """Handle delete button clicks.""" if event.column == "Delete": - # Get the row index row_idx = event.row if row_idx is not None and 0 <= row_idx < len(tabulator.value): # type: ignore[has-type] - # Get the ID from the row snippet_id = tabulator.value.iloc[row_idx]["ID"] # type: ignore[has-type] - # Delete from database get_db().delete_snippet(snippet_id) - # Remove from tabulator tabulator.value = tabulator.value.drop(tabulator.value.index[row_idx]).reset_index(drop=True) # type: ignore[has-type] tabulator = pn.widgets.Tabulator( df, formatters=formatters, - buttons={"Delete": ""}, - row_content=lambda row: pn.pane.Markdown(f"```python\n{row['App']}\n```", sizing_mode="stretch_width"), + buttons={"Delete": "✕"}, + titles={"Delete": ""}, + row_content=make_row_content, sizing_mode="stretch_both", + show_index=False, + layout="fit_data_stretch", + widths={"Description": 250, "Method": 100, "Status": 100, "Created": 160}, page_size=20, - hidden_columns=["App"], # Hide code column from table view + hidden_columns=["ID", "App", "Error"], disabled=True, ) # Bind delete callback tabulator.on_click(on_delete) - return pn.template.FastListTemplate(title="Snippet Manager", main=[tabulator], header=[banner()]) + # About button and dialog + about_button = pmui.IconButton( + label="About", + icon="info", + description="Click to learn about the Snippet Manager page.", + sizing_mode="fixed", + color="light", + margin=(10, 0), + ) + about = pmui.Dialog(ABOUT, close_on_click=True, width=0) + about_button.js_on_click(args={"about": about}, code="about.data.open = true") + + # GitHub button + github_button = pmui.IconButton( + label="Github", + icon="star", + description="Give HoloViz-MCP a star on GitHub", + sizing_mode="fixed", + color="light", + margin=(10, 0), + href="https://github.com/MarcSkovMadsen/holoviz-mcp", + target="_blank", + ) + + return pmui.Page( + title="Snippet Manager", + site_url="./", + header=[pn.Row(pn.Spacer(), about_button, github_button, align="end")], + main=[about, pmui.Container(tabulator, width_option="xl")], + ) diff --git a/src/holoviz_mcp/display_mcp/pages/feed_page.py b/src/holoviz_mcp/display_mcp/pages/feed_page.py index b3f802a..2e6987d 100644 --- a/src/holoviz_mcp/display_mcp/pages/feed_page.py +++ b/src/holoviz_mcp/display_mcp/pages/feed_page.py @@ -5,11 +5,34 @@ """ import panel as pn +import panel_material_ui as pmui from holoviz_mcp.display_mcp.database import get_db -from holoviz_mcp.display_mcp.ui import banner from holoviz_mcp.display_mcp.utils import get_relative_view_url +ABOUT = """ +## Visualization Feed + +This page displays a live feed of recent visualizations created through the HoloViz MCP display tool. + +### Features + +- **Live Updates**: The feed automatically refreshes every second to show new visualizations +- **View / Code Tabs**: Each visualization shows both an interactive preview and the source code +- **Actions**: Open visualizations in full screen, copy code to clipboard, or delete entries +- **Limit Control**: Use the sidebar to control how many visualizations are displayed + +### How It Works + +When an AI assistant uses the `show` tool to display a visualization, it appears here in the feed. +Each entry includes the visualization name, creation time, description, and an iframe preview. + +### Learn More + +For more information about this project, including setup instructions and advanced configuration options, +visit: [HoloViz MCP](https://marcskovmadsen.github.io/holoviz-mcp/). +""" + def feed_page(): """Create the /feed page. @@ -17,7 +40,7 @@ def feed_page(): Displays a feed of recent visualizations with automatic updates. """ # Create sidebar with filters - limit = pn.widgets.IntSlider(name="Limit", value=3, start=1, end=100) + limit = pmui.IntInput(name="Limit", value=3, start=1, end=100, sizing_mode="stretch_width") # Create chat feed chat_feed = pn.Column(sizing_mode="stretch_both") @@ -53,11 +76,11 @@ def get_view(req): allow="fullscreen; clipboard-write; autoplay" > """ - # Create copy button with JavaScript callback - open_button = pn.widgets.Button( - name="🔗 Full Screen", - button_type="light", + # Create action buttons with Material UI icon buttons + open_button = pmui.IconButton( + icon="open_in_new", description="Open visualization in new tab", + color="primary", ) open_button.js_on_click( code=f""" @@ -65,11 +88,10 @@ def get_view(req): """ ) - copy_button = pn.widgets.Button( - name="📋 Copy Code", - button_type="light", - width=120, + copy_button = pmui.IconButton( + icon="content_copy", description="Copy code to clipboard", + color="primary", ) # JavaScript callback to copy code to clipboard @@ -81,31 +103,38 @@ def get_view(req): """, ) - delete_button = pn.widgets.Button( - name="🗑️ Delete", - button_type="danger", - width=120, + delete_button = pmui.IconButton( + icon="delete", description="Delete this visualization", + color="error", ) delete_button.on_click(lambda event: on_delete(req.id)) with pn.config.set(sizing_mode="stretch_width"): - message = pn.Column( - pn.pane.Markdown( - title, - margin=(10, 10, 0, 10), - ), - pn.Tabs( - pn.pane.Markdown(iframe, name="View"), - pn.widgets.CodeEditor( - value=req.app, - name="Code", - language="python", - theme="github_dark", + message = pmui.Paper( + pn.Column( + pn.pane.Markdown( + title, + margin=(10, 10, 0, 10), + ), + pn.Tabs( + pn.pane.Markdown(iframe, name="View"), + pn.widgets.CodeEditor( + value=req.app, + name="Code", + language="python", + theme="github_dark", + sizing_mode="stretch_width", + min_height=400, + ), + margin=(0, 10, 10, 10), ), - margin=(0, 10, 10, 10), + pn.Row(pn.HSpacer(), open_button, copy_button, delete_button, margin=(0, 10, 0, 10), align="end"), + sizing_mode="stretch_width", ), - pn.Row(pn.HSpacer(), open_button, copy_button, delete_button, margin=(0, 10, 0, 10), align="end"), + elevation=2, + margin=(0, 0, 15, 0), + sizing_mode="stretch_width", ) pn.state.cache["views"][req.id] = message @@ -129,11 +158,36 @@ def update_chat(*events): update_chat() pn.state.add_periodic_callback(update_chat, 1000) # Refresh every 1 seconds - return pn.template.FastListTemplate( + # About button and dialog + about_button = pmui.IconButton( + label="About", + icon="info", + description="Click to learn about the Visualization Feed.", + sizing_mode="fixed", + color="light", + margin=(10, 0), + ) + about = pmui.Dialog(ABOUT, close_on_click=True, width=0) + about_button.js_on_click(args={"about": about}, code="about.data.open = true") + + # GitHub button + github_button = pmui.IconButton( + label="Github", + icon="star", + description="Give HoloViz-MCP a star on GitHub", + sizing_mode="fixed", + color="light", + margin=(10, 0), + href="https://github.com/MarcSkovMadsen/holoviz-mcp", + target="_blank", + ) + + return pmui.Page( title="Visualization Feed", + site_url="./", sidebar=[limit], - main=[pn.Column(chat_feed, sizing_mode="stretch_both")], - header=[banner()], + header=[pn.Row(pn.Spacer(), about_button, github_button, align="end")], + main=[about, pmui.Container(pn.Column(chat_feed, sizing_mode="stretch_both"), width_option="xl", sizing_mode="stretch_both")], ) diff --git a/src/holoviz_mcp/thumbnails/hv_list.png b/src/holoviz_mcp/thumbnails/hv_list.png new file mode 100644 index 0000000..dbfac1a Binary files /dev/null and b/src/holoviz_mcp/thumbnails/hv_list.png differ diff --git a/tests/test_cli.py b/tests/test_cli.py index c1b8573..cb84ae5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -38,7 +38,7 @@ def test_cli_help(self): timeout=10, ) assert result.returncode == 0 - assert "HoloViz Model Context Protocol" in result.stdout + assert "AI-powered documentation and tooling" in result.stdout assert "serve" in result.stdout def test_cli_version(self): @@ -59,7 +59,7 @@ def test_cli_update_help(self): timeout=15, ) assert result.returncode == 0 - assert "Update the documentation index" in result.stdout + assert "documentation search index" in result.stdout def test_cli_install_copilot_help(self): result = subprocess.run( @@ -69,7 +69,7 @@ def test_cli_install_copilot_help(self): timeout=10, ) assert result.returncode == 0 - assert "Install HoloViz MCP resources" in result.stdout + assert "Set up HoloViz MCP" in result.stdout def test_cli_install_claude_help(self): result = subprocess.run( @@ -79,7 +79,7 @@ def test_cli_install_claude_help(self): timeout=10, ) assert result.returncode == 0 - assert "Install HoloViz MCP resources for Claude Code" in result.stdout + assert "Set up HoloViz MCP agents and skills for Claude Code" in result.stdout def test_cli_serve_help(self): result = subprocess.run( @@ -89,7 +89,7 @@ def test_cli_serve_help(self): timeout=10, ) assert result.returncode == 0 - assert "Serve Panel apps" in result.stdout + assert "Launch the built-in developer UI apps" in result.stdout def test_cli_default_starts_server(self): process = subprocess.Popen( @@ -142,7 +142,7 @@ def test_entry_point_exists(self): timeout=10, ) assert result.returncode == 0 - assert "HoloViz Model Context Protocol" in result.stdout + assert "AI-powered documentation and tooling" in result.stdout def test_entry_point_version(self): result = subprocess.run( @@ -162,7 +162,7 @@ def test_entry_point_update(self): timeout=10, ) assert result.returncode == 0 - assert "Update the documentation index" in result.stdout + assert "documentation search index" in result.stdout def test_entry_point_install_copilot(self): result = subprocess.run( @@ -172,7 +172,7 @@ def test_entry_point_install_copilot(self): timeout=10, ) assert result.returncode == 0 - assert "Install HoloViz MCP resources" in result.stdout + assert "Set up HoloViz MCP" in result.stdout def test_entry_point_install_claude(self): result = subprocess.run( @@ -182,7 +182,7 @@ def test_entry_point_install_claude(self): timeout=10, ) assert result.returncode == 0 - assert "Install HoloViz MCP resources for Claude Code" in result.stdout + assert "Set up HoloViz MCP agents and skills for Claude Code" in result.stdout def test_entry_point_serve(self): result = subprocess.run( @@ -192,7 +192,7 @@ def test_entry_point_serve(self): timeout=10, ) assert result.returncode == 0 - assert "Serve Panel apps" in result.stdout + assert "Launch the built-in developer UI apps" in result.stdout # ══════════════════════════════════════════════════════════════════════════════ @@ -204,7 +204,7 @@ class TestPnHelp: def test_pn_help(self): result = runner.invoke(app, ["pn", "--help"]) assert result.exit_code == 0 - assert "Panel component tools" in plain(result.output) + assert "Panel widgets, panes, and layouts" in plain(result.output) def test_pn_list_help(self): result = runner.invoke(app, ["pn", "list", "--help"]) @@ -239,7 +239,7 @@ class TestHvHelp: def test_hv_help(self): result = runner.invoke(app, ["hv", "--help"]) assert result.exit_code == 0 - assert "HoloViews element tools" in plain(result.output) + assert "HoloViews visualization elements" in plain(result.output) def test_hv_list_help(self): result = runner.invoke(app, ["hv", "list", "--help"]) @@ -256,7 +256,7 @@ class TestHvplotHelp: def test_hvplot_help(self): result = runner.invoke(app, ["hvplot", "--help"]) assert result.exit_code == 0 - assert "hvPlot plot type tools" in plain(result.output) + assert "hvPlot chart types and signatures" in plain(result.output) def test_hvplot_list_help(self): result = runner.invoke(app, ["hvplot", "list", "--help"]) @@ -291,7 +291,7 @@ class TestDocHelp: def test_doc_help(self): result = runner.invoke(app, ["doc", "--help"]) assert result.exit_code == 0 - assert "Documentation" in plain(result.output) + assert "documentation page" in plain(result.output) def test_doc_list_help(self): result = runner.invoke(app, ["doc", "list", "--help"])