Skip to content

Commit

Permalink
Experimental inline completer (jupyterlab#15160)
Browse files Browse the repository at this point in the history
* Early WIP for inline completion provider API

* Improve ghost text behaviour, widget, and tokens

* Improve inline completer hoverbox widget

* Implement accept and invoke

* Fix shortcuts assignment

* Split up ghost to separate file, improve ghost and widget

* Improve appending and filter text logic, move protected method

* Add `readonly` keyword to identifier/name

Co-authored-by: Frédéric Collonval <[email protected]>

* Remove `Omit<>`, improve documentation

* Expose provider ID on the ghost text widget

This is based on the observation that various providers
have different ways of styling the ghost text, for example
by adding brand-specific background; to enable providers
to style the ghost text specifically this commit adds
a data attribute on the ghost text widget.

* Avoid using `Omit<>` for factory, add readonly on `completions`

* Check for items (fix for no kernel)

* Add provider icon

* Implement settings, rearrange plugins

* Make `inlineProviders` optional in options for backward compatibility

* Use search history request with limit of 100 cells

* Add icons to settings

* Polish completer widget icon

* Implement settings for inline completion providers

* Initial streaming implementation

* Implement streaming indicator, typing and streaming animations

* Implement setting for streaming animation

* Expose mime type in request

* Fix hiding stream on cell change & not showing if 1st provider yields empty

* Wrap lines (while preserving indentation), useful in markdown

* Revert pre-wrap as it introduces jitter on streaming

* Better blur handling

* Hide inline completions on cursor movement

* Implement on hover display of widget

* Show new line immediately when streaming

* Implement per-provider timeout and debouncer

* Restore `pre-wrap`, but exclude the streaming token itself

* Integrity fix

* Fix invoke command

* Add unit tests for inline compelter

* Implement progress bar for multiple providers

* Automatic application of license header

* Remove `const` as `enum const` breaks building tests separately

Though ideally it would be `enum cosnt`

* Fix initial defaults composition for providers

* `onHover`: allow clicking past widget when hidden

* Add visual/integration tests

* Use `oneOf` instead of `enum` to enable translations

* Update Playwright Snapshots

* Add missing `await`

* Add user-facing and developer-facing docs

* Remove LSP provider declaration for now

Co-authored-by: Frédéric Collonval <[email protected]>

* Fix icon styling in dark theme

---------

Co-authored-by: Frédéric Collonval <[email protected]>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Oct 31, 2023
1 parent b835dd9 commit aa117ca
Show file tree
Hide file tree
Showing 38 changed files with 3,275 additions and 93 deletions.
97 changes: 97 additions & 0 deletions docs/source/extension/extension_points.rst
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,103 @@ If you are adding your own activities to JupyterLab, you might consider providin
a ``WidgetTracker`` token of your own, so that other extensions can make use of it.
Completion Providers
--------------------
Both code completer and inline completer can be extended by registering
an (inline) completion provider on the completion manager provided by
the ``ICompletionProviderManager`` token.
Code Completer
^^^^^^^^^^^^^^
A minimal code completion provider needs to implement the `fetch` and `isApplicable`
methods, and define a unique `identifier` property, but the ``ICompletionProvider``
interface allows for much more extensive customization of the completer.
.. code:: typescript
import { ICompletionProviderManager, ICompletionProvider } from '@jupyterlab/completer';
class MyProvider implements ICompletionProvider {
readonly identifier = 'my-provider';
async isApplicable(context) {
return true;
}
async fetch(request, context) {
return {
start: request.offset,
end: request.offset,
items: [
{ label: 'option 1' },
{ label: 'option 2' }
]
};
}
}
const plugin: JupyterFrontEndPlugin<void> = {
id: 'my-completer-extension:provider',
autoStart: true,
requires: [ICompletionProviderManager],
activate: (app: JupyterFrontEnd, manager: ICompletionProviderManager): void => {
const provider = new MyProvider();
manager.registerProvider(provider);
}
};
A more detailed example is provided in the `extension-examples <https://github.com/jupyterlab/extension-examples/tree/main/completer>`__ repository.
For an example of an extensively customised completion provider, see the
`jupyterlab-lsp <https://github.com/jupyter-lsp/jupyterlab-lsp>`__ extension.
Inline Completer
^^^^^^^^^^^^^^^^
.. versionadded::4.1
Experimental Inline Completion API was added in JupyterLab 4.1.
We welcome feedback on making it better for extension authors.
A minimal inline completion provider extension would only implement the
required method `fetch` and define `identifier` and `name` properties,
but a number of additional fields can be used for enhanced functionality,
such as streaming, see the ``IInlineCompletionProvider`` documentation.
.. code:: typescript
import { ICompletionProviderManager, IInlineCompletionProvider } from '@jupyterlab/completer';
class MyInlineProvider implements IInlineCompletionProvider {
readonly identifier = 'my-provider';
readonly name = 'My provider';
async fetch(request, context) {
return {
items: [
{ insertText: 'suggestion 1' },
{ insertText: 'suggestion 2' }
]
};
}
}
const plugin: JupyterFrontEndPlugin<void> = {
id: 'my-completer-extension:inline-provider',
autoStart: true,
requires: [ICompletionProviderManager],
activate: (app: JupyterFrontEnd, manager: ICompletionProviderManager): void => {
const provider = new MyInlineProvider();
manager.registerInlineProvider(provider);
}
};
For an example of an inline completion provider with streaming support, see
`jupyterlab-transformers-completer <https://github.com/krassowski/jupyterlab-transformers-completer>`__.
State Database
--------------
Expand Down
51 changes: 51 additions & 0 deletions docs/source/user/completer.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
.. Copyright (c) Jupyter Development Team.
.. Distributed under the terms of the Modified BSD License.
.. _completer:

Completer
=========

Two completer implementations are available in JupyterLab: code completer for tab-completion,
and inline completer for inline (as-you-type) suggestions.

Both the code completer and inline completer can present completions from third-party
providers when extensions with relevant (inline) completion providers are installed.

Code completer widget
---------------------

The code completer widget can be activated by pressing :kbd:`Tab` in a non-empty line of a code cell.

To cycle completion candidates use:
- :kbd:`Up`/:kbd:`Down` arrow keys or :kbd:`Tab`/:kbd:`Shift`+:kbd:`Shift` for cycling one item at a time
- :kbd:`Page Up`/:kbd:`Page Down` keys for jumping over multiple items at once

To accept the active completion candidate pressing :kbd:`Enter`, or click on it with your mouse/pointer.

By default the completions will include the symbols ("tokens") from the current editor ("context"),
and any suggestions returned by the active kernel in response to ``complete_request`` message.
You may be able to improve the relevance of completion suggestions by adjusting the configuration
of the kernel of your choice.

Documentation panel
^^^^^^^^^^^^^^^^^^^

The documentation panel presents additional information about the completion candidate.
It can be enabled in Code Completer settings. By default this panel sends ``inspect_request``
to the active kernel and is therefore only available in notebooks and other documents
with active session connected to a kernel that supports inspections.

Inline completer
----------------

JupyterLab 4.1+ includes an experimental inline completer, showing the suggestions
as greyed out "ghost" text. Compared to the completer widget, the inline completer:

- can present multi-line completions
- is automatically invoked as you type
- does not offer additional information such as type of documentation for the suggestions
- can provide completions in both code and markdown cells (the default history provider only suggests in code cells)

The inline completer is disabled by default and can be enabled in the Settings Editor
by enabling the History Provider.
1 change: 1 addition & 0 deletions docs/source/user/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ files
file_editor
notebook
code_console
completer
terminal
running
commands
Expand Down
152 changes: 152 additions & 0 deletions galata/test/jupyterlab/inline-completer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.

import { expect, galata, test } from '@jupyterlab/galata';

const fileName = 'notebook.ipynb';
const COMPLETER_SELECTOR = '.jp-InlineCompleter';
const GHOST_SELECTOR = '.jp-GhostText';
const PLUGIN_ID = '@jupyterlab/completer-extension:inline-completer';

const SHARED_SETTINGS = {
providers: {
'@jupyterlab/inline-completer:history': {
enabled: true
}
}
};

test.describe('Inline Completer', () => {
test.beforeEach(async ({ page }) => {
await page.notebook.createNew(fileName);
await page.notebook.setCell(0, 'code', 'suggestion_1 = 1');
await page.notebook.addCell('code', 'suggestion_2 = 2');
await page.notebook.addCell('code', 's');
await page.notebook.runCell(0, true);
await page.notebook.runCell(1, true);
await page.notebook.enterCellEditingMode(2);
// we need to wait until the completer gets bound to the cell after entering it
await page.waitForTimeout(50);
});

test.describe('Widget "onHover", shortcuts on', () => {
test.use({
mockSettings: {
...galata.DEFAULT_SETTINGS,
[PLUGIN_ID]: {
showWidget: 'onHover',
showShortcuts: true,
...SHARED_SETTINGS
}
}
});

test('Widget shows up on hover', async ({ page }) => {
await page.keyboard.press('u');

// Hover
const ghostText = page.locator(GHOST_SELECTOR);
await ghostText.waitFor();
await ghostText.hover();

// Widget shows up
const completer = page.locator(COMPLETER_SELECTOR);
await completer.waitFor();

// Wait for full opacity
await page.waitForTimeout(100);

const imageName = 'inline-completer-shortcuts-on.png';
expect(await completer.screenshot()).toMatchSnapshot(imageName);

// Should hide on moving cursor away
const toolbar = await page.notebook.getToolbar();
await toolbar.hover();
await completer.waitFor({ state: 'hidden' });
});
});

test.describe('Widget "always", shortcuts off', () => {
test.use({
mockSettings: {
...galata.DEFAULT_SETTINGS,
[PLUGIN_ID]: {
showWidget: 'always',
showShortcuts: false,
...SHARED_SETTINGS
}
}
});

test('Widget shows up on typing, hides on blur', async ({ page }) => {
await page.keyboard.press('u');

// Widget shows up
const completer = page.locator(COMPLETER_SELECTOR);
await completer.waitFor();

const imageName = 'inline-completer-shortcuts-off.png';
expect(await completer.screenshot()).toMatchSnapshot(imageName);

// Should hide on blur
await page.keyboard.press('Escape');
await page.waitForTimeout(50);
await expect(completer).toBeHidden();
});

test('Focusing on widget does not hide it', async ({ page }) => {
await page.keyboard.press('u');
const completer = page.locator(COMPLETER_SELECTOR);
await completer.waitFor();

// Focusing or clicking should not hide
await completer.focus();
await completer.click();
await page.waitForTimeout(100);
await expect(completer).toBeVisible();
});

test('Shows up on invoke command', async ({ page }) => {
await page.evaluate(async () => {
await window.jupyterapp.commands.execute('inline-completer:invoke');
});

// Widget shows up
const completer = page.locator(COMPLETER_SELECTOR);
await completer.waitFor();
});
});

test.describe('Ghost text', () => {
test.use({
mockSettings: {
...galata.DEFAULT_SETTINGS,
[PLUGIN_ID]: {
showWidget: 'never',
...SHARED_SETTINGS
}
}
});

test('Ghost text updates on typing', async ({ page }) => {
await page.keyboard.press('u');

// Ghost text shows up
const ghostText = page.locator(GHOST_SELECTOR);
await ghostText.waitFor();

// Ghost text should be updated from "ggestion" to "estion"
await page.keyboard.type('gg');
await expect(ghostText).toHaveText(/estion.*/);

const cellEditor = await page.notebook.getCellInput(2);
const imageName = 'editor-with-ghost-text.png';
expect(await cellEditor.screenshot()).toMatchSnapshot(imageName);

// Ghost text should hide
await page.keyboard.press('Escape');
await page.waitForTimeout(50);
await expect(ghostText).toBeHidden();
});
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions packages/completer-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@
"@jupyterlab/application": "^4.1.0-alpha.2",
"@jupyterlab/completer": "^4.1.0-alpha.2",
"@jupyterlab/settingregistry": "^4.1.0-alpha.2",
"@jupyterlab/translation": "^4.1.0-alpha.2",
"@jupyterlab/ui-components": "^4.1.0-alpha.2",
"@lumino/commands": "^2.1.3",
"@lumino/coreutils": "^2.1.2",
"@rjsf/utils": "^5.13.2",
"react": "^18.2.0"
Expand Down
64 changes: 64 additions & 0 deletions packages/completer-extension/schema/inline-completer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"title": "Inline Completer",
"description": "Inline completer settings.",
"jupyter.lab.setting-icon": "completer:inline",
"jupyter.lab.setting-icon-label": "Inline Completer",
"jupyter.lab.transform": true,
"jupyter.lab.shortcuts": [
{
"command": "inline-completer:next",
"keys": ["Alt ]"],
"selector": ".jp-mod-completer-enabled"
},
{
"command": "inline-completer:previous",
"keys": ["Alt ["],
"selector": ".jp-mod-completer-enabled"
},
{
"command": "inline-completer:accept",
"keys": ["Alt End"],
"selector": ".jp-mod-completer-enabled"
},
{
"command": "inline-completer:invoke",
"keys": ["Alt \\"],
"selector": ".jp-mod-completer-enabled"
}
],
"properties": {
"providers": {
"title": "Inline completion providers",
"type": "object"
},
"showWidget": {
"title": "Show widget",
"description": "When to show the inline completer widget.",
"type": "string",
"oneOf": [
{ "const": "always", "title": "Always" },
{ "const": "onHover", "title": "On hover" },
{ "const": "never", "title": "Never" }
],
"default": "onHover"
},
"showShortcuts": {
"title": "Show shortcuts in the widget",
"description": "Whether to show shortcuts in the inline completer widget.",
"type": "boolean",
"default": true
},
"streamingAnimation": {
"title": "Streaming animation",
"description": "Transition effect used when streaming tokens from model.",
"type": "string",
"oneOf": [
{ "const": "none", "title": "None" },
{ "const": "uncover", "title": "Uncover" }
],
"default": "uncover"
}
},
"additionalProperties": false,
"type": "object"
}
Loading

0 comments on commit aa117ca

Please sign in to comment.