diff --git a/app/package.json b/app/package.json index f9286b58b4..2a2aa87e8b 100644 --- a/app/package.json +++ b/app/package.json @@ -62,6 +62,7 @@ "@jupyterlab/hub-extension": "~4.5.0-alpha.3", "@jupyterlab/imageviewer": "~4.5.0-alpha.3", "@jupyterlab/imageviewer-extension": "~4.5.0-alpha.3", + "@jupyterlab/inspector-extension": "~4.5.0-alpha.3", "@jupyterlab/javascript-extension": "~4.5.0-alpha.3", "@jupyterlab/json-extension": "~4.5.0-alpha.3", "@jupyterlab/logconsole-extension": "~4.5.0-alpha.3", @@ -160,6 +161,7 @@ "@jupyterlab/htmlviewer-extension": "~4.5.0-alpha.3", "@jupyterlab/hub-extension": "~4.5.0-alpha.3", "@jupyterlab/imageviewer-extension": "~4.5.0-alpha.3", + "@jupyterlab/inspector-extension": "~4.5.0-alpha.3", "@jupyterlab/javascript-extension": "~4.5.0-alpha.3", "@jupyterlab/json-extension": "~4.5.0-alpha.3", "@jupyterlab/logconsole-extension": "~4.5.0-alpha.3", @@ -292,6 +294,9 @@ "@jupyterlab/htmlviewer-extension": true, "@jupyterlab/hub-extension": true, "@jupyterlab/imageviewer-extension": true, + "@jupyterlab/inspector-extension": [ + "@jupyterlab/inspector-extension:inspector" + ], "@jupyterlab/lsp-extension": true, "@jupyterlab/mainmenu-extension": [ "@jupyterlab/mainmenu-extension:plugin" @@ -403,6 +408,7 @@ "@jupyterlab/fileeditor", "@jupyterlab/htmlviewer", "@jupyterlab/imageviewer", + "@jupyterlab/inspector", "@jupyterlab/lsp", "@jupyterlab/mainmenu", "@jupyterlab/markdownviewer", diff --git a/docs/source/_static/images/pager.png b/docs/source/_static/images/pager.png new file mode 100644 index 0000000000..73ebf39371 Binary files /dev/null and b/docs/source/_static/images/pager.png differ diff --git a/docs/source/pager.md b/docs/source/pager.md new file mode 100644 index 0000000000..bdd21cb53a --- /dev/null +++ b/docs/source/pager.md @@ -0,0 +1,77 @@ +# Contextual Help (Pager) + +The Contextual Help (also known as the pager) is a feature that displays help documentation and function signatures when you request them in a Jupyter Notebook cell. It provides a convenient way to view documentation without leaving your current context. + +```{image} ./_static/images/pager.png + +``` + +## What is the Pager? + +The pager displays help content such as: + +- Function docstrings and signatures +- Object information +- Module documentation +- Any content typically shown by Python's `help()` function or IPython's `?` and `??` operators + +## Default Behavior + +By default, the notebook pager opens documentation in the **down area** at the bottom of the notebook interface. This behavior is inherited from the classic Jupyter Notebook interface and keeps your main notebook view uncluttered while providing easy access to help content. + +When you use help commands like: + +```ipython +# Show help for a function +help(print) + +# IPython magic for quick help +print? + +# Detailed help with source code +print?? +``` + +The documentation will appear in the collapsible panel at the bottom of the notebook page. + +## Configuration Options + +You can customize the pager behavior through the notebook settings: + +### Opening Help in Different Areas + +The pager behavior is controlled by the `openHelpInDownArea` setting: + +- **`true` (default)**: Help content opens in the down area +- **`false`**: Help content displays inline within the cell output (like in JupyterLab) + +### How to Change the Setting + +1. **Via Settings Menu**: Navigate to Settings → Advanced Settings Editor → Notebook +2. **Via Configuration File**: Add the following to your notebook configuration: + +```json +{ + "@jupyter-notebook/notebook-extension:pager": { + "openHelpInDownArea": false + } +} +``` + +### Switching to the JupyterLab behavior + +If you prefer the JupyterLab approach where help content appears inline with your cell outputs, set `openHelpInDownArea` to `false`. This will display help content directly below the cell that requested it, similar to regular cell output. + +Each mode has its own benefits. + +**With the Down Area (Default)** + +- **Persistent**: Help content stays visible while you work on other cells +- **Organized**: Keeps main notebook area clean and focused +- **Familiar**: Matches classic Jupyter Notebook behavior + +**Inline Output** + +- **Contextual**: Help appears directly where you requested it +- **JupyterLab-compatible**: Familiar to JupyterLab users +- **Self-contained**: Each cell's help is contained within its output diff --git a/docs/source/user-documentation.md b/docs/source/user-documentation.md index cdb7f562e2..5e8233d681 100644 --- a/docs/source/user-documentation.md +++ b/docs/source/user-documentation.md @@ -8,6 +8,7 @@ Use this page to navigate to different parts of the user documentation. notebook ui_components notebook_7_features +pager examples/Notebook/examples_index.rst custom_css configuring/plugins diff --git a/packages/application-extension/schema/shell.json b/packages/application-extension/schema/shell.json index 00a67f0160..0fdd57a6c1 100644 --- a/packages/application-extension/schema/shell.json +++ b/packages/application-extension/schema/shell.json @@ -9,6 +9,7 @@ "title": "Customize shell widget positioning", "description": "Overrides default widget position in the application layout", "default": { + "Inspector": { "area": "down" }, "Markdown Preview": { "area": "right" }, "Plugins": { "area": "left" } } @@ -24,7 +25,7 @@ "type": "object", "properties": { "area": { - "enum": ["left", "right"] + "enum": ["left", "right", "down"] } }, "additionalProperties": false diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts index 65a7159c14..eee76bf42c 100644 --- a/packages/application/src/shell.ts +++ b/packages/application/src/shell.ts @@ -305,6 +305,12 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { } else if (area === 'right') { this.expandRight(id); } else if (area === 'down') { + const tabIndex = this._downPanel.tabBar.titles.findIndex( + (title) => title.owner.id === id + ); + if (tabIndex >= 0) { + this._downPanel.currentIndex = tabIndex; + } this._downPanel.show(); widget.activate(); } else { diff --git a/packages/notebook-extension/package.json b/packages/notebook-extension/package.json index 3edfc512a0..dac20a4977 100644 --- a/packages/notebook-extension/package.json +++ b/packages/notebook-extension/package.json @@ -43,6 +43,7 @@ "@jupyterlab/apputils": "~4.6.0-alpha.3", "@jupyterlab/cells": "~4.5.0-alpha.3", "@jupyterlab/docmanager": "~4.5.0-alpha.3", + "@jupyterlab/inspector": "~4.5.0-alpha.3", "@jupyterlab/notebook": "~4.5.0-alpha.3", "@jupyterlab/settingregistry": "~4.5.0-alpha.3", "@jupyterlab/translation": "~4.5.0-alpha.3", diff --git a/packages/notebook-extension/schema/pager.json b/packages/notebook-extension/schema/pager.json new file mode 100644 index 0000000000..f1b42928ea --- /dev/null +++ b/packages/notebook-extension/schema/pager.json @@ -0,0 +1,14 @@ +{ + "title": "Jupyter Notebook Pager Settings", + "description": "Settings for controlling pager/help display behavior", + "properties": { + "openHelpInDownArea": { + "type": "boolean", + "title": "Open Help in Down Area", + "description": "Whether to open help/documentation in the inspector panel (down area) or display it inline in the cell output", + "default": true + } + }, + "additionalProperties": false, + "type": "object" +} diff --git a/packages/notebook-extension/src/index.ts b/packages/notebook-extension/src/index.ts index e0fafbb08f..653be09adb 100644 --- a/packages/notebook-extension/src/index.ts +++ b/packages/notebook-extension/src/index.ts @@ -7,28 +7,47 @@ import { } from '@jupyterlab/application'; import { - ISessionContext, DOMUtils, - IToolbarWidgetRegistry, ICommandPalette, + ISessionContext, + IToolbarWidgetRegistry, } from '@jupyterlab/apputils'; import { Cell, CodeCell } from '@jupyterlab/cells'; import { PageConfig, Text, Time, URLExt } from '@jupyterlab/coreutils'; +import { ReadonlyJSONObject } from '@lumino/coreutils'; + import { IDocumentManager } from '@jupyterlab/docmanager'; import { DocumentRegistry } from '@jupyterlab/docregistry'; +import { + IInspector, + InspectionHandler, + KernelConnector, +} from '@jupyterlab/inspector'; + +/** + * Interface for inspection handler with custom mime bundle handling + */ +interface ICustomInspectionHandler extends InspectionHandler { + onMimeBundleChange(mimeData: ReadonlyJSONObject): void; +} + import { IMainMenu } from '@jupyterlab/mainmenu'; import { - NotebookPanel, - INotebookTracker, INotebookTools, + INotebookTracker, + NotebookPanel, } from '@jupyterlab/notebook'; +import { Kernel, KernelMessage } from '@jupyterlab/services'; + +import { MimeModel } from '@jupyterlab/rendermime'; + import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ITranslator, nullTranslator } from '@jupyterlab/translation'; @@ -37,6 +56,8 @@ import { INotebookShell } from '@jupyter-notebook/application'; import { Poll } from '@lumino/polling'; +import { Signal } from '@lumino/signaling'; + import { Widget } from '@lumino/widgets'; import { TrustedComponent } from './trusted'; @@ -749,6 +770,245 @@ const editNotebookMetadata: JupyterFrontEndPlugin = { }, }; +/** + * A plugin providing a pager widget to display help and documentation + * in the down panel, similar to classic notebook behavior. + */ +const pager: JupyterFrontEndPlugin = { + id: '@jupyter-notebook/notebook-extension:pager', + description: + 'A plugin to toggle the inspector when a pager payload is received.', + autoStart: true, + requires: [INotebookTracker, IInspector], + optional: [ISettingRegistry, ITranslator], + activate: ( + app: JupyterFrontEnd, + notebookTracker: INotebookTracker, + inspector: IInspector, + settingRegistry: ISettingRegistry | null, + translator: ITranslator | null + ) => { + translator = translator ?? nullTranslator; + + let openHelpInDownArea = true; + + const kernelMessageHandlers: { + [sessionId: string]: { + kernel: Kernel.IKernelConnection; + handler: ( + sender: Kernel.IKernelConnection, + args: Kernel.IAnyMessageArgs + ) => void; + }; + } = {}; + + const cleanupKernelMessageHandler = (sessionId: string) => { + if (kernelMessageHandlers[sessionId]) { + const { kernel, handler } = kernelMessageHandlers[sessionId]; + kernel.anyMessage.disconnect(handler); + delete kernelMessageHandlers[sessionId]; + } + }; + + if (settingRegistry) { + const loadSettings = settingRegistry.load(pager.id); + const updateSettings = (settings: ISettingRegistry.ISettings): void => { + openHelpInDownArea = settings.get('openHelpInDownArea') + .composite as boolean; + setSource(app.shell.currentWidget); + }; + + Promise.all([loadSettings, app.restored]) + .then(([settings]) => { + updateSettings(settings); + settings.changed.connect(updateSettings); + }) + .catch((reason: Error) => { + console.error( + `Failed to load settings for ${pager.id}: ${reason.message}` + ); + }); + } + + const setupPagerListener = (sessionContext: ISessionContext) => { + const sessionId = sessionContext.session?.id; + if (!sessionId) { + return; + } + + // Always clean up existing handlers first + cleanupKernelMessageHandler(sessionId); + + if (!openHelpInDownArea) { + return; + } + + // Listen for kernel messages that may contain pager payloads + const kernelMessageHandler = async ( + sender: Kernel.IKernelConnection, + args: Kernel.IAnyMessageArgs + ) => { + const { msg, direction } = args; + + // only check 'execute_reply' from the shell channel for pager data + if ( + direction === 'recv' && + msg.channel === 'shell' && + msg.header.msg_type === 'execute_reply' + ) { + const content = msg.content as KernelMessage.IExecuteReply; + if ( + content.status === 'ok' && + content.payload && + content.payload.length > 0 + ) { + const pagePayload = content.payload.find( + (item) => item.source === 'page' + ); + + if (pagePayload && pagePayload.data) { + // Remove the 'page' payload from the message to prevent it from also appearing in the cell's output area + content.payload = content.payload.filter( + (item) => item.source !== 'page' + ); + if (content.payload.length === 0) { + // If no other payloads remain, delete the payload array from the content. + delete content.payload; + } + + await app.commands.execute('inspector:open'); + + // Call our custom handler directly with the whole mime bundle + inspectionHandler.onMimeBundleChange( + pagePayload.data as ReadonlyJSONObject + ); + } + } + } + }; + + // Connect to the kernel's anyMessage signal to catch + // pager payloads before the output area + if (sessionContext.session?.kernel) { + sessionContext.session.kernel.anyMessage.connect(kernelMessageHandler); + kernelMessageHandlers[sessionId] = { + kernel: sessionContext.session.kernel, + handler: kernelMessageHandler, + }; + } + }; + + let inspectionHandler: ICustomInspectionHandler; + + // Create a handler for each notebook that is created. + notebookTracker.widgetAdded.connect((_sender, panel) => { + // Use custom pager behavior + if (panel.sessionContext) { + setupPagerListener(panel.sessionContext); + } + + panel.sessionContext.sessionChanged.connect(() => { + if (panel.sessionContext) { + setupPagerListener(panel.sessionContext); + } + }); + + panel.sessionContext.kernelChanged.connect(() => { + if (panel.sessionContext) { + setupPagerListener(panel.sessionContext); + } + }); + + const sessionContext = panel.sessionContext; + const rendermime = panel.content.rendermime; + const connector = new (class extends KernelConnector { + async fetch(request: InspectionHandler.IRequest): Promise { + // no-op + } + })({ sessionContext }); + + // Define a custom inspection handler so we can persist the pager data even after + // switching cells or moving the cursor position + inspectionHandler = new (class extends InspectionHandler { + get inspected() { + return this._notebookInspected; + } + + onEditorChange(text: string): void { + // no-op + } + + onMimeBundleChange(mimeData: ReadonlyJSONObject): void { + const update: IInspector.IInspectorUpdate = { content: null }; + + if (mimeData) { + const mimeType = rendermime.preferredMimeType(mimeData); + if (mimeType) { + const widget = rendermime.createRenderer(mimeType); + // set trusted since this is coming from a cell execution + const trusted = true; + const model = new MimeModel({ data: mimeData, trusted }); + void widget.renderModel(model); + update.content = widget; + } + } + + // Emit the inspection update signal + this._notebookInspected.emit(update); + } + + private _notebookInspected: Signal< + InspectionHandler, + IInspector.IInspectorUpdate + > = new Signal(this); + })({ connector, rendermime }); + + // Listen for parent disposal. + panel.disposed.connect(() => { + inspectionHandler.dispose(); + }); + }); + + // Keep track of notebook instances and set inspector source. + const setSource = (widget: Widget | null): void => { + if (openHelpInDownArea) { + inspector.source = inspectionHandler; + } else { + if (widget && notebookTracker.currentWidget) { + // default to the JupyterLab behavior but with a single inspection handler, + // since there is only one active notebook at a time + const panel = notebookTracker.currentWidget; + const sessionContext = panel.sessionContext; + const rendermime = panel.content.rendermime; + const connector = new KernelConnector({ sessionContext }); + const handler = new InspectionHandler({ + connector, + rendermime, + }); + + const cell = panel.content.activeCell; + handler.editor = cell && cell.editor; + + panel.content.activeCellChanged.connect((sender, cell) => { + void cell?.ready.then(() => { + if (cell === panel.content.activeCell && cell) { + handler.editor = cell.editor; + } + }); + }); + + panel.disposed.connect(() => { + handler.dispose(); + }); + inspector.source = handler; + } + } + }; + app.shell.currentChanged?.connect((_, args) => setSource(args.newValue)); + void app.restored.then(() => setSource(app.shell.currentWidget)); + }, +}; + /** * Export the plugins as default. */ @@ -761,6 +1021,7 @@ const plugins: JupyterFrontEndPlugin[] = [ kernelLogo, kernelStatus, notebookToolsWidget, + pager, scrollOutput, tabIcon, trusted, diff --git a/ui-tests/test/menus.spec.ts-snapshots/opened-menu-help-chromium-linux.png b/ui-tests/test/menus.spec.ts-snapshots/opened-menu-help-chromium-linux.png index 7140216319..a3729b6787 100644 Binary files a/ui-tests/test/menus.spec.ts-snapshots/opened-menu-help-chromium-linux.png and b/ui-tests/test/menus.spec.ts-snapshots/opened-menu-help-chromium-linux.png differ diff --git a/ui-tests/test/menus.spec.ts-snapshots/opened-menu-help-firefox-linux.png b/ui-tests/test/menus.spec.ts-snapshots/opened-menu-help-firefox-linux.png index 189b210d91..1f54b62116 100644 Binary files a/ui-tests/test/menus.spec.ts-snapshots/opened-menu-help-firefox-linux.png and b/ui-tests/test/menus.spec.ts-snapshots/opened-menu-help-firefox-linux.png differ diff --git a/ui-tests/test/notebook.spec.ts b/ui-tests/test/notebook.spec.ts index de1223ca61..e1c42ef1f4 100644 --- a/ui-tests/test/notebook.spec.ts +++ b/ui-tests/test/notebook.spec.ts @@ -250,4 +250,39 @@ test.describe('Notebook', () => { await page.waitForSelector('.jp-OutputPlaceholder', { state: 'hidden' }); }); + + test('Help pager should open in down area with question mark syntax', async ({ + page, + tmpPath, + }) => { + const notebook = 'empty.ipynb'; + await page.contents.uploadFile( + path.resolve(__dirname, `./notebooks/${notebook}`), + `${tmpPath}/${notebook}` + ); + await page.goto(`notebooks/${tmpPath}/${notebook}`); + + await waitForKernelReady(page); + + await page.click('.jp-Cell-inputArea'); + + // Enter code in the first cell + await page.locator( + '.jp-Cell-inputArea >> .cm-editor >> .cm-content[contenteditable="true"]' + ).type(`import math + +math.pi?`); + + // Run the cell + runAndAdvance(page); + + const inspector = page.locator('.jp-Inspector'); + await expect(inspector).toBeVisible(); + + const inspectorContent = page.locator('.jp-Inspector-content'); + await expect(inspectorContent).toContainText('3.14'); + + const cellOutput = page.locator('.jp-Cell-outputArea'); + await expect(cellOutput.first()).toBeEmpty(); + }); }); diff --git a/yarn.lock b/yarn.lock index 73783278d4..50b52cb5c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2167,6 +2167,7 @@ __metadata: "@jupyterlab/htmlviewer-extension": ~4.5.0-alpha.3 "@jupyterlab/hub-extension": ~4.5.0-alpha.3 "@jupyterlab/imageviewer-extension": ~4.5.0-alpha.3 + "@jupyterlab/inspector-extension": ~4.5.0-alpha.3 "@jupyterlab/javascript-extension": ~4.5.0-alpha.3 "@jupyterlab/json-extension": ~4.5.0-alpha.3 "@jupyterlab/logconsole-extension": ~4.5.0-alpha.3 @@ -2395,6 +2396,7 @@ __metadata: "@jupyterlab/apputils": ~4.6.0-alpha.3 "@jupyterlab/cells": ~4.5.0-alpha.3 "@jupyterlab/docmanager": ~4.5.0-alpha.3 + "@jupyterlab/inspector": ~4.5.0-alpha.3 "@jupyterlab/notebook": ~4.5.0-alpha.3 "@jupyterlab/settingregistry": ~4.5.0-alpha.3 "@jupyterlab/translation": ~4.5.0-alpha.3 @@ -3788,7 +3790,24 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/inspector@npm:^4.5.0-alpha.3": +"@jupyterlab/inspector-extension@npm:~4.5.0-alpha.3": + version: 4.5.0-alpha.3 + resolution: "@jupyterlab/inspector-extension@npm:4.5.0-alpha.3" + dependencies: + "@jupyterlab/application": ^4.5.0-alpha.3 + "@jupyterlab/apputils": ^4.6.0-alpha.3 + "@jupyterlab/console": ^4.5.0-alpha.3 + "@jupyterlab/inspector": ^4.5.0-alpha.3 + "@jupyterlab/launcher": ^4.5.0-alpha.3 + "@jupyterlab/notebook": ^4.5.0-alpha.3 + "@jupyterlab/translation": ^4.5.0-alpha.3 + "@jupyterlab/ui-components": ^4.5.0-alpha.3 + "@lumino/widgets": ^2.7.1 + checksum: 12defd4c7797c6414800413316f9e1cd749ace9c8bd917dfdf8c3f74c491545293ea551e5a40e1572066218ef9b1579520db2146f51e22e7769e9359993ae2f3 + languageName: node + linkType: hard + +"@jupyterlab/inspector@npm:^4.5.0-alpha.3, @jupyterlab/inspector@npm:~4.5.0-alpha.3": version: 4.5.0-alpha.3 resolution: "@jupyterlab/inspector@npm:4.5.0-alpha.3" dependencies: