diff --git a/mito-ai/package.json b/mito-ai/package.json index d2f6b9006..62f82fb48 100644 --- a/mito-ai/package.json +++ b/mito-ai/package.json @@ -57,8 +57,15 @@ "watch:labextension": "jupyter labextension watch ." }, "dependencies": { - "@jupyterlab/application": "^4.2.4", - "@jupyterlab/apputils": "^4.3.4", + "@codemirror/state": "^6.2.0", + "@codemirror/view": "^6.9.6", + "@jupyterlab/application": "^4.1.0", + "@jupyterlab/apputils": "^4.2.0", + "@jupyterlab/cells": "^4.1.0", + "@jupyterlab/codeeditor": "^4.1.0", + "@jupyterlab/codemirror": "^4.1.0", + "@lumino/commands": "^2.0.0", + "@lumino/coreutils": "^2.0.0", "@lumino/widgets": "^2.5.0", "@types/react": "^18.0.26", "@types/react-dom": "^18.0.10", diff --git a/mito-ai/src/Extensions/AiChat/AiChatPlugin.ts b/mito-ai/src/Extensions/AiChat/AiChatPlugin.ts index 725fc70a6..dc32d2f9b 100644 --- a/mito-ai/src/Extensions/AiChat/AiChatPlugin.ts +++ b/mito-ai/src/Extensions/AiChat/AiChatPlugin.ts @@ -1,7 +1,7 @@ import { ILayoutRestorer, JupyterFrontEnd, - JupyterFrontEndPlugin, + JupyterFrontEndPlugin } from '@jupyterlab/application'; import { ICommandPalette, WidgetTracker } from '@jupyterlab/apputils'; import { INotebookTracker } from '@jupyterlab/notebook'; @@ -9,33 +9,43 @@ import { buildChatWidget } from './ChatWidget'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { IVariableManager } from '../VariableManager/VariableManagerPlugin'; import { COMMAND_MITO_AI_OPEN_CHAT } from '../../commands'; - +import { IChatTracker } from './token'; /** * Initialization data for the mito-ai extension. */ -const AiChatPlugin: JupyterFrontEndPlugin = { +const AiChatPlugin: JupyterFrontEndPlugin = { id: 'mito_ai:plugin', description: 'AI chat for JupyterLab', autoStart: true, - requires: [INotebookTracker, ICommandPalette, IRenderMimeRegistry, IVariableManager], + requires: [ + INotebookTracker, + ICommandPalette, + IRenderMimeRegistry, + IVariableManager + ], optional: [ILayoutRestorer], + provides: IChatTracker, activate: ( - app: JupyterFrontEnd, - notebookTracker: INotebookTracker, - palette: ICommandPalette, + app: JupyterFrontEnd, + notebookTracker: INotebookTracker, + palette: ICommandPalette, rendermime: IRenderMimeRegistry, variableManager: IVariableManager, restorer: ILayoutRestorer | null ) => { - // Define a widget creator function, // then call it to make a new widget const newWidget = () => { // Create a blank content widget inside of a MainAreaWidget - const chatWidget = buildChatWidget(app, notebookTracker, rendermime, variableManager) - return chatWidget - } + const chatWidget = buildChatWidget( + app, + notebookTracker, + rendermime, + variableManager + ); + return chatWidget; + }; let widget = newWidget(); @@ -43,35 +53,35 @@ const AiChatPlugin: JupyterFrontEndPlugin = { app.commands.addCommand(COMMAND_MITO_AI_OPEN_CHAT, { label: 'Your friendly Python Expert chat bot', execute: () => { - // In order for the widget to be accessible, the widget must be: // 1. Created // 2. Added to the widget tracker - // 3. Attatched to the frontend + // 3. Attatched to the frontend // Step 1: Create the widget if its not already created if (!widget || widget.isDisposed) { widget = newWidget(); } - // Step 2: Add the widget to the widget tracker if + // Step 2: Add the widget to the widget tracker if // its not already there if (!tracker.has(widget)) { tracker.add(widget); } - // Step 3: Attatch the widget to the app if its not + // Step 3: Attatch the widget to the app if its not // already there if (!widget.isAttached) { app.shell.add(widget, 'left', { rank: 2000 }); } - - // Now that the widget is potentially accessible, activating the + + // Now that the widget is potentially accessible, activating the // widget opens the taskpane app.shell.activateById(widget.id); - + // Set focus on the chat input - const chatInput: HTMLTextAreaElement | null = widget.node.querySelector('.chat-input'); + const chatInput: HTMLTextAreaElement | null = + widget.node.querySelector('.chat-input'); chatInput?.focus(); } }); @@ -79,25 +89,29 @@ const AiChatPlugin: JupyterFrontEndPlugin = { app.commands.addKeyBinding({ command: COMMAND_MITO_AI_OPEN_CHAT, keys: ['Accel E'], - selector: 'body', + selector: 'body' }); app.shell.add(widget, 'left', { rank: 2000 }); // Add the command to the palette. - palette.addItem({ command: COMMAND_MITO_AI_OPEN_CHAT, category: 'AI Chat' }); + palette.addItem({ + command: COMMAND_MITO_AI_OPEN_CHAT, + category: 'AI Chat' + }); // Track and restore the widget state - let tracker = new WidgetTracker({ + const tracker = new WidgetTracker({ namespace: widget.id }); if (restorer) { restorer.add(widget, 'mito_ai'); } + + // This allows us to force plugin load order + return tracker; } }; export default AiChatPlugin; - - diff --git a/mito-ai/src/Extensions/AiChat/token.ts b/mito-ai/src/Extensions/AiChat/token.ts new file mode 100644 index 000000000..cbf84a761 --- /dev/null +++ b/mito-ai/src/Extensions/AiChat/token.ts @@ -0,0 +1,7 @@ +import type { WidgetTracker } from '@jupyterlab/apputils'; +import { Token } from '@lumino/coreutils'; + +export const IChatTracker = new Token( + 'mito-ai/IChatTracker', + 'Widget tracker for the chat sidebar.' +); diff --git a/mito-ai/src/Extensions/emptyCell/emptyCell.ts b/mito-ai/src/Extensions/emptyCell/emptyCell.ts new file mode 100644 index 000000000..8254d315c --- /dev/null +++ b/mito-ai/src/Extensions/emptyCell/emptyCell.ts @@ -0,0 +1,93 @@ +import { Facet, type Extension } from '@codemirror/state'; +import { + Decoration, + EditorView, + ViewPlugin, + WidgetType, + type DecorationSet, + type ViewUpdate +} from '@codemirror/view'; + +/** + * Theme for the advice widget. + */ +const adviceTheme = EditorView.baseTheme({ + '& .cm-mito-advice': { + color: 'var(--jp-ui-font-color3)', + fontStyle: 'italic' + }, + '& .cm-mito-advice > kbd': { + borderRadius: '3px', + borderStyle: 'solid', + borderWidth: '1px', + fontSize: 'calc(var(--jp-code-font-size) - 2px)', + padding: '3px 5px', + verticalAlign: 'middle' + } +}); + +/** + * A facet that stores the chat shortcut. + */ +const adviceText = Facet.define({ + combine: values => (values.length ? values[values.length - 1] : '') +}); + +class AdviceWidget extends WidgetType { + constructor(readonly advice: string) { + super(); + } + + eq(other: AdviceWidget) { + return false; + } + + toDOM() { + const wrap = document.createElement('span'); + wrap.className = 'cm-mito-advice'; + wrap.innerHTML = this.advice; + return wrap; + } +} + +function shouldDisplayAdvice(view: EditorView): DecorationSet { + const shortcut = view.state.facet(adviceText); + const widgets = []; + if (view.hasFocus && view.state.doc.length == 0) { + const deco = Decoration.widget({ + widget: new AdviceWidget(shortcut), + side: 1 + }); + widgets.push(deco.range(0)); + } + return Decoration.set(widgets); +} + +export const showAdvice = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = shouldDisplayAdvice(view); + } + + update(update: ViewUpdate) { + const advice = update.view.state.facet(adviceText); + if ( + update.docChanged || + update.focusChanged || + advice !== update.startState.facet(adviceText) + ) { + this.decorations = shouldDisplayAdvice(update.view); + } + } + }, + { + decorations: v => v.decorations, + provide: () => [adviceTheme] + } +); + +export function advicePlugin(options: { advice?: string } = {}): Extension { + return [adviceText.of(options.advice ?? ''), showAdvice]; +} diff --git a/mito-ai/src/Extensions/emptyCell/index.ts b/mito-ai/src/Extensions/emptyCell/index.ts new file mode 100644 index 000000000..e109e1140 --- /dev/null +++ b/mito-ai/src/Extensions/emptyCell/index.ts @@ -0,0 +1,72 @@ +import type { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; +import type { WidgetTracker } from '@jupyterlab/apputils'; +import type { ICellModel } from '@jupyterlab/cells'; +import { IEditorMimeTypeService } from '@jupyterlab/codeeditor'; +import { + EditorExtensionRegistry, + IEditorExtensionRegistry +} from '@jupyterlab/codemirror'; +import { CommandRegistry } from '@lumino/commands'; +import { COMMAND_MITO_AI_OPEN_CHAT } from '../../commands'; +import { IChatTracker } from '../AiChat/token'; +import { advicePlugin } from './emptyCell'; + +export const localPrompt: JupyterFrontEndPlugin = { + id: 'mito-ai:empty-editor-advice', + description: 'Display a default message in an empty code cell.', + autoStart: true, + requires: [IChatTracker, IEditorExtensionRegistry], + activate: ( + app: JupyterFrontEnd, + // Trick to ensure the chat plugin is available and loaded before this one + // so that the keybinding can be properly resolved. + tracker: WidgetTracker, + extensions: IEditorExtensionRegistry + ): void => { + const keyBindings = app.commands.keyBindings.find( + b => b.command === COMMAND_MITO_AI_OPEN_CHAT + ); + const pythonAdvice = `Start writing python or Press ${CommandRegistry.formatKeystroke( + keyBindings?.keys[0] ?? 'Accel E' + ) + .split(/[\+\s]/) + .map(s => `${s}`) + .join(' + ')} to ask Mito AI to write code for you.`; + extensions.addExtension({ + name: 'mito-ai:empty-editor-advice', + factory: options => { + let advice = ''; // Default advice + // Add the advice only for cells (not for file editor) + if (options.inline) { + let guessedMimeType = options.model.mimeType; + if ( + guessedMimeType === IEditorMimeTypeService.defaultMimeType && + (options.model as ICellModel).type === 'code' + ) { + // Assume the kernel is not yet ready and will be a Python one. + // FIXME it will be better to deal with model.mimeTypeChanged signal + // but this is gonna be hard. + guessedMimeType = 'text/x-ipython'; + } + // Tune the advice with the mimetype + switch (guessedMimeType) { + case 'text/x-ipython': // Python code cell + advice = pythonAdvice; + break; + case 'text/x-ipythongfm': // Jupyter Markdown cell + advice = 'Start writing markdown.'; + break; + } + } + return EditorExtensionRegistry.createImmutableExtension( + advicePlugin({ + advice + }) + ); + } + }); + } +}; diff --git a/mito-ai/src/index.ts b/mito-ai/src/index.ts index bfae4e464..c69e84cb0 100644 --- a/mito-ai/src/index.ts +++ b/mito-ai/src/index.ts @@ -1,14 +1,15 @@ - import AiChatPlugin from './Extensions/AiChat/AiChatPlugin'; import VariableManagerPlugin from './Extensions/VariableManager/VariableManagerPlugin'; import ErrorMimeRendererPlugin from './Extensions/ErrorMimeRenderer/ErrorMimeRendererPlugin'; import CellToolbarButtonsPlugin from './Extensions/CellToolbarButtons/CellToolbarButtonsPlugin'; +import { localPrompt } from './Extensions/emptyCell'; -// This is the main entry point to the mito-ai extension. It must export all of the top level +// This is the main entry point to the mito-ai extension. It must export all of the top level // extensions that we want to load. export default [ - AiChatPlugin, - ErrorMimeRendererPlugin, - VariableManagerPlugin, - CellToolbarButtonsPlugin + AiChatPlugin, + ErrorMimeRendererPlugin, + VariableManagerPlugin, + CellToolbarButtonsPlugin, + localPrompt ]; diff --git a/mito-ai/ui-tests/README.md b/mito-ai/ui-tests/README.md deleted file mode 100644 index 918251459..000000000 --- a/mito-ai/ui-tests/README.md +++ /dev/null @@ -1,167 +0,0 @@ -# Integration Testing - -This folder contains the integration tests of the extension. - -They are defined using [Playwright](https://playwright.dev/docs/intro) test runner -and [Galata](https://github.com/jupyterlab/jupyterlab/tree/main/galata) helper. - -The Playwright configuration is defined in [playwright.config.js](./playwright.config.js). - -The JupyterLab server configuration to use for the integration test is defined -in [jupyter_server_test_config.py](./jupyter_server_test_config.py). - -The default configuration will produce video for failing tests and an HTML report. - -> There is a UI mode that you may like; see [that video](https://www.youtube.com/watch?v=jF0yA-JLQW0). - -## Run the tests - -> All commands are assumed to be executed from the root directory - -To run the tests, you need to: - -1. Compile the extension: - -```sh -jlpm install -jlpm build:prod -``` - -> Check the extension is installed in JupyterLab. - -2. Install test dependencies (needed only once): - -```sh -cd ./ui-tests -jlpm install -jlpm playwright install -cd .. -``` - -3. Execute the [Playwright](https://playwright.dev/docs/intro) tests: - -```sh -cd ./ui-tests -jlpm playwright test -``` - -Test results will be shown in the terminal. In case of any test failures, the test report -will be opened in your browser at the end of the tests execution; see -[Playwright documentation](https://playwright.dev/docs/test-reporters#html-reporter) -for configuring that behavior. - -## Update the tests snapshots - -> All commands are assumed to be executed from the root directory - -If you are comparing snapshots to validate your tests, you may need to update -the reference snapshots stored in the repository. To do that, you need to: - -1. Compile the extension: - -```sh -jlpm install -jlpm build:prod -``` - -> Check the extension is installed in JupyterLab. - -2. Install test dependencies (needed only once): - -```sh -cd ./ui-tests -jlpm install -jlpm playwright install -cd .. -``` - -3. Execute the [Playwright](https://playwright.dev/docs/intro) command: - -```sh -cd ./ui-tests -jlpm playwright test -u -``` - -> Some discrepancy may occurs between the snapshots generated on your computer and -> the one generated on the CI. To ease updating the snapshots on a PR, you can -> type `please update playwright snapshots` to trigger the update by a bot on the CI. -> Once the bot has computed new snapshots, it will commit them to the PR branch. - -## Create tests - -> All commands are assumed to be executed from the root directory - -To create tests, the easiest way is to use the code generator tool of playwright: - -1. Compile the extension: - -```sh -jlpm install -jlpm build:prod -``` - -> Check the extension is installed in JupyterLab. - -2. Install test dependencies (needed only once): - -```sh -cd ./ui-tests -jlpm install -jlpm playwright install -cd .. -``` - -3. Start the server: - -```sh -cd ./ui-tests -jlpm start -``` - -4. Execute the [Playwright code generator](https://playwright.dev/docs/codegen) in **another terminal**: - -```sh -cd ./ui-tests -jlpm playwright codegen localhost:8888 -``` - -## Debug tests - -> All commands are assumed to be executed from the root directory - -To debug tests, a good way is to use the inspector tool of playwright: - -1. Compile the extension: - -```sh -jlpm install -jlpm build:prod -``` - -> Check the extension is installed in JupyterLab. - -2. Install test dependencies (needed only once): - -```sh -cd ./ui-tests -jlpm install -jlpm playwright install -cd .. -``` - -3. Execute the Playwright tests in [debug mode](https://playwright.dev/docs/debug): - -```sh -cd ./ui-tests -jlpm playwright test --debug -``` - -## Upgrade Playwright and the browsers - -To update the web browser versions, you must update the package `@playwright/test`: - -```sh -cd ./ui-tests -jlpm up "@playwright/test" -jlpm playwright install -``` diff --git a/mito-ai/ui-tests/jupyter_server_test_config.py b/mito-ai/ui-tests/jupyter_server_test_config.py deleted file mode 100644 index f2a94782a..000000000 --- a/mito-ai/ui-tests/jupyter_server_test_config.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Server configuration for integration tests. - -!! Never use this configuration in production because it -opens the server to the world and provide access to JupyterLab -JavaScript objects through the global window variable. -""" -from jupyterlab.galata import configure_jupyter_server - -configure_jupyter_server(c) - -# Uncomment to set server log level to debug level -# c.ServerApp.log_level = "DEBUG" diff --git a/mito-ai/ui-tests/package.json b/mito-ai/ui-tests/package.json deleted file mode 100644 index f0394ec04..000000000 --- a/mito-ai/ui-tests/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "mito-ai-ui-tests", - "version": "1.0.0", - "description": "JupyterLab mito-ai Integration Tests", - "private": true, - "scripts": { - "start": "jupyter lab --config jupyter_server_test_config.py", - "test": "jlpm playwright test", - "test:update": "jlpm playwright test --update-snapshots" - }, - "devDependencies": { - "@jupyterlab/galata": "^5.0.5", - "@playwright/test": "^1.37.0" - } -} diff --git a/mito-ai/ui-tests/playwright.config.js b/mito-ai/ui-tests/playwright.config.js deleted file mode 100644 index 9ece6fa11..000000000 --- a/mito-ai/ui-tests/playwright.config.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Configuration for Playwright using default from @jupyterlab/galata - */ -const baseConfig = require('@jupyterlab/galata/lib/playwright-config'); - -module.exports = { - ...baseConfig, - webServer: { - command: 'jlpm start', - url: 'http://localhost:8888/lab', - timeout: 120 * 1000, - reuseExistingServer: !process.env.CI - } -}; diff --git a/mito-ai/ui-tests/tests/mito_ai.ts b/mito-ai/ui-tests/tests/mito_ai.ts deleted file mode 100644 index 9833755a6..000000000 --- a/mito-ai/ui-tests/tests/mito_ai.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { expect, test } from '@jupyterlab/galata'; - -/** - * Don't load JupyterLab webpage before running the tests. - * This is required to ensure we capture all log messages. - */ -test.use({ autoGoto: false }); - -test('should emit an activation console message', async ({ page }) => { - const logs: string[] = []; - - page.on('console', message => { - logs.push(message.text()); - }); - - await page.goto(); - - expect( - logs.filter(s => s === 'JupyterLab extension mito-ai is activated!') - ).toHaveLength(1); -}); diff --git a/mito-ai/ui-tests/yarn.lock b/mito-ai/ui-tests/yarn.lock deleted file mode 100644 index e69de29bb..000000000 diff --git a/mito-ai/yarn.lock b/mito-ai/yarn.lock index bfcbee7d8..1d8b1c28a 100644 --- a/mito-ai/yarn.lock +++ b/mito-ai/yarn.lock @@ -1600,7 +1600,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.4.0, @codemirror/state@npm:^6.4.1": +"@codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.2.0, @codemirror/state@npm:^6.4.0, @codemirror/state@npm:^6.4.1": version: 6.4.1 resolution: "@codemirror/state@npm:6.4.1" checksum: b81b55574091349eed4d32fc0eadb0c9688f1f7c98b681318f59138ee0f527cb4c4a97831b70547c0640f02f3127647838ae6730782de4a3dd2cc58836125d01 @@ -1618,6 +1618,17 @@ __metadata: languageName: node linkType: hard +"@codemirror/view@npm:^6.9.6": + version: 6.35.0 + resolution: "@codemirror/view@npm:6.35.0" + dependencies: + "@codemirror/state": ^6.4.0 + style-mod: ^4.1.0 + w3c-keyname: ^2.2.4 + checksum: 8584d354df2147f07bb184a2443d6451db25f7a63c09644fc705c695e100042141f5162058718c495eb1c51fbab5eb37814bb72fc1ddf968e855def669a43193 + languageName: node + linkType: hard + "@csstools/css-parser-algorithms@npm:^2.3.1": version: 2.7.1 resolution: "@csstools/css-parser-algorithms@npm:2.7.1" @@ -2901,7 +2912,7 @@ __metadata: languageName: node linkType: hard -"@lumino/commands@npm:^2.3.0, @lumino/commands@npm:^2.3.1": +"@lumino/commands@npm:^2.0.0, @lumino/commands@npm:^2.3.0, @lumino/commands@npm:^2.3.1": version: 2.3.1 resolution: "@lumino/commands@npm:2.3.1" dependencies: @@ -7895,10 +7906,13 @@ __metadata: version: 0.0.0-use.local resolution: "mito-ai@workspace:." dependencies: + "@codemirror/state": ^6.2.0 + "@codemirror/view": ^6.9.6 "@jupyterlab/application": ^4.2.4 "@jupyterlab/apputils": ^4.3.4 "@jupyterlab/builder": ^4.0.0 "@jupyterlab/testutils": ^4.0.0 + "@lumino/commands": ^2.0.0 "@lumino/widgets": ^2.5.0 "@types/jest": ^29.2.0 "@types/json-schema": ^7.0.11 diff --git a/tests/mitoai_ui_tests/mitoAdvice.spec.ts b/tests/mitoai_ui_tests/mitoAdvice.spec.ts new file mode 100644 index 000000000..d186c53fc --- /dev/null +++ b/tests/mitoai_ui_tests/mitoAdvice.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from '@jupyterlab/galata'; + +test('should display an advice message in empty code cell', async ({ + page +}) => { + await page.notebook.createNew(); + await page.notebook.enterCellEditingMode(0); + + // Should display the advice message by default + await expect + .soft((await page.notebook.getCellLocator(0))!.getByRole('textbox')) + .toHaveText( + 'Start writing python or Press Ctrl + E to ask Mito AI to write code for you.' + ); + + await page.notebook.setCell(0, 'code', 'print("Hello, World!")'); + + await expect + .soft((await page.notebook.getCellLocator(0))!.getByRole('textbox')) + .toHaveText('print("Hello, World!")'); + + await page.notebook.enterCellEditingMode(0); + await page.keyboard.press('ControlOrMeta+a'); + await page.keyboard.press('Backspace'); + + // Should display the advice message if cell content is erased + await expect + .soft((await page.notebook.getCellLocator(0))!.getByRole('textbox')) + .toHaveText( + 'Start writing python or Press Ctrl + E to ask Mito AI to write code for you.' + ); + + await page.keyboard.press('ControlOrMeta+e'); + // Should open the Mito AI chat tab + expect(await page.sidebar.isTabOpen('mito_ai')).toEqual(true); +}); + +test('should display an advice message in empty markdown cell', async ({ + page +}) => { + await page.notebook.createNew(); + await page.notebook.setCellType(0, 'markdown'); + await page.notebook.enterCellEditingMode(0); + + // Should display the advice message by default + await expect + .soft((await page.notebook.getCellLocator(0))!.getByRole('textbox')) + .toHaveText('Start writing markdown.'); + + await page.notebook.setCell(0, 'markdown', '# Hello World'); + + await expect + .soft((await page.notebook.getCellLocator(0))!.getByRole('textbox')) + .toHaveText('# Hello World'); + + await page.notebook.enterCellEditingMode(0); + await page.keyboard.press('ControlOrMeta+a'); + await page.keyboard.press('Backspace'); + + // Should display the advice message if cell content is erased + await expect((await page.notebook.getCellLocator(0))!.getByRole('textbox')) + .toHaveText('Start writing markdown.'); +}); + +test('should not display an advice message in raw cell', async ({ page }) => { + await page.notebook.createNew(); + await page.notebook.setCellType(0, 'raw'); + await page.notebook.enterCellEditingMode(0); + + // Should display the advice message by default + await expect( + (await page.notebook.getCellLocator(0))!.getByRole('textbox') + ).toHaveText(''); +});