diff --git a/README.md b/README.md index 6102c25..d311a97 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,12 @@ for the frontend extension. ## Features -- **Document Management**: Open documents in JupyterLab with various layout modes -- **Markdown Preview**: Open markdown files in rendered preview mode -- **Notebook Operations**: Clear outputs and show diffs for notebooks -- **Jupyter AI Integration**: Tools available for use with Jupyter AI +- **Command Discovery**: List all available JupyterLab commands with their metadata +- **Command Execution**: Execute any JupyterLab command programmatically from Python ## Requirements -- JupyterLab >= 4.0.0 -- jupyter-ai (for AI toolkit functionality) +- JupyterLab >= 4.5.0a3 ## Install @@ -30,47 +27,29 @@ pip install jupyterlab_commands_toolkit ## Usage -### With Jupyter AI - -The extension provides a toolkit for Jupyter AI with the following tools: - -1. **open_document_tool**: Open documents in JupyterLab with various layout modes -2. **open_markdown_preview_tool**: Open markdown files in rendered preview mode -3. **clear_notebook_outputs_tool**: Clear all outputs in the active notebook -4. **show_notebook_diff_tool**: Show git diff for the current notebook using nbdime +Use the toolkit to execute any JupyterLab command from Python: ```python -# Access the AI toolkit -from jupyterlab_commands_toolkit import ai_toolkit +import asyncio +from jupyterlab_commands_toolkit.tools import execute_command, list_all_commands -# The toolkit is automatically available to Jupyter AI when installed -``` +# Execute a command (requires running in an async context) +async def main(): + # List all available commands + commands = await list_all_commands() -### Direct Usage + # Toggle the file browser + result = await execute_command("filebrowser:toggle-main") -You can also use the commands directly: - -```python -from jupyterlab_commands_toolkit.tools import ( - open_document, - open_markdown_file_in_preview_mode, - clear_all_outputs_in_notebook, - show_diff_of_current_notebook -) + # Run notebook cells + result = await execute_command("notebook:run-all-cells") -# Open a document -open_document("notebook.ipynb", mode="split-right") - -# Open markdown in preview -open_markdown_file_in_preview_mode("README.md") - -# Clear notebook outputs -clear_all_outputs_in_notebook(True) - -# Show notebook diff -show_diff_of_current_notebook(True) +# Run in JupyterLab environment +asyncio.run(main()) ``` +For a full list of available commands in JupyterLab, refer to the [JupyterLab Command Registry documentation](https://jupyterlab.readthedocs.io/en/latest/user/commands.html#commands-list). + ## Uninstall To remove the extension, execute: diff --git a/jupyterlab_commands_toolkit/__init__.py b/jupyterlab_commands_toolkit/__init__.py index e6032d5..56dde4d 100644 --- a/jupyterlab_commands_toolkit/__init__.py +++ b/jupyterlab_commands_toolkit/__init__.py @@ -5,34 +5,73 @@ # in editable mode with pip. It is highly recommended to install # the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs import warnings - warnings.warn("Importing 'jupyterlab_commands_toolkit' outside a proper installation.") + + warnings.warn( + "Importing 'jupyterlab_commands_toolkit' outside a proper installation." + ) __version__ = "dev" import pathlib from jupyter_server.serverapp import ServerApp -from .toolkit import toolkit -# Export the AI toolkit for jupyter-ai integration -try: - from .toolkit import toolkit as ai_toolkit -except ImportError: - # If jupyter-ai is not available, the AI toolkit won't be available - toolkit = None def _jupyter_labextension_paths(): - return [{ - "src": "labextension", - "dest": "jupyterlab-commands-toolkit" - }] + return [{"src": "labextension", "dest": "jupyterlab-commands-toolkit"}] def _jupyter_server_extension_points(): - return [{ - "module": "jupyterlab_commands_toolkit" - }] + return [{"module": "jupyterlab_commands_toolkit"}] + def _load_jupyter_server_extension(serverapp: ServerApp): - schema_path = pathlib.Path(__file__).parent / "events" / "jupyterlab-command.yml" - serverapp.event_logger.register_event_schema(schema_path) - serverapp.log.info("jupyterlab_commands_toolkit extension loaded.") \ No newline at end of file + command_schema_path = ( + pathlib.Path(__file__).parent / "events" / "jupyterlab-command.yml" + ) + serverapp.event_logger.register_event_schema(command_schema_path) + + result_schema_path = ( + pathlib.Path(__file__).parent / "events" / "jupyterlab-command-result.yml" + ) + serverapp.event_logger.register_event_schema(result_schema_path) + + async def command_result_listener(logger, schema_id: str, data: dict) -> None: + """ + Handle command result events from the frontend. + + This listener receives the results of JupyterLab commands that were + executed in the frontend and processes them accordingly. + """ + from .tools import handle_command_result + + try: + request_id = data.get("requestId", "unknown") + success = data.get("success", False) + result = data.get("result") + error = data.get("error") + + serverapp.log.info( + f"Received command result for request {request_id}: success={success}" + ) + + if success: + if result is not None: + serverapp.log.debug(f"Command result: {result}") + else: + serverapp.log.warning(f"Command failed: {error}") + + handle_command_result(data) + + except Exception as e: + serverapp.log.error(f"Error processing command result: {e}") + + result_schema_id = ( + "https://events.jupyter.org/jupyterlab_command_toolkit/lab_command_result/v1" + ) + serverapp.event_logger.add_listener( + schema_id=result_schema_id, listener=command_result_listener + ) + + serverapp.log.info( + "jupyterlab_commands_toolkit extension loaded with bidirectional event communication." + ) diff --git a/jupyterlab_commands_toolkit/events/jupyterlab-command-result.yml b/jupyterlab_commands_toolkit/events/jupyterlab-command-result.yml new file mode 100644 index 0000000..6fd6d14 --- /dev/null +++ b/jupyterlab_commands_toolkit/events/jupyterlab-command-result.yml @@ -0,0 +1,22 @@ +"$id": https://events.jupyter.org/jupyterlab_command_toolkit/lab_command_result/v1 +version: 1 +title: A JupyterLab Command Execution Result +personal-data: true +description: | + Result of a JupyterLab Command execution +type: object +required: + - requestId + - success +properties: + requestId: + type: string + description: The unique identifier for the command request + success: + type: boolean + description: Whether the command executed successfully + result: + description: The result data from the command execution + error: + type: string + description: Error message if the command failed \ No newline at end of file diff --git a/jupyterlab_commands_toolkit/toolkit.py b/jupyterlab_commands_toolkit/toolkit.py deleted file mode 100644 index 16e10b5..0000000 --- a/jupyterlab_commands_toolkit/toolkit.py +++ /dev/null @@ -1,50 +0,0 @@ -"""JupyterLab Commands toolkit for Jupyter AI""" -from jupyter_ai.tools.models import Tool, Toolkit - -from typing import Optional -from .tools import ( - open_document, - open_markdown_file_in_preview_mode, - clear_all_outputs_in_notebook, - show_diff_of_current_notebook, - INSERT_MODE -) - -# Create the toolkit -toolkit = Toolkit( - name="jupyterlab_commands_toolkit", - description="""A comprehensive toolkit for controlling JupyterLab interface and performing notebook operations through AI commands. - -This toolkit provides programmatic access to JupyterLab's core functionality, enabling AI assistants to: - -**Document Management:** -- Open files, notebooks, and documents with precise control over layout positioning -- Support for split-pane layouts (top, left, right, bottom) and tab management -- Open markdown files in rendered preview mode for better readability - -**Notebook Operations:** -- Clear all cell outputs in the active notebook for cleanup and sharing -- Display git diffs for notebooks using nbdime visualization -- Maintain notebook structure while performing operations - -**Layout Control:** -- Split current workspace into multiple panes -- Merge content with adjacent areas -- Create new tabs before or after current position -- Flexible positioning options for optimal workspace organization - -**Key Features:** -- Event-driven architecture using JupyterLab's command system -- Seamless integration with Jupyter AI for natural language control -- Support for relative file paths from server root directory -- Comprehensive error handling and user feedback -- Compatible with JupyterLab 4.0+ and modern Jupyter environments - -Use these tools to programmatically manage your JupyterLab workspace, organize documents, and perform common notebook operations through conversational AI interfaces.""" -) - -# Add tools to the toolkit -toolkit.add_tool(Tool(callable=open_document, read=True)) -toolkit.add_tool(Tool(callable=open_markdown_file_in_preview_mode, read=True)) -toolkit.add_tool(Tool(callable=clear_all_outputs_in_notebook, read=True)) -toolkit.add_tool(Tool(callable=show_diff_of_current_notebook, read=True)) diff --git a/jupyterlab_commands_toolkit/tools.py b/jupyterlab_commands_toolkit/tools.py index 1bb07a8..e229eb7 100644 --- a/jupyterlab_commands_toolkit/tools.py +++ b/jupyterlab_commands_toolkit/tools.py @@ -1,185 +1,146 @@ -from typing import Literal, Optional -from jupyter_server.serverapp import ServerApp - - -def emit(data): - server = ServerApp.instance() - server.io_loop.call_later( - 0.1, - server.event_logger.emit, - schema_id="https://events.jupyter.org/jupyterlab_command_toolkit/lab_command/v1", - data=data - ) +import asyncio +import time +import uuid +from typing import Any, Dict, Optional +from jupyter_server.serverapp import ServerApp -INSERT_MODE = Literal['split-top', 'split-left', 'split-right', 'split-bottom', 'merge-top', 'merge-left', 'merge-right', 'merge-bottom', 'tab-before', 'tab-after'] +# Store for pending command results +pending_requests: Dict[str, Dict[str, Any]] = {} -def open_document(relative_path: str, mode: Optional[INSERT_MODE] = None) -> None: +def emit(data, wait_for_result=False): """ - Open a document in JupyterLab. - - This function opens a document at the specified path in JupyterLab by emitting - a 'docmanager:open' command. The document can be opened in various modes that - control how it's displayed relative to existing open documents. - + Emit an event to the frontend with optional result waiting. + Args: - relative_path (str): The relative path to the document to open. - This should be relative to the Jupyter server's root directory. - Examples: 'notebook.ipynb', 'folder/script.py', 'data.csv' - - mode (Optional[INSERT_MODE], optional): The mode specifying how to open - the document. Defaults to None, which opens in the default manner. - Available modes: - - 'split-top': Split the current area and open above - - 'split-left': Split the current area and open to the left - - 'split-right': Split the current area and open to the right - - 'split-bottom': Split the current area and open below - - 'merge-top': Merge with the area above - - 'merge-left': Merge with the area to the left - - 'merge-right': Merge with the area to the right - - 'merge-bottom': Merge with the area below - - 'tab-before': Open as a tab before the current tab - - 'tab-after': Open as a tab after the current tab - + data: Event data to emit + wait_for_result: Whether to add a request ID for result tracking + Returns: - None: This function doesn't return a value. It emits an event to JupyterLab - to trigger the document opening action. - - Examples: - >>> open_document('notebook.ipynb') # Open notebook in default mode - >>> open_document('script.py', mode='split-right') # Open script in right split - >>> open_document('data/analysis.csv', mode='tab-after') # Open CSV as new tab - - Note: - This function requires a running Jupyter server instance and emits events - using the JupyterLab command toolkit event schema. + str: Request ID if wait_for_result is True, None otherwise """ - emit({ - "name": "docmanager:open", - "args": { - "path": relative_path, - "options": { - "mode": mode - } + server = ServerApp.instance() + + # Add request ID if waiting for result + request_id = None + if wait_for_result: + request_id = str(uuid.uuid4()) + data["requestId"] = request_id + pending_requests[request_id] = { + "timestamp": time.time(), + "data": data, + "result": None, + "completed": False, + "future": asyncio.Future(), } - }) + server.io_loop.call_later( + 0.1, + server.event_logger.emit, + schema_id="https://events.jupyter.org/jupyterlab_command_toolkit/lab_command/v1", + data=data, + ) -def open_markdown_file_in_preview_mode(relative_path: str, mode: Optional[INSERT_MODE] = None) -> None: + return request_id + + +async def emit_and_wait_for_result(data, timeout=10.0): """ - Open a markdown file in preview mode in JupyterLab. - - This function opens a markdown file (.md) in rendered preview mode rather than - as an editable text file. It emits a 'markdownviewer:open' command to display - the markdown content with proper formatting, headers, links, and styling. - + Emit a command and wait for its result. + Args: - relative_path (str): The relative path to the markdown file to open in preview. - This should be relative to the Jupyter server's root directory and - typically should have a .md extension. - Examples: 'README.md', 'docs/guide.md', 'notes/meeting-notes.md' - - mode (Optional[INSERT_MODE], optional): The mode specifying how to open - the preview. Defaults to None, which opens in the default manner. - Available modes: - - 'split-top': Split the current area and open preview above - - 'split-left': Split the current area and open preview to the left - - 'split-right': Split the current area and open preview to the right - - 'split-bottom': Split the current area and open preview below - - 'merge-top': Merge with the area above - - 'merge-left': Merge with the area to the left - - 'merge-right': Merge with the area to the right - - 'merge-bottom': Merge with the area below - - 'tab-before': Open as a tab before the current tab - - 'tab-after': Open as a tab after the current tab - + data: Command data to emit + timeout: How long to wait for a result (seconds) + Returns: - None: This function doesn't return a value. It emits an event to JupyterLab - to trigger the markdown preview opening action. - - Examples: - >>> open_markdown_file_in_preview_mode('README.md') # Open README in preview - >>> open_markdown_file_in_preview_mode('docs/api.md', mode='split-right') # Preview in right split - >>> open_markdown_file_in_preview_mode('changelog.md', mode='tab-after') # Preview in new tab - - Note: - - This function specifically opens markdown files in rendered preview mode - - Use open_document() instead if you want to edit the markdown source - - Requires a running Jupyter server with markdown preview extension - - The file should typically have a .md or .markdown extension + dict: Command result from the frontend """ - emit({ - "name": "markdownviewer:open", - "args": { - "path": relative_path, - "options": { - "mode": mode - } + request_id = emit(data, wait_for_result=True) + + try: + future = pending_requests[request_id]["future"] + result = await asyncio.wait_for(future, timeout=timeout) + return result + except asyncio.TimeoutError: + return { + "success": False, + "error": f"Command timed out after {timeout} seconds", + "request_id": request_id, } - }) + finally: + pending_requests.pop(request_id, None) + + +def handle_command_result(event_data): + """Handle incoming command results from the frontend.""" + request_id = event_data.get("requestId") + if request_id and request_id in pending_requests: + request_info = pending_requests[request_id] + request_info["result"] = event_data + request_info["completed"] = True + + future = request_info.get("future") + if future and not future.done(): + future.set_result(event_data) -def clear_all_outputs_in_notebook(run: bool) -> None: +async def list_all_commands() -> dict: """ - Clear all outputs in the active notebook. - - This function clears all cell outputs in the currently active notebook by - emitting a 'notebook:clear-all-cell-outputs' command. This is useful for - cleaning up notebook outputs before sharing or when outputs are no longer - needed. - - Args: - run (bool): Run this command. - + Retrieve a list of all available JupyterLab commands. + + This function emits a request to the JupyterLab frontend to retrieve all + registered commands in the application. It waits for the response and + returns the complete list of available commands with their metadata. + Returns: - None: This function doesn't return a value. It emits an event to JupyterLab - to trigger the clear all outputs action. - - Examples: - >>> clear_all_outputs_in_notebook() # Clear all outputs in current notebook - - Note: - - This function only works when a notebook is currently active/focused - - All cell outputs (including text, images, plots, etc.) will be cleared - - The cell source code remains unchanged, only outputs are removed - - This action cannot be undone, so use with caution - - Requires an active notebook session in JupyterLab + dict: A dictionary containing the command list response from JupyterLab. + The structure typically includes: + - success (bool): Whether the operation succeeded + - commands (list): List of available command objects, with arguments and types + - error (str, optional): Error message if the operation failed + + Raises: + asyncio.TimeoutError: If the frontend doesn't respond within the timeout period """ - emit({ - "name": "notebook:clear-all-cell-outputs", - "args": {} - }) + return await emit_and_wait_for_result( + {"name": "jupyterlab-commands-toolkit:list-all-commands", "args": {}} + ) -def show_diff_of_current_notebook(run: bool) -> None: +async def execute_command(command_id: str, args: Optional[dict] = None) -> dict: """ - Show git diff of the current notebook in JupyterLab. - - This function displays the git differences for the currently active notebook - by emitting an 'nbdime:diff-git' command. It uses nbdime (Jupyter notebook - diff tool) to show a visual comparison between the current notebook state - and the last committed version in git. - + Execute a JupyterLab command with optional arguments. + + This function sends a command execution request to the JupyterLab frontend + and waits for the result. The command is identified by its unique command_id + and can be parameterized with optional arguments. + Args: - run (bool): Run this command. - + command_id (str): The unique identifier of the JupyterLab command to execute. + This should be a valid command ID registered in JupyterLab. + args (Optional[dict], optional): A dictionary of arguments to pass to the + command. Defaults to None, which is converted + to an empty dictionary. + Returns: - None: This function doesn't return a value. It emits an event to JupyterLab - to trigger the notebook diff display. - + dict: A dictionary containing the command execution response from JupyterLab. + The structure typically includes: + - success (bool): Whether the command executed successfully + - result (any): The return value from the executed command + - error (str, optional): Error message if the command failed + - request_id (str): The unique identifier for this request + + Raises: + asyncio.TimeoutError: If the frontend doesn't respond within the timeout period + Examples: - >>> show_diff_of_current_notebook(True) # Show git diff for current notebook - - Note: - - This function only works when a notebook is currently active/focused - - Requires the nbdime extension to be installed and enabled in JupyterLab - - The notebook must be in a git repository for diffs to be meaningful - - Shows differences between current state and last git commit - - Displays both content and output differences in a visual format - - Useful for reviewing changes before committing notebook modifications + >>> await execute_command("application:toggle-left-area") + {'success': True, 'result': None} + + >>> await execute_command("docmanager:open", {"path": "notebook.ipynb"}) + {'success': True, 'result': 'opened'} """ - emit({ - "name": "nbdime:diff-git", - "args": {} - }) \ No newline at end of file + if args is None: + args = {} + return await emit_and_wait_for_result({"name": command_id, "args": args}) diff --git a/pyproject.toml b/pyproject.toml index b8083e1..114515d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,6 @@ classifiers = [ dependencies = [ "jupyter_server>=2.4.0,<3", "jupyterlab-eventlistener", - "jupyter-ai" ] dynamic = ["version", "description", "authors", "urls", "keywords"] diff --git a/src/index.ts b/src/index.ts index 112d4b3..a7173ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,34 +5,132 @@ import { import { Event } from '@jupyterlab/services'; import { IEventListener } from 'jupyterlab-eventlistener'; +const JUPYTERLAB_COMMAND_SCHEMA_ID = + 'https://events.jupyter.org/jupyterlab_command_toolkit/lab_command/v1'; -const JUPYTERLAB_COMMAND_SCHEMA_ID = "https://events.jupyter.org/jupyterlab_command_toolkit/lab_command/v1" +const JUPYTERLAB_COMMAND_RESULT_SCHEMA_ID = + 'https://events.jupyter.org/jupyterlab_command_toolkit/lab_command_result/v1'; type JupyterLabCommand = { - name: string, - args: any -} + name: string; + args: any; + requestId?: string; +}; +type JupyterLabCommandResult = { + requestId: string; + success: boolean; + result?: any; + error?: string; +}; /** * Initialization data for the jupyterlab-commands-toolkit extension. */ const plugin: JupyterFrontEndPlugin = { id: 'jupyterlab-commands-toolkit:plugin', - description: 'A Jupyter extension that provides an AI toolkit for JupyterLab commands.', + description: + 'A Jupyter extension that provides an AI toolkit for JupyterLab commands.', autoStart: true, requires: [IEventListener], activate: (app: JupyterFrontEnd, eventListener: IEventListener) => { + console.log( + 'JupyterLab extension jupyterlab-commands-toolkit is activated 2342521263!' + ); + + const { commands } = app; - console.log('JupyterLab extension jupyterlab-commands-toolkit is activated 2342521263!'); - eventListener.addListener( JUPYTERLAB_COMMAND_SCHEMA_ID, - async (manager, schemaId, event: Event.Emission) => { - let data = event as any as JupyterLabCommand - await app.commands.execute(data.name, data.args) + async (manager, _, event: Event.Emission) => { + const data = event as any as JupyterLabCommand; + const result: JupyterLabCommandResult = { + requestId: data.requestId || '', + success: false + }; + + try { + const commandResult = await app.commands.execute( + data.name, + data.args + ); + result.success = true; + + // Handle Widget objects specially (including subclasses like DocumentWidget) + let serializedResult; + if ( + commandResult && + typeof commandResult === 'object' && + (commandResult.constructor?.name?.includes('Widget') || + commandResult.id) + ) { + serializedResult = { + type: commandResult.constructor?.name || 'Widget', + id: commandResult.id, + title: commandResult.title?.label || commandResult.title, + className: commandResult.className + }; + } else { + // For other objects, try JSON serialization with fallback + try { + serializedResult = JSON.parse(JSON.stringify(commandResult)); + } catch { + serializedResult = commandResult + ? '[Complex object - cannot serialize]' + : 'Command executed successfully'; + } + } + + result.result = serializedResult; + } catch (error) { + result.success = false; + result.error = error instanceof Error ? error.message : String(error); + } + + // Emit the result back if we have a requestId + if (data.requestId) { + manager.emit({ + schema_id: JUPYTERLAB_COMMAND_RESULT_SCHEMA_ID, + version: '1', + data: result + }); + } } ); + + commands.addCommand('jupyterlab-commands-toolkit:list-all-commands', { + label: 'List All Commands', + describedBy: { + args: {} + }, + execute: async () => { + const commandList: Array<{ + id: string; + label?: string; + caption?: string; + description?: string; + args?: any; + }> = []; + + const commandIds = commands.listCommands(); + + for (const id of commandIds) { + const description = await commands.describedBy(id); + const label = commands.label(id); + const caption = commands.caption(id); + const usage = commands.usage(id); + + commandList.push({ + id, + label: label || undefined, + caption: caption || undefined, + description: usage || undefined, + args: description?.args || undefined + }); + } + return commandList; + } + }); } };