瀏覽代碼

Merge pull request #8080 from kiteco/completer-items-refactor

Refactor Completer API
Saul Shanabrook 5 年之前
父節點
當前提交
a51f9da32e

+ 4 - 1
packages/completer-extension/src/index.ts

@@ -93,7 +93,10 @@ const manager: JupyterFrontEndPlugin<ICompletionManager> = {
         const { connector, editor, parent } = completable;
         const model = new CompleterModel();
         const completer = new Completer({ editor, model });
-        const handler = new CompletionHandler({ completer, connector });
+        const handler = new CompletionHandler({
+          completer,
+          connector
+        });
         const id = parent.id;
 
         // Hide the widget when it first loads.

+ 1 - 0
packages/completer/package.json

@@ -45,6 +45,7 @@
     "@jupyterlab/coreutils": "^4.1.0",
     "@jupyterlab/services": "^5.1.0",
     "@jupyterlab/statedb": "^2.1.0",
+    "@jupyterlab/ui-components": "^2.1.0",
     "@lumino/algorithm": "^1.2.3",
     "@lumino/coreutils": "^1.4.2",
     "@lumino/disposable": "^1.3.5",

+ 23 - 0
packages/completer/src/dummyconnector.ts

@@ -0,0 +1,23 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import { DataConnector } from '@jupyterlab/statedb';
+
+import { CompletionHandler } from './handler';
+
+/**
+ * DummyConnector's fetch method always returns a rejected Promise.
+ * This class is only instantiated if both CompletionHandler._connector and
+ * CompletionHandler._fetchItems are undefined.
+ */
+export class DummyConnector extends DataConnector<
+  CompletionHandler.IReply,
+  void,
+  CompletionHandler.IRequest
+> {
+  fetch(_: CompletionHandler.IRequest): Promise<CompletionHandler.IReply> {
+    return Promise.reject(
+      'Attempting to fetch with DummyConnector. Please ensure connector responseType is set.'
+    );
+  }
+}

+ 199 - 35
packages/completer/src/handler.ts

@@ -5,6 +5,8 @@ import { CodeEditor } from '@jupyterlab/codeeditor';
 
 import { Text } from '@jupyterlab/coreutils';
 
+import { LabIcon } from '@jupyterlab/ui-components';
+
 import { IDataConnector } from '@jupyterlab/statedb';
 
 import { ReadonlyJSONObject, JSONObject, JSONArray } from '@lumino/coreutils';
@@ -16,6 +18,7 @@ import { Message, MessageLoop } from '@lumino/messaging';
 import { Signal } from '@lumino/signaling';
 
 import { Completer } from './widget';
+import { DummyConnector } from './dummyconnector';
 
 /**
  * A class added to editors that can host a completer.
@@ -59,7 +62,14 @@ export class CompletionHandler implements IDisposable {
     void,
     CompletionHandler.IRequest
   > {
-    return this._connector;
+    if ('responseType' in this._connector) {
+      return new DummyConnector();
+    }
+    return this._connector as IDataConnector<
+      CompletionHandler.IReply,
+      void,
+      CompletionHandler.IRequest
+    >;
   }
   set connector(
     connector: IDataConnector<
@@ -349,34 +359,87 @@ export class CompletionHandler implements IDisposable {
     const state = this.getState(editor, position);
     const request: CompletionHandler.IRequest = { text, offset };
 
+    if (this._isICompletionItemsConnector(this._connector)) {
+      return this._connector
+        .fetch(request)
+        .then(reply => {
+          this._validate(pending, request);
+          if (!reply) {
+            throw new Error(`Invalid request: ${request}`);
+          }
+
+          this._onFetchItemsReply(state, reply);
+        })
+        .catch(_ => {
+          this._onFailure();
+        });
+    }
+
     return this._connector
       .fetch(request)
       .then(reply => {
-        if (this.isDisposed) {
-          throw new Error('Handler is disposed');
-        }
-
-        // If a newer completion request has created a pending request, bail.
-        if (pending !== this._pending) {
-          throw new Error('A newer completion request is pending');
-        }
-
+        this._validate(pending, request);
         if (!reply) {
           throw new Error(`Invalid request: ${request}`);
         }
 
         this._onReply(state, reply);
       })
-      .catch(reason => {
-        // Completion request failures or negative results fail silently.
-        const model = this.completer.model;
-
-        if (model) {
-          model.reset(true);
-        }
+      .catch(_ => {
+        this._onFailure();
       });
   }
 
+  private _isICompletionItemsConnector(
+    connector:
+      | IDataConnector<
+          CompletionHandler.IReply,
+          void,
+          CompletionHandler.IRequest
+        >
+      | CompletionHandler.ICompletionItemsConnector
+  ): connector is CompletionHandler.ICompletionItemsConnector {
+    return (
+      (connector as CompletionHandler.ICompletionItemsConnector)
+        .responseType === CompletionHandler.ICompletionItemsResponseType
+    );
+  }
+
+  private _validate(pending: number, request: CompletionHandler.IRequest) {
+    if (this.isDisposed) {
+      throw new Error('Handler is disposed');
+    }
+    // If a newer completion request has created a pending request, bail.
+    if (pending !== this._pending) {
+      throw new Error('A newer completion request is pending');
+    }
+  }
+
+  /**
+   * Updates model with text state and current cursor position.
+   */
+  private _updateModel(
+    state: Completer.ITextState,
+    start: number,
+    end: number
+  ): Completer.IModel | null {
+    const model = this.completer.model;
+    const text = state.text;
+
+    if (!model) {
+      return null;
+    }
+
+    // Update the original request.
+    model.original = state;
+    // Update the cursor.
+    model.cursor = {
+      start: Text.charIndexToJsIndex(start, text),
+      end: Text.charIndexToJsIndex(end, text)
+    };
+    return model;
+  }
+
   /**
    * Receive a completion reply from the connector.
    *
@@ -388,16 +451,11 @@ export class CompletionHandler implements IDisposable {
     state: Completer.ITextState,
     reply: CompletionHandler.IReply
   ): void {
-    const model = this.completer.model;
-    const text = state.text;
-
+    const model = this._updateModel(state, reply.start, reply.end);
     if (!model) {
       return;
     }
 
-    // Update the original request.
-    model.original = state;
-
     // Dedupe the matches.
     const matches: string[] = [];
     const matchSet = new Set(reply.matches || []);
@@ -434,19 +492,42 @@ export class CompletionHandler implements IDisposable {
 
     // Update the options, including the type map.
     model.setOptions(matches, typeMap);
+  }
 
-    // Update the cursor.
-    model.cursor = {
-      start: Text.charIndexToJsIndex(reply.start, text),
-      end: Text.charIndexToJsIndex(reply.end, text)
-    };
+  /**
+   * Receive completion items from provider.
+   *
+   * @param state - The state of the editor when completion request was made.
+   *
+   * @param reply - The API response returned for a completion request.
+   */
+  private _onFetchItemsReply(
+    state: Completer.ITextState,
+    reply: CompletionHandler.ICompletionItemsReply
+  ) {
+    const model = this._updateModel(state, reply.start, reply.end);
+    if (!model) {
+      return;
+    }
+    if (model.setCompletionItems) {
+      model.setCompletionItems(reply.items);
+    }
   }
 
-  private _connector: IDataConnector<
-    CompletionHandler.IReply,
-    void,
-    CompletionHandler.IRequest
-  >;
+  /**
+   * If completion request fails, reset model and fail silently.
+   */
+  private _onFailure() {
+    const model = this.completer.model;
+
+    if (model) {
+      model.reset(true);
+    }
+  }
+
+  private _connector:
+    | IDataConnector<CompletionHandler.IReply, void, CompletionHandler.IRequest>
+    | CompletionHandler.ICompletionItemsConnector;
   private _editor: CodeEditor.IEditor | null = null;
   private _enabled = false;
   private _pending = 0;
@@ -468,15 +549,98 @@ export namespace CompletionHandler {
 
     /**
      * The data connector used to populate completion requests.
-     *
+     * Use the connector with ICompletionItemsReply for enhanced completions.
      * #### Notes
      * The only method of this connector that will ever be called is `fetch`, so
      * it is acceptable for the other methods to be simple functions that return
      * rejected promises.
      */
-    connector: IDataConnector<IReply, void, IRequest>;
+    connector:
+      | IDataConnector<IReply, void, IRequest>
+      | CompletionHandler.ICompletionItemsConnector;
+  }
+
+  /**
+   * Type alias for ICompletionItem list.
+   * Implementers of this interface should be responsible for
+   * deduping and sorting the items in the list.
+   */
+  export type ICompletionItems = ReadonlyArray<ICompletionItem>;
+
+  /**
+   * Completion item object based off of LSP CompletionItem.
+   * Compared to the old kernel completions interface, this enhances the completions UI to support:
+   * - differentiation between inserted text and user facing text
+   * - documentation for each completion item to be displayed adjacently
+   * - deprecation styling
+   * - custom icons
+   * and other potential new features.
+   */
+  export interface ICompletionItem {
+    /**
+     * User facing completion.
+     * If insertText is not set, this will be inserted.
+     */
+    label: string;
+
+    /**
+     * Completion to be inserted.
+     */
+    insertText?: string;
+
+    /**
+     * Type of this completion item.
+     */
+    type?: string;
+
+    /**
+     * LabIcon object for icon to be rendered with completion type.
+     */
+    icon?: LabIcon;
+
+    /**
+     * A human-readable string with additional information
+     * about this item, like type or symbol information.
+     */
+    documentation?: string;
+
+    /**
+     * Indicates if the item is deprecated.
+     */
+    deprecated?: boolean;
+  }
+
+  export type ICompletionItemsConnector = IDataConnector<
+    CompletionHandler.ICompletionItemsReply,
+    void,
+    CompletionHandler.IRequest
+  > &
+    CompletionHandler.ICompleterConnecterResponseType;
+
+  /**
+   * A reply to a completion items fetch request.
+   */
+  export interface ICompletionItemsReply {
+    /**
+     * The starting index for the substring being replaced by completion.
+     */
+    start: number;
+    /**
+     * The end index for the substring being replaced by completion.
+     */
+    end: number;
+    /**
+     * A list of completion items.
+     */
+    items: CompletionHandler.ICompletionItems;
   }
 
+  export interface ICompleterConnecterResponseType {
+    responseType: typeof ICompletionItemsResponseType;
+  }
+
+  export const ICompletionItemsResponseType = 'ICompletionItemsReply' as const;
+
   /**
    * A reply to a completion request.
    */

+ 105 - 1
packages/completer/src/model.ts

@@ -9,12 +9,13 @@ import {
   toArray
 } from '@lumino/algorithm';
 
-import { JSONExt } from '@lumino/coreutils';
+import { JSONExt, ReadonlyPartialJSONArray } from '@lumino/coreutils';
 
 import { StringExt } from '@lumino/algorithm';
 
 import { ISignal, Signal } from '@lumino/signaling';
 
+import { CompletionHandler } from './handler';
 import { Completer } from './widget';
 
 /**
@@ -165,6 +166,40 @@ export class CompleterModel implements Completer.IModel {
     Signal.clearData(this);
   }
 
+  /**
+   * The list of visible items in the completer menu.
+   *
+   * #### Notes
+   * This is a read-only property.
+   */
+  completionItems?(): CompletionHandler.ICompletionItems {
+    let query = this._query;
+    if (query) {
+      return this._markup(query);
+    }
+    return this._completionItems;
+  }
+
+  /**
+   * Set the list of visible items in the completer menu, and append any
+   * new types to KNOWN_TYPES.
+   */
+  setCompletionItems?(newValue: CompletionHandler.ICompletionItems): void {
+    if (
+      JSONExt.deepEqual(
+        (newValue as unknown) as ReadonlyPartialJSONArray,
+        (this._completionItems as unknown) as ReadonlyPartialJSONArray
+      )
+    ) {
+      return;
+    }
+    this._completionItems = newValue;
+    this._orderedTypes = Private.findOrderedCompletionItemTypes(
+      this._completionItems
+    );
+    this._stateChanged.emit(undefined);
+  }
+
   /**
    * The list of visible items in the completer menu.
    *
@@ -355,6 +390,44 @@ export class CompleterModel implements Completer.IModel {
     this._stateChanged.emit(undefined);
   }
 
+  /**
+   * Check if CompletionItem matches against query.
+   * Highlight matching prefix by adding <mark> tags.
+   */
+  private _markup(query: string): CompletionHandler.ICompletionItems {
+    const items = this._completionItems;
+    let results: CompletionHandler.ICompletionItem[] = [];
+    for (let item of items) {
+      // See if label matches query string
+      // With ICompletionItems, the label may include parameters, so we exclude them from the matcher.
+      // e.g. Given label `foo(b, a, r)` and query `bar`,
+      // don't count parameters, `b`, `a`, and `r` as matches.
+      const index = item.label.indexOf('(');
+      const prefix = index > -1 ? item.label.substring(0, index) : item.label;
+      let match = StringExt.matchSumOfSquares(prefix, query);
+      // Filter non-matching items.
+      if (match) {
+        // Highlight label text if there's a match
+        let marked = StringExt.highlight(
+          item.label,
+          match.indices,
+          Private.mark
+        );
+        results.push({
+          label: marked.join(''),
+          // If no insertText is present, preserve original label value
+          // by setting it as the insertText.
+          insertText: item.insertText ? item.insertText : item.label,
+          type: item.type,
+          icon: item.icon,
+          documentation: item.documentation,
+          deprecated: item.deprecated
+        });
+      }
+    }
+    return results;
+  }
+
   /**
    * Apply the query to the complete options list to return the matching subset.
    */
@@ -388,6 +461,7 @@ export class CompleterModel implements Completer.IModel {
   private _reset(): void {
     this._current = null;
     this._cursor = null;
+    this._completionItems = [];
     this._options = [];
     this._original = null;
     this._query = '';
@@ -399,6 +473,7 @@ export class CompleterModel implements Completer.IModel {
   private _current: Completer.ITextState | null = null;
   private _cursor: Completer.ICursorSpan | null = null;
   private _isDisposed = false;
+  private _completionItems: CompletionHandler.ICompletionItems = [];
   private _options: string[] = [];
   private _original: Completer.ITextState | null = null;
   private _query = '';
@@ -469,6 +544,35 @@ namespace Private {
     return a.raw.localeCompare(b.raw);
   }
 
+  /**
+   * Compute a reliably ordered list of types for ICompletionItems.
+   *
+   * #### Notes
+   * The resulting list always begins with the known types:
+   * ```
+   * ['function', 'instance', 'class', 'module', 'keyword']
+   * ```
+   * followed by other types in alphabetical order.
+   *
+   */
+  export function findOrderedCompletionItemTypes(
+    items: CompletionHandler.ICompletionItems
+  ): string[] {
+    const newTypeSet = new Set<string>();
+    items.forEach(item => {
+      if (
+        item.type &&
+        !KNOWN_TYPES.includes(item.type) &&
+        !newTypeSet.has(item.type!)
+      ) {
+        newTypeSet.add(item.type!);
+      }
+    });
+    const newTypes = Array.from(newTypeSet);
+    newTypes.sort((a, b) => a.localeCompare(b));
+    return KNOWN_TYPES.concat(newTypes);
+  }
+
   /**
    * Compute a reliably ordered list of types.
    *

+ 8 - 5
packages/completer/src/tokens.ts

@@ -49,12 +49,15 @@ export namespace ICompletionManager {
 
     /**
      * The data connector used to populate the completer.
+     * Use the connector with ICompletionItemsReply for enhanced completions.
      */
-    connector: IDataConnector<
-      CompletionHandler.IReply,
-      void,
-      CompletionHandler.IRequest
-    >;
+    connector:
+      | IDataConnector<
+          CompletionHandler.IReply,
+          void,
+          CompletionHandler.IRequest
+        >
+      | CompletionHandler.ICompletionItemsConnector;
   }
 
   /**

+ 152 - 27
packages/completer/src/widget.ts

@@ -19,6 +19,8 @@ import { ISignal, Signal } from '@lumino/signaling';
 
 import { Widget } from '@lumino/widgets';
 
+import { CompletionHandler } from './handler';
+
 /**
  * The class name added to completer menu items.
  */
@@ -225,6 +227,84 @@ export class Completer extends Widget {
       return;
     }
 
+    let node: HTMLElement | null = null;
+    let completionItemList = model.completionItems && model.completionItems();
+    if (completionItemList && completionItemList.length) {
+      node = this._createCompletionItemNode(model, completionItemList);
+    } else {
+      node = this._createIItemNode(model);
+    }
+    if (!node) {
+      return;
+    }
+
+    let active = node.querySelectorAll(`.${ITEM_CLASS}`)[this._activeIndex];
+    active.classList.add(ACTIVE_CLASS);
+
+    // If this is the first time the current completer session has loaded,
+    // populate any initial subset match.
+    if (!model.query) {
+      const populated = this._populateSubset();
+      if (populated) {
+        this.update();
+        return;
+      }
+    }
+
+    if (this.isHidden) {
+      this.show();
+      this._setGeometry();
+      this._visibilityChanged.emit(undefined);
+    } else {
+      this._setGeometry();
+    }
+  }
+
+  private _createCompletionItemNode(
+    model: Completer.IModel,
+    items: CompletionHandler.ICompletionItems
+  ): HTMLElement | null {
+    // If there are no items, reset and bail.
+    if (!items.length) {
+      this._resetFlag = true;
+      this.reset();
+      if (!this.isHidden) {
+        this.hide();
+        this._visibilityChanged.emit(undefined);
+      }
+      return null;
+    }
+
+    // If there is only one option, signal and bail.
+    // We don't test the filtered `items`, as that
+    // is too aggressive of completer behavior, it can
+    // lead to double typing of an option.
+    if (items.length === 1) {
+      this._selected.emit(items[0].insertText || items[0].label);
+      this.reset();
+      return null;
+    }
+
+    // Clear the node.
+    let node = this.node;
+    node.textContent = '';
+
+    // Compute an ordered list of all the types in the typeMap, this is computed
+    // once by the model each time new data arrives for efficiency.
+    let orderedTypes = model.orderedTypes();
+
+    // Populate the completer items.
+    for (let item of items) {
+      if (!this._renderer.createCompletionItemNode) {
+        return null;
+      }
+      let li = this._renderer.createCompletionItemNode(item, orderedTypes);
+      node.appendChild(li);
+    }
+    return node;
+  }
+
+  private _createIItemNode(model: Completer.IModel): HTMLElement | null {
     const items = toArray(model.items());
 
     // If there are no items, reset and bail.
@@ -235,7 +315,7 @@ export class Completer extends Widget {
         this.hide();
         this._visibilityChanged.emit(undefined);
       }
-      return;
+      return null;
     }
 
     // If there is only one option, signal and bail.
@@ -246,7 +326,7 @@ export class Completer extends Widget {
     if (options.length === 1) {
       this._selected.emit(options[0]);
       this.reset();
-      return;
+      return null;
     }
 
     // Clear the node.
@@ -266,27 +346,7 @@ export class Completer extends Widget {
       );
       node.appendChild(li);
     }
-
-    const active = node.querySelectorAll(`.${ITEM_CLASS}`)[this._activeIndex];
-    active.classList.add(ACTIVE_CLASS);
-
-    // If this is the first time the current completer session has loaded,
-    // populate any initial subset match.
-    if (!model.query) {
-      const populated = this._populateSubset();
-      if (populated) {
-        this.update();
-        return;
-      }
-    }
-
-    if (this.isHidden) {
-      this.show();
-      this._setGeometry();
-      this._visibilityChanged.emit(undefined);
-    } else {
-      this._setGeometry();
-    }
+    return node;
   }
 
   /**
@@ -605,6 +665,16 @@ export namespace Completer {
      */
     query: string;
 
+    /**
+     * Get the list of visible CompletionItems in the completer menu.
+     */
+    completionItems?(): CompletionHandler.ICompletionItems;
+
+    /**
+     * Set the list of visible CompletionItems in the completer menu.
+     */
+    setCompletionItems?(items: CompletionHandler.ICompletionItems): void;
+
     /**
      * Get the of visible items in the completer menu.
      */
@@ -711,6 +781,15 @@ export namespace Completer {
    * A renderer for completer widget nodes.
    */
   export interface IRenderer {
+    /**
+     * Create an item node (an `li` element)  from a ICompletionItem
+     * for a text completer menu.
+     */
+    createCompletionItemNode?(
+      item: CompletionHandler.ICompletionItem,
+      orderedTypes: string[]
+    ): HTMLLIElement;
+
     /**
      * Create an item node (an `li` element) for a text completer menu.
      */
@@ -725,6 +804,22 @@ export namespace Completer {
    * The default implementation of an `IRenderer`.
    */
   export class Renderer implements IRenderer {
+    /**
+     * Create an item node from an ICompletionItem for a text completer menu.
+     */
+    createCompletionItemNode(
+      item: CompletionHandler.ICompletionItem,
+      orderedTypes: string[]
+    ): HTMLLIElement {
+      return this._constructNode(
+        this._createBaseNode(item.insertText || item.label),
+        this._createMatchNode(item.label),
+        !!item.type,
+        item.type,
+        orderedTypes
+      );
+    }
+
     /**
      * Create an item node for a text completer menu.
      */
@@ -733,22 +828,52 @@ export namespace Completer {
       typeMap: TypeMap,
       orderedTypes: string[]
     ): HTMLLIElement {
+      return this._constructNode(
+        this._createBaseNode(item.raw),
+        this._createMatchNode(item.text),
+        !JSONExt.deepEqual(typeMap, {}),
+        typeMap[item.raw] || '',
+        orderedTypes
+      );
+    }
+
+    /**
+     * Create base node with the value to be inserted
+     */
+    private _createBaseNode(value: string): HTMLLIElement {
       const li = document.createElement('li');
       li.className = ITEM_CLASS;
       // Set the raw, un-marked up value as a data attribute.
-      li.setAttribute('data-value', item.raw);
+      li.setAttribute('data-value', value);
+      return li;
+    }
 
+    /**
+     * Create match node to highlight potential prefix match within result.
+     */
+    private _createMatchNode(result: string): HTMLElement {
       const matchNode = document.createElement('code');
       matchNode.className = 'jp-Completer-match';
       // Use innerHTML because search results include <mark> tags.
-      matchNode.innerHTML = defaultSanitizer.sanitize(item.text, {
+      matchNode.innerHTML = defaultSanitizer.sanitize(result, {
         allowedTags: ['mark']
       });
+      return matchNode;
+    }
 
+    /**
+     * Attaches type and match nodes to base node.
+     */
+    private _constructNode(
+      li: HTMLLIElement,
+      matchNode: HTMLElement,
+      typesExist: boolean,
+      type: any,
+      orderedTypes: string[]
+    ): HTMLLIElement {
       // If there are types provided add those.
-      if (!JSONExt.deepEqual(typeMap, {})) {
+      if (typesExist) {
         const typeNode = document.createElement('span');
-        const type = typeMap[item.raw] || '';
         typeNode.textContent = (type[0] || '').toLowerCase();
         const colorIndex = (orderedTypes.indexOf(type) % N_COLORS) + 1;
         typeNode.className = 'jp-Completer-type';

+ 1 - 0
packages/completer/style/index.css

@@ -5,6 +5,7 @@
 
 /* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */
 @import url('~@lumino/widgets/style/index.css');
+@import url('~@jupyterlab/ui-components/style/index.css');
 @import url('~@jupyterlab/apputils/style/index.css');
 
 @import url('./base.css');

+ 88 - 1
packages/completer/test/model.spec.ts

@@ -8,7 +8,11 @@ import { JSONExt } from '@lumino/coreutils';
 
 import { CodeEditor } from '@jupyterlab/codeeditor';
 
-import { CompleterModel, Completer } from '@jupyterlab/completer';
+import {
+  CompleterModel,
+  Completer,
+  CompletionHandler
+} from '@jupyterlab/completer';
 
 function makeState(text: string): Completer.ITextState {
   return {
@@ -27,6 +31,7 @@ describe('completer/model', () => {
       it('should create a completer model', () => {
         const model = new CompleterModel();
         expect(model).toBeInstanceOf(CompleterModel);
+        expect(model.setCompletionItems).toBeDefined();
       });
     });
 
@@ -45,6 +50,21 @@ describe('completer/model', () => {
         expect(called).toBe(2);
       });
 
+      it('should signal when model items have changed', () => {
+        let model = new CompleterModel();
+        let called = 0;
+        let listener = (sender: any, args: void) => {
+          called++;
+        };
+        model.stateChanged.connect(listener);
+        expect(called).toBe(0);
+        model.setCompletionItems!([{ label: 'foo' }]);
+        expect(called).toBe(1);
+        model.setCompletionItems!([{ label: 'foo' }]);
+        model.setCompletionItems!([{ label: 'foo' }, { label: 'bar' }]);
+        expect(called).toBe(2);
+      });
+
       it('should not signal when options have not changed', () => {
         const model = new CompleterModel();
         let called = 0;
@@ -64,6 +84,25 @@ describe('completer/model', () => {
         expect(called).toBe(3);
       });
 
+      it('should not signal when items have not changed', () => {
+        let model = new CompleterModel();
+        let called = 0;
+        let listener = (sender: any, args: void) => {
+          called++;
+        };
+        model.stateChanged.connect(listener);
+        expect(called).toBe(0);
+        model.setCompletionItems!([{ label: 'foo' }]);
+        model.setCompletionItems!([{ label: 'foo' }]);
+        expect(called).toBe(1);
+        model.setCompletionItems!([{ label: 'foo' }, { label: 'bar' }]);
+        model.setCompletionItems!([{ label: 'foo' }, { label: 'bar' }]);
+        expect(called).toBe(2);
+        model.setCompletionItems!([]);
+        model.setCompletionItems!([]);
+        expect(called).toBe(3);
+      });
+
       it('should signal when original request changes', () => {
         const model = new CompleterModel();
         let called = 0;
@@ -141,6 +180,54 @@ describe('completer/model', () => {
       });
     });
 
+    describe('#completionItems()', () => {
+      it('should default to { items: [] }', () => {
+        let model = new CompleterModel();
+        let want: CompletionHandler.ICompletionItems = [];
+        expect(model.completionItems!()).toEqual(want);
+      });
+
+      it('should return unmarked ICompletionItems if query is blank', () => {
+        let model = new CompleterModel();
+        let want: CompletionHandler.ICompletionItems = [
+          { label: 'foo' },
+          { label: 'bar' },
+          { label: 'baz' }
+        ];
+        model.setCompletionItems!([
+          { label: 'foo' },
+          { label: 'bar' },
+          { label: 'baz' }
+        ]);
+        expect(model.completionItems!()).toEqual(want);
+      });
+
+      it('should return a marked list of items if query is set', () => {
+        let model = new CompleterModel();
+        let want = '<mark>f</mark>oo';
+        model.setCompletionItems!([
+          { label: 'foo' },
+          { label: 'bar' },
+          { label: 'baz' }
+        ]);
+        model.query = 'f';
+        expect(model.completionItems!().length).toEqual(1);
+        expect(model.completionItems!()[0].label).toEqual(want);
+      });
+
+      it('should return { items: [] } if reset', () => {
+        let model = new CompleterModel();
+        let want: CompletionHandler.ICompletionItems = [];
+        model.setCompletionItems!([
+          { label: 'foo' },
+          { label: 'bar' },
+          { label: 'baz' }
+        ]);
+        model.reset();
+        expect(model.completionItems!()).toEqual(want);
+      });
+    });
+
     describe('#items()', () => {
       it('should return an unfiltered list of items if query is blank', () => {
         const model = new CompleterModel();

+ 616 - 1
packages/completer/test/widget.spec.ts

@@ -14,7 +14,11 @@ import { CodeEditor, CodeEditorWrapper } from '@jupyterlab/codeeditor';
 
 import { CodeMirrorEditor } from '@jupyterlab/codemirror';
 
-import { Completer, CompleterModel } from '@jupyterlab/completer';
+import {
+  Completer,
+  CompletionHandler,
+  CompleterModel
+} from '@jupyterlab/completer';
 
 import { framePromise, sleep } from '@jupyterlab/testutils';
 
@@ -33,6 +37,15 @@ function createEditorWidget(): CodeEditorWrapper {
 }
 
 class CustomRenderer extends Completer.Renderer {
+  createCompletionItemNode(
+    item: CompletionHandler.ICompletionItem,
+    orderedTypes: string[]
+  ): HTMLLIElement {
+    let li = super.createCompletionItemNode!(item, orderedTypes);
+    li.classList.add(TEST_ITEM_CLASS);
+    return li;
+  }
+
   createItemNode(
     item: Completer.IItem,
     typeMap: Completer.TypeMap,
@@ -104,6 +117,28 @@ describe('completer/widget', () => {
           expect.arrayContaining([TEST_ITEM_CLASS])
         );
       });
+
+      it('should accept completion items with a renderer', () => {
+        let options: Completer.IOptions = {
+          editor: null,
+          model: new CompleterModel(),
+          renderer: new CustomRenderer()
+        };
+        options.model!.setCompletionItems!([
+          { label: 'foo' },
+          { label: 'bar' }
+        ]);
+
+        let widget = new Completer(options);
+        expect(widget).toBeInstanceOf(Completer);
+        MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+
+        let items = widget.node.querySelectorAll(`.${ITEM_CLASS}`);
+        expect(items).toHaveLength(2);
+        expect(Array.from(items[0].classList)).toEqual(
+          expect.arrayContaining([TEST_ITEM_CLASS])
+        );
+      });
     });
 
     describe('#selected', () => {
@@ -131,6 +166,64 @@ describe('completer/widget', () => {
         widget.dispose();
         anchor.dispose();
       });
+
+      describe('#selected with completion items', () => {
+        it('should emit the insert text if it is present', () => {
+          let anchor = createEditorWidget();
+          let options: Completer.IOptions = {
+            editor: anchor.editor,
+            model: new CompleterModel()
+          };
+          let value = '';
+          let listener = (sender: any, selected: string) => {
+            value = selected;
+          };
+          options.model!.setCompletionItems!([
+            { label: 'foo', insertText: 'bar' },
+            { label: 'baz' }
+          ]);
+          Widget.attach(anchor, document.body);
+
+          let widget = new Completer(options);
+
+          widget.selected.connect(listener);
+          Widget.attach(widget, document.body);
+          MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+          expect(value).toBe('');
+          widget.selectActive();
+          expect(value).toBe('bar');
+          widget.dispose();
+          anchor.dispose();
+        });
+
+        it('should emit the label if insert text is not present', () => {
+          let anchor = createEditorWidget();
+          let options: Completer.IOptions = {
+            editor: anchor.editor,
+            model: new CompleterModel()
+          };
+          let value = '';
+          let listener = (sender: any, selected: string) => {
+            value = selected;
+          };
+          options.model!.setCompletionItems!([
+            { label: 'foo' },
+            { label: 'baz' }
+          ]);
+          Widget.attach(anchor, document.body);
+
+          let widget = new Completer(options);
+
+          widget.selected.connect(listener);
+          Widget.attach(widget, document.body);
+          MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+          expect(value).toBe('');
+          widget.selectActive();
+          expect(value).toBe('foo');
+          widget.dispose();
+          anchor.dispose();
+        });
+      });
     });
 
     describe('#visibilityChanged', () => {
@@ -183,6 +276,61 @@ describe('completer/widget', () => {
         code.dispose();
         panel.dispose();
       });
+
+      it('should emit a signal when completion items completer visibility changes', async () => {
+        let panel = new Panel();
+        let code = createEditorWidget();
+        let editor = code.editor;
+        let model = new CompleterModel();
+        let called = false;
+
+        editor.model.value.text = 'a';
+        panel.node.style.position = 'absolute';
+        panel.node.style.top = '0px';
+        panel.node.style.left = '0px';
+        panel.node.style.height = '1000px';
+        code.node.style.height = '900px';
+        panel.addWidget(code);
+        Widget.attach(panel, document.body);
+        panel.node.scrollTop = 0;
+        document.body.scrollTop = 0;
+
+        let position = code.editor.getPositionAt(1)!;
+
+        editor.setCursorPosition(position);
+
+        let request: Completer.ITextState = {
+          column: position.column,
+          lineHeight: editor.lineHeight,
+          charWidth: editor.charWidth,
+          line: position.line,
+          text: 'a'
+        };
+
+        model.original = request;
+        model.cursor = { start: 0, end: 1 };
+        model.setCompletionItems!([
+          { label: 'abc' },
+          { label: 'abd' },
+          { label: 'abe' },
+          { label: 'abi' }
+        ]);
+
+        let widget = new Completer({ model, editor: code.editor });
+        widget.hide();
+        expect(called).toBe(false);
+        widget.visibilityChanged.connect(() => {
+          called = true;
+        });
+        Widget.attach(widget, document.body);
+        MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+
+        await framePromise();
+        expect(called).toBe(true);
+        widget.dispose();
+        code.dispose();
+        panel.dispose();
+      });
     });
 
     describe('#model', () => {
@@ -272,6 +420,33 @@ describe('completer/widget', () => {
         widget.dispose();
         anchor.dispose();
       });
+
+      it('should reset the completer widget and its completion items', () => {
+        let anchor = createEditorWidget();
+        let model = new CompleterModel();
+        let options: Completer.IOptions = {
+          editor: anchor.editor,
+          model
+        };
+        model.setCompletionItems!([{ label: 'foo' }, { label: 'bar' }]);
+        Widget.attach(anchor, document.body);
+
+        let widget = new Completer(options);
+
+        Widget.attach(widget, document.body);
+        MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+        expect(widget.isHidden).toBe(false);
+        expect(model.completionItems!()).toEqual([
+          { label: 'foo' },
+          { label: 'bar' }
+        ]);
+        widget.reset();
+        MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+        expect(widget.isHidden).toBe(true);
+        expect(model.completionItems!()).toEqual([]);
+        widget.dispose();
+        anchor.dispose();
+      });
     });
 
     describe('#handleEvent()', () => {
@@ -316,6 +491,33 @@ describe('completer/widget', () => {
           anchor.dispose();
         });
 
+        it('should reset completion items if keydown is outside anchor', () => {
+          let model = new CompleterModel();
+          let anchor = createEditorWidget();
+          let options: Completer.IOptions = {
+            editor: anchor.editor,
+            model
+          };
+          model.setCompletionItems!([{ label: 'foo' }, { label: 'bar' }]);
+          Widget.attach(anchor, document.body);
+
+          let widget = new Completer(options);
+
+          Widget.attach(widget, document.body);
+          MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+          expect(widget.isHidden).toBe(false);
+          expect(model.completionItems!()).toEqual([
+            { label: 'foo' },
+            { label: 'bar' }
+          ]);
+          simulate(document.body, 'keydown', { keyCode: 70 }); // F
+          MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+          expect(widget.isHidden).toBe(true);
+          expect(model.completionItems!()).toEqual([]);
+          widget.dispose();
+          anchor.dispose();
+        });
+
         it('should select the item below and not progress past last', () => {
           const anchor = createEditorWidget();
           const model = new CompleterModel();
@@ -381,6 +583,71 @@ describe('completer/widget', () => {
           anchor.dispose();
         });
 
+        it('should select the completion item below and not progress past last', () => {
+          let anchor = createEditorWidget();
+          let model = new CompleterModel();
+          let options: Completer.IOptions = {
+            editor: anchor.editor,
+            model
+          };
+          model.setCompletionItems!([
+            { label: 'foo' },
+            { label: 'bar' },
+            { label: 'baz' }
+          ]);
+          Widget.attach(anchor, document.body);
+
+          let widget = new Completer(options);
+          let target = document.createElement('div');
+
+          anchor.node.appendChild(target);
+          Widget.attach(widget, document.body);
+          MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+
+          let items = widget.node.querySelectorAll(`.${ITEM_CLASS}`);
+          expect(Array.from(items[0].classList)).toEqual(
+            expect.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[1].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[2].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          simulate(target, 'keydown', { keyCode: 40 }); // Down
+          expect(Array.from(items[0].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[1].classList)).toEqual(
+            expect.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[2].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          simulate(target, 'keydown', { keyCode: 40 }); // Down
+          expect(Array.from(items[0].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[1].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[2].classList)).toEqual(
+            expect.arrayContaining([ACTIVE_CLASS])
+          );
+          simulate(target, 'keydown', { keyCode: 40 }); // Down
+          expect(Array.from(items[0].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[1].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[2].classList)).toEqual(
+            expect.arrayContaining([ACTIVE_CLASS])
+          );
+          widget.dispose();
+          anchor.dispose();
+        });
+
         it('should select the item above and not progress beyond first', () => {
           const anchor = createEditorWidget();
           const model = new CompleterModel();
@@ -464,6 +731,90 @@ describe('completer/widget', () => {
           anchor.dispose();
         });
 
+        it('should select the completion item above and not progress beyond first', () => {
+          let anchor = createEditorWidget();
+          let model = new CompleterModel();
+          let options: Completer.IOptions = {
+            editor: anchor.editor,
+            model
+          };
+          model.setCompletionItems!([
+            { label: 'foo' },
+            { label: 'bar' },
+            { label: 'baz' }
+          ]);
+          Widget.attach(anchor, document.body);
+
+          let widget = new Completer(options);
+
+          Widget.attach(widget, document.body);
+          MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+
+          let items = widget.node.querySelectorAll(`.${ITEM_CLASS}`);
+
+          expect(Array.from(items[0].classList)).toEqual(
+            expect.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[1].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[2].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          simulate(anchor.node, 'keydown', { keyCode: 40 }); // Down
+          expect(Array.from(items[0].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[1].classList)).toEqual(
+            expect.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[2].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          simulate(anchor.node, 'keydown', { keyCode: 40 }); // Down
+          expect(Array.from(items[0].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[1].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[2].classList)).toEqual(
+            expect.arrayContaining([ACTIVE_CLASS])
+          );
+          simulate(anchor.node, 'keydown', { keyCode: 38 }); // Up
+          expect(Array.from(items[0].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[1].classList)).toEqual(
+            expect.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[2].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          simulate(anchor.node, 'keydown', { keyCode: 38 }); // Up
+          expect(Array.from(items[0].classList)).toEqual(
+            expect.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[1].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[2].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          simulate(anchor.node, 'keydown', { keyCode: 38 }); // Up
+          expect(Array.from(items[0].classList)).toEqual(
+            expect.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[1].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          expect(Array.from(items[2].classList)).toEqual(
+            expect.not.arrayContaining([ACTIVE_CLASS])
+          );
+          widget.dispose();
+          anchor.dispose();
+        });
+
         it('should mark common subset on start and complete that subset on tab', async () => {
           const anchor = createEditorWidget();
           const model = new CompleterModel();
@@ -500,6 +851,45 @@ describe('completer/widget', () => {
           widget.dispose();
           anchor.dispose();
         });
+
+        it('should mark common subset of completion items on start and complete that subset on tab', async () => {
+          let anchor = createEditorWidget();
+          let model = new CompleterModel();
+          let options: Completer.IOptions = {
+            editor: anchor.editor,
+            model
+          };
+          let value = '';
+          let listener = (sender: any, selected: string) => {
+            value = selected;
+          };
+          model.setCompletionItems!([
+            { label: 'fo' },
+            { label: 'foo' },
+            { label: 'foo' },
+            { label: 'fooo' }
+          ]);
+          Widget.attach(anchor, document.body);
+
+          let widget = new Completer(options);
+
+          widget.selected.connect(listener);
+          Widget.attach(widget, document.body);
+          MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+          await framePromise();
+          let marked = widget.node.querySelectorAll(`.${ITEM_CLASS} mark`);
+          expect(value).toHaveLength(0);
+          expect(marked).toHaveLength(4);
+          expect(marked[0].textContent).toBe('fo');
+          expect(marked[1].textContent).toBe('fo');
+          expect(marked[2].textContent).toBe('fo');
+          expect(marked[3].textContent).toBe('fo');
+          simulate(anchor.node, 'keydown', { keyCode: 9 }); // Tab key
+          MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+          expect(value).toBe('fo');
+          widget.dispose();
+          anchor.dispose();
+        });
       });
 
       describe('mousedown', () => {
@@ -537,6 +927,41 @@ describe('completer/widget', () => {
           anchor.dispose();
         });
 
+        it('should trigger a selected signal on mouse down of completion item', () => {
+          let anchor = createEditorWidget();
+          let model = new CompleterModel();
+          let options: Completer.IOptions = {
+            editor: anchor.editor,
+            model
+          };
+          let value = '';
+          let listener = (sender: any, selected: string) => {
+            value = selected;
+          };
+          model.setCompletionItems!([
+            { label: 'foo' },
+            { label: 'bar' },
+            { label: 'baz' }
+          ]);
+          model.query = 'b';
+          Widget.attach(anchor, document.body);
+
+          let widget = new Completer(options);
+
+          widget.selected.connect(listener);
+          Widget.attach(widget, document.body);
+          MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+
+          let item = widget.node.querySelectorAll(`.${ITEM_CLASS} mark`)[1];
+
+          simulate(anchor.node, 'keydown', { keyCode: 9 }); // Tab key
+          expect(model.query).toBe('ba');
+          simulate(item, 'mousedown');
+          expect(value).toBe('baz');
+          widget.dispose();
+          anchor.dispose();
+        });
+
         it('should ignore nonstandard mouse clicks (e.g., right click)', () => {
           const anchor = createEditorWidget();
           const model = new CompleterModel();
@@ -563,6 +988,32 @@ describe('completer/widget', () => {
           anchor.dispose();
         });
 
+        it('should ignore nonstandard mouse clicks (e.g., right click) on completion item', () => {
+          let anchor = createEditorWidget();
+          let model = new CompleterModel();
+          let options: Completer.IOptions = {
+            editor: anchor.editor,
+            model
+          };
+          let value = '';
+          let listener = (sender: any, selected: string) => {
+            value = selected;
+          };
+          model.setCompletionItems!([{ label: 'foo' }, { label: 'bar' }]);
+          Widget.attach(anchor, document.body);
+
+          let widget = new Completer(options);
+
+          widget.selected.connect(listener);
+          Widget.attach(widget, document.body);
+          MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+          expect(value).toBe('');
+          simulate(widget.node, 'mousedown', { button: 1 });
+          expect(value).toBe('');
+          widget.dispose();
+          anchor.dispose();
+        });
+
         it('should ignore a mouse down that misses an item', () => {
           const anchor = createEditorWidget();
           const model = new CompleterModel();
@@ -589,6 +1040,32 @@ describe('completer/widget', () => {
           anchor.dispose();
         });
 
+        it('should ignore a mouse down that misses a completion item', () => {
+          let anchor = createEditorWidget();
+          let model = new CompleterModel();
+          let options: Completer.IOptions = {
+            editor: anchor.editor,
+            model
+          };
+          let value = '';
+          let listener = (sender: any, selected: string) => {
+            value = selected;
+          };
+          model.setCompletionItems!([{ label: 'foo' }, { label: 'bar' }]);
+          Widget.attach(anchor, document.body);
+
+          let widget = new Completer(options);
+
+          widget.selected.connect(listener);
+          Widget.attach(widget, document.body);
+          MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+          expect(value).toBe('');
+          simulate(widget.node, 'mousedown');
+          expect(value).toBe('');
+          widget.dispose();
+          anchor.dispose();
+        });
+
         it('should hide widget if mouse down misses it', () => {
           const anchor = createEditorWidget();
           const model = new CompleterModel();
@@ -614,6 +1091,32 @@ describe('completer/widget', () => {
           widget.dispose();
           anchor.dispose();
         });
+
+        it('should hide completion items widget if mouse down misses it', () => {
+          let anchor = createEditorWidget();
+          let model = new CompleterModel();
+          let options: Completer.IOptions = {
+            editor: anchor.editor,
+            model
+          };
+          let listener = (sender: any, selected: string) => {
+            // no op
+          };
+          model.setCompletionItems!([{ label: 'foo' }, { label: 'bar' }]);
+          Widget.attach(anchor, document.body);
+
+          let widget = new Completer(options);
+
+          widget.selected.connect(listener);
+          Widget.attach(widget, document.body);
+          MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+          expect(widget.isHidden).toBe(false);
+          simulate(anchor.node, 'mousedown');
+          MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+          expect(widget.isHidden).toBe(true);
+          widget.dispose();
+          anchor.dispose();
+        });
       });
 
       describe('scroll', () => {
@@ -716,6 +1219,82 @@ describe('completer/widget', () => {
         anchor.dispose();
       });
 
+      it('should emit label if there is only one completion item and insert Text is not set', () => {
+        let anchor = createEditorWidget();
+        let model = new CompleterModel();
+        let coords = { left: 0, right: 0, top: 100, bottom: 120 };
+        let request: Completer.ITextState = {
+          column: 0,
+          lineHeight: 0,
+          charWidth: 0,
+          line: 0,
+          coords: coords as CodeEditor.ICoordinate,
+          text: 'f'
+        };
+
+        let value = '';
+        let options: Completer.IOptions = {
+          editor: anchor.editor,
+          model
+        };
+        let listener = (sender: any, selected: string) => {
+          value = selected;
+        };
+
+        Widget.attach(anchor, document.body);
+        model.original = request;
+        model.setCompletionItems!([{ label: 'foo' }]);
+
+        let widget = new Completer(options);
+        widget.selected.connect(listener);
+        Widget.attach(widget, document.body);
+
+        expect(value).toBe('');
+        MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+        expect(value).toBe('foo');
+
+        widget.dispose();
+        anchor.dispose();
+      });
+
+      it('should emit insert text if there is only one completion item and insert Text is set', () => {
+        let anchor = createEditorWidget();
+        let model = new CompleterModel();
+        let coords = { left: 0, right: 0, top: 100, bottom: 120 };
+        let request: Completer.ITextState = {
+          column: 0,
+          lineHeight: 0,
+          charWidth: 0,
+          line: 0,
+          coords: coords as CodeEditor.ICoordinate,
+          text: 'f'
+        };
+
+        let value = '';
+        let options: Completer.IOptions = {
+          editor: anchor.editor,
+          model
+        };
+        let listener = (sender: any, selected: string) => {
+          value = selected;
+        };
+
+        Widget.attach(anchor, document.body);
+        model.original = request;
+        model.setCompletionItems!([{ label: 'foo', insertText: 'bar' }]);
+
+        let widget = new Completer(options);
+        widget.selected.connect(listener);
+        Widget.attach(widget, document.body);
+
+        expect(value).toBe('');
+        MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+        expect(value).toBe('bar');
+
+        widget.dispose();
+        anchor.dispose();
+      });
+
       it('should do nothing if a model does not exist', () => {
         const widget = new LogWidget({ editor: null });
         MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
@@ -755,6 +1334,42 @@ describe('completer/widget', () => {
         widget.dispose();
         anchor.dispose();
       });
+
+      it('should un-hide widget if multiple completion items are available', () => {
+        let anchor = createEditorWidget();
+        let model = new CompleterModel();
+        let coords = { left: 0, right: 0, top: 100, bottom: 120 };
+        let request: Completer.ITextState = {
+          column: 0,
+          lineHeight: 0,
+          charWidth: 0,
+          line: 0,
+          coords: coords as CodeEditor.ICoordinate,
+          text: 'f'
+        };
+
+        let options: Completer.IOptions = {
+          editor: anchor.editor,
+          model
+        };
+
+        Widget.attach(anchor, document.body);
+        model.original = request;
+        model.setCompletionItems!([
+          { label: 'foo' },
+          { label: 'bar' },
+          { label: 'baz' }
+        ]);
+
+        let widget = new Completer(options);
+        widget.hide();
+        expect(widget.isHidden).toBe(true);
+        Widget.attach(widget, document.body);
+        MessageLoop.sendMessage(widget, Widget.Msg.UpdateRequest);
+        expect(widget.isVisible).toBe(true);
+        widget.dispose();
+        anchor.dispose();
+      });
     });
   });
 });

+ 3 - 0
packages/completer/tsconfig.json

@@ -20,6 +20,9 @@
     },
     {
       "path": "../statedb"
+    },
+    {
+      "path": "../ui-components"
     }
   ]
 }

+ 6 - 0
packages/completer/tsconfig.test.json

@@ -17,6 +17,9 @@
     {
       "path": "../statedb"
     },
+    {
+      "path": "../ui-components"
+    },
     {
       "path": "../../testutils"
     },
@@ -34,6 +37,9 @@
     },
     {
       "path": "../statedb"
+    },
+    {
+      "path": "../ui-components"
     }
   ]
 }