Bug 1479521 - Refactor JsTerm autocompletion behavior; r=Honza.
authorNicolas Chevobbe <nchevobbe@mozilla.com>
Mon, 13 Aug 2018 10:07:15 +0000
changeset 486294 9838d4b3680d806475d4ae6dbbf9410e6be991da
parent 486293 32a474d6a1c8edf6c17365ae0f5ea75c01b23dd1
child 486295 65f3480a713b33098891dadd7d504313aa39775d
push id9719
push userffxbld-merge
push dateFri, 24 Aug 2018 17:49:46 +0000
treeherdermozilla-beta@719ec98fba77 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersHonza
bugs1479521
milestone63.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1479521 - Refactor JsTerm autocompletion behavior; r=Honza. We used to rely on different things to both display the autocompletion text and accept an autocompletion. This patches introduces new helper functions in order to make the code more easy to reason about. We also rollback our decision to show the popup when there is only 1 item in the autocompletion list in order to be more consistent with what Chrome does. Differential Revision: https://phabricator.services.mozilla.com/D2824
devtools/client/sourceeditor/editor.js
devtools/client/webconsole/components/JSTerm.js
devtools/shared/webconsole/client.js
--- a/devtools/client/sourceeditor/editor.js
+++ b/devtools/client/sourceeditor/editor.js
@@ -59,16 +59,17 @@ const CM_SCRIPTS = [
 const CM_IFRAME = "chrome://devtools/content/sourceeditor/codemirror/cmiframe.html";
 
 const CM_MAPPING = [
   "clearHistory",
   "defaultCharWidth",
   "extendSelection",
   "focus",
   "getCursor",
+  "getLine",
   "getScrollInfo",
   "getSelection",
   "getViewport",
   "hasFocus",
   "lineCount",
   "openDialog",
   "redo",
   "refresh",
--- a/devtools/client/webconsole/components/JSTerm.js
+++ b/devtools/client/webconsole/components/JSTerm.js
@@ -85,22 +85,16 @@ class JSTerm extends Component {
 
     const {
       hud,
     } = props;
 
     this.hud = hud;
     this.hudId = this.hud.hudId;
 
-    /**
-     * Stores the data for the last completion.
-     * @type object
-     */
-    this.lastCompletion = { value: null };
-
     this._keyPress = this._keyPress.bind(this);
     this._inputEventHandler = this._inputEventHandler.bind(this);
     this._blurEventHandler = this._blurEventHandler.bind(this);
     this.onContextMenu = this.onContextMenu.bind(this);
 
     this.SELECTED_FRAME = -1;
 
     /**
@@ -127,26 +121,22 @@ class JSTerm extends Component {
     this._lastFrameActorId = null;
 
     /**
      * Last input value.
      * @type string
      */
     this.lastInputValue = "";
 
+    this.currentAutoCompletionRequestId = null;
+
     this.autocompletePopup = null;
     this.inputNode = null;
     this.completeNode = null;
 
-    this.COMPLETE_FORWARD = 0;
-    this.COMPLETE_BACKWARD = 1;
-    this.COMPLETE_HINT_ONLY = 2;
-    this.COMPLETE_PAGEUP = 3;
-    this.COMPLETE_PAGEDOWN = 4;
-
     this._telemetry = new Telemetry();
 
     EventEmitter.decorate(this);
     hud.jsterm = this;
   }
 
   componentDidMount() {
     const autocompleteOptions = {
@@ -173,111 +163,104 @@ class JSTerm extends Component {
           lineWrapping: true,
           mode: Editor.modes.js,
           styleActiveLine: false,
           tabIndex: "0",
           viewportMargin: Infinity,
           extraKeys: {
             "Enter": () => {
               // No need to handle shift + Enter as it's natively handled by CodeMirror.
-              if (
-                !this.autocompletePopup.isOpen &&
-                !Debugger.isCompilableUnit(this.getInputValue())
-              ) {
+
+              const hasSuggestion = this.hasAutocompletionSuggestion();
+              if (!hasSuggestion && !Debugger.isCompilableUnit(this.getInputValue())) {
                 // incomplete statement
                 return "CodeMirror.Pass";
               }
 
-              if (
-                this.autocompletePopup.isOpen
-                && this.autocompletePopup.selectedIndex > -1
-              ) {
+              if (hasSuggestion) {
                 return this.acceptProposedCompletion();
               }
 
               this.execute();
               return null;
             },
 
             "Tab": () => {
-              // Generate a completion and accept the first proposed value.
-              if (
-                this.complete(this.COMPLETE_HINT_ONLY) &&
-                this.lastCompletion &&
-                this.acceptProposedCompletion()
-              ) {
-                return false;
-              }
-
               if (this.hasEmptyInput()) {
                 this.editor.codeMirror.getInputField().blur();
                 return false;
               }
 
-              if (!this.editor.somethingSelected()) {
+              const isSomethingSelected = this.editor.somethingSelected();
+              const hasSuggestion = this.hasAutocompletionSuggestion();
+
+              if (hasSuggestion && !isSomethingSelected) {
+                this.acceptProposedCompletion();
+                return false;
+              }
+
+              if (!isSomethingSelected) {
                 this.insertStringAtCursor("\t");
                 return false;
               }
 
-              // Input is not empty and some text is selected, let the editor handle this.
+              // Something is selected, let the editor handle the indent.
               return true;
             },
 
             "Up": () => {
               let inputUpdated;
               if (this.autocompletePopup.isOpen) {
-                inputUpdated = this.complete(this.COMPLETE_BACKWARD);
-              } else if (this.canCaretGoPrevious()) {
+                this.autocompletePopup.selectPreviousItem();
+                return null;
+              }
+
+              if (this.canCaretGoPrevious()) {
                 inputUpdated = this.historyPeruse(HISTORY_BACK);
               }
 
               if (!inputUpdated) {
                 return "CodeMirror.Pass";
               }
               return null;
             },
 
             "Down": () => {
               let inputUpdated;
               if (this.autocompletePopup.isOpen) {
-                inputUpdated = this.complete(this.COMPLETE_FORWARD);
-              } else if (this.canCaretGoNext()) {
+                this.autocompletePopup.selectNextItem();
+                return null;
+              }
+
+              if (this.canCaretGoNext()) {
                 inputUpdated = this.historyPeruse(HISTORY_FORWARD);
               }
 
               if (!inputUpdated) {
                 return "CodeMirror.Pass";
               }
               return null;
             },
 
             "Left": () => {
-              if (this.autocompletePopup.isOpen || this.lastCompletion.value) {
+              if (this.autocompletePopup.isOpen || this.getAutoCompletionText()) {
                 this.clearCompletion();
               }
               return "CodeMirror.Pass";
             },
 
             "Right": () => {
-              const haveSuggestion =
-                this.autocompletePopup.isOpen || this.lastCompletion.value;
-
-              if (
-                haveSuggestion &&
-                this.complete(this.COMPLETE_HINT_ONLY) &&
-                this.lastCompletion.value &&
-                this.acceptProposedCompletion()
-              ) {
+              // We only want to complete on Right arrow if the completion text is
+              // displayed.
+              if (this.getAutoCompletionText()) {
+                this.acceptProposedCompletion();
                 return null;
               }
 
-              if (this.autocompletePopup.isOpen) {
-                this.clearCompletion();
-              }
-
+              this.clearCompletion();
               return "CodeMirror.Pass";
             },
 
             "Ctrl-N": () => {
               // Control-N differs from down arrow: it ignores autocomplete state.
               // Note that we preserve the default 'down' navigation within
               // multiline text.
               if (
@@ -305,26 +288,26 @@ class JSTerm extends Component {
               }
 
               this.clearCompletion();
               return "CodeMirror.Pass";
             },
 
             "PageUp": () => {
               if (this.autocompletePopup.isOpen) {
-                this.complete(this.COMPLETE_PAGEUP);
+                this.autocompletePopup.selectPreviousPageItem();
                 return null;
               }
 
               return "CodeMirror.Pass";
             },
 
             "PageDown": () => {
               if (this.autocompletePopup.isOpen) {
-                this.complete(this.COMPLETE_PAGEDOWN);
+                this.autocompletePopup.selectNextPageItem();
                 return null;
               }
 
               return "CodeMirror.Pass";
             },
 
             "Home": () => {
               if (this.autocompletePopup.isOpen) {
@@ -727,17 +710,17 @@ class JSTerm extends Component {
   /**
    * The inputNode "input" and "keyup" event handler.
    * @private
    */
   _inputEventHandler() {
     const value = this.getInputValue();
     if (this.lastInputValue !== value) {
       this.resizeInput();
-      this.complete(this.COMPLETE_HINT_ONLY);
+      this.updateAutocompletion();
       this.lastInputValue = value;
     }
   }
 
   /**
    * The window "blur" event handler.
    * @private
    */
@@ -830,64 +813,64 @@ class JSTerm extends Component {
         if (this.autocompletePopup.isOpen) {
           this.clearCompletion();
           event.preventDefault();
           event.stopPropagation();
         }
         break;
 
       case KeyCodes.DOM_VK_RETURN:
-        if (
-            this.autocompletePopup.isOpen &&
-            this.autocompletePopup.selectedIndex > -1) {
+        if (this.hasAutocompletionSuggestion()) {
           this.acceptProposedCompletion();
         } else {
           this.execute();
         }
         event.preventDefault();
         break;
 
       case KeyCodes.DOM_VK_UP:
         if (this.autocompletePopup.isOpen) {
-          inputUpdated = this.complete(this.COMPLETE_BACKWARD);
+          this.autocompletePopup.selectPreviousItem();
+          event.preventDefault();
         } else if (this.canCaretGoPrevious()) {
           inputUpdated = this.historyPeruse(HISTORY_BACK);
         }
         if (inputUpdated) {
           event.preventDefault();
         }
         break;
 
       case KeyCodes.DOM_VK_DOWN:
         if (this.autocompletePopup.isOpen) {
-          inputUpdated = this.complete(this.COMPLETE_FORWARD);
+          this.autocompletePopup.selectNextItem();
+          event.preventDefault();
         } else if (this.canCaretGoNext()) {
           inputUpdated = this.historyPeruse(HISTORY_FORWARD);
         }
         if (inputUpdated) {
           event.preventDefault();
         }
         break;
 
       case KeyCodes.DOM_VK_PAGE_UP:
         if (this.autocompletePopup.isOpen) {
-          inputUpdated = this.complete(this.COMPLETE_PAGEUP);
+          this.autocompletePopup.selectPreviousPageItem();
         } else {
           this.hud.outputScroller.scrollTop =
             Math.max(0,
               this.hud.outputScroller.scrollTop -
               this.hud.outputScroller.clientHeight
             );
         }
         event.preventDefault();
         break;
 
       case KeyCodes.DOM_VK_PAGE_DOWN:
         if (this.autocompletePopup.isOpen) {
-          inputUpdated = this.complete(this.COMPLETE_PAGEDOWN);
+          this.autocompletePopup.selectNextPageItem();
         } else {
           this.hud.outputScroller.scrollTop =
             Math.min(this.hud.outputScroller.scrollHeight,
               this.hud.outputScroller.scrollTop +
               this.hud.outputScroller.clientHeight
             );
         }
         event.preventDefault();
@@ -900,52 +883,43 @@ class JSTerm extends Component {
         } else if (inputValue.length <= 0) {
           this.hud.outputScroller.scrollTop = 0;
           event.preventDefault();
         }
         break;
 
       case KeyCodes.DOM_VK_END:
         if (this.autocompletePopup.isOpen) {
-          this.autocompletePopup.selectedIndex =
-            this.autocompletePopup.itemCount - 1;
+          this.autocompletePopup.selectedIndex = this.autocompletePopup.itemCount - 1;
           event.preventDefault();
         } else if (inputValue.length <= 0) {
-          this.hud.outputScroller.scrollTop =
-            this.hud.outputScroller.scrollHeight;
+          this.hud.outputScroller.scrollTop = this.hud.outputScroller.scrollHeight;
           event.preventDefault();
         }
         break;
 
       case KeyCodes.DOM_VK_LEFT:
-        if (this.autocompletePopup.isOpen || this.lastCompletion.value) {
+        if (this.autocompletePopup.isOpen || this.getAutoCompletionText()) {
           this.clearCompletion();
         }
         break;
 
       case KeyCodes.DOM_VK_RIGHT:
-        const haveSuggestion = this.autocompletePopup.isOpen || this.lastCompletion.value;
-        if (
-          haveSuggestion &&
-          this.complete(this.COMPLETE_HINT_ONLY) &&
-          this.lastCompletion.value &&
-          this.acceptProposedCompletion()
-        ) {
+        // We only want to complete on Right arrow if the completion text is
+        // displayed.
+        if (this.getAutoCompletionText()) {
+          this.acceptProposedCompletion();
           event.preventDefault();
         }
-        if (this.autocompletePopup.isOpen) {
-          this.clearCompletion();
-        }
+        this.clearCompletion();
         break;
 
       case KeyCodes.DOM_VK_TAB:
-        // Generate a completion and accept the first proposed value.
-        if (this.complete(this.COMPLETE_HINT_ONLY) &&
-            this.lastCompletion &&
-            this.acceptProposedCompletion()) {
+        if (this.hasAutocompletionSuggestion()) {
+          this.acceptProposedCompletion();
           event.preventDefault();
         } else if (!this.hasEmptyInput()) {
           if (!event.shiftKey) {
             this.insertStringAtCursor("\t");
           }
           event.preventDefault();
         }
         break;
@@ -1058,230 +1032,114 @@ class JSTerm extends Component {
     if (node.selectionStart != node.selectionEnd) {
       return false;
     }
 
     return node.selectionStart == node.value.length ? true :
            node.selectionStart == 0 && !multiline;
   }
 
-  /**
-   * Completes the current typed text in the inputNode. Completion is performed
-   * only if the selection/cursor is at the end of the string. If no completion
-   * is found, the current inputNode value and cursor/selection stay.
-   *
-   * @param int type possible values are
-   *    - this.COMPLETE_FORWARD: If there is more than one possible completion
-   *          and the input value stayed the same compared to the last time this
-   *          function was called, then the next completion of all possible
-   *          completions is used. If the value changed, then the first possible
-   *          completion is used and the selection is set from the current
-   *          cursor position to the end of the completed text.
-   *          If there is only one possible completion, then this completion
-   *          value is used and the cursor is put at the end of the completion.
-   *    - this.COMPLETE_BACKWARD: Same as this.COMPLETE_FORWARD but if the
-   *          value stayed the same as the last time the function was called,
-   *          then the previous completion of all possible completions is used.
-   *    - this.COMPLETE_PAGEUP: Scroll up one page if available or select the
-   *          first item.
-   *    - this.COMPLETE_PAGEDOWN: Scroll down one page if available or select
-   *          the last item.
-   *    - this.COMPLETE_HINT_ONLY: If there is more than one possible
-   *          completion and the input value stayed the same compared to the
-   *          last time this function was called, then the same completion is
-   *          used again. If there is only one possible completion, then
-   *          the this.getInputValue() is set to this value and the selection
-   *          is set from the current cursor position to the end of the
-   *          completed text.
-   * @param function callback
-   *        Optional function invoked when the autocomplete properties are
-   *        updated.
-   * @returns boolean true if there existed a completion for the current input,
-   *          or false otherwise.
-   */
-  complete(type, callback) {
+  async updateAutocompletion() {
     const inputValue = this.getInputValue();
+    const {editor, inputNode} = this;
     const frameActor = this.getFrameActor(this.SELECTED_FRAME);
-    // If the inputNode has no value, then don't try to complete on it.
-    if (!inputValue) {
-      this.clearCompletion();
-      callback && callback(this);
-      this.emit("autocomplete-updated");
-      return false;
-    }
 
-    const {editor, inputNode} = this;
-    // Only complete if the selection is empty.
+    // Only complete if the selection is empty and the input value is not.
     if (
+      !inputValue ||
       (inputNode && inputNode.selectionStart != inputNode.selectionEnd) ||
-      (editor && editor.getSelection())
+      (editor && editor.getSelection()) ||
+      (this.lastInputValue === inputValue && frameActor === this._lastFrameActorId)
     ) {
       this.clearCompletion();
-      this.callback && callback(this);
       this.emit("autocomplete-updated");
-      return false;
-    }
-
-    // Update the completion results.
-    if (this.lastCompletion.value != inputValue || frameActor != this._lastFrameActorId) {
-      this._updateCompletionResult(type, callback);
-      return false;
-    }
-
-    const popup = this.autocompletePopup;
-    let accepted = false;
-
-    if (type != this.COMPLETE_HINT_ONLY && popup.itemCount == 1) {
-      this.acceptProposedCompletion();
-      accepted = true;
-    } else if (type == this.COMPLETE_BACKWARD) {
-      popup.selectPreviousItem();
-    } else if (type == this.COMPLETE_FORWARD) {
-      popup.selectNextItem();
-    } else if (type == this.COMPLETE_PAGEUP) {
-      popup.selectPreviousPageItem();
-    } else if (type == this.COMPLETE_PAGEDOWN) {
-      popup.selectNextPageItem();
-    }
-
-    callback && callback(this);
-    this.emit("autocomplete-updated");
-    return accepted || popup.itemCount > 0;
-  }
-
-  /**
-   * Update the completion result. This operation is performed asynchronously by
-   * fetching updated results from the content process.
-   *
-   * @private
-   * @param int type
-   *        Completion type. See this.complete() for details.
-   * @param function [callback]
-   *        Optional, function to invoke when completion results are received.
-   */
-  _updateCompletionResult(type, callback) {
-    const value = this.getInputValue();
-    const frameActor = this.getFrameActor(this.SELECTED_FRAME);
-    if (this.lastCompletion.value == value && frameActor == this._lastFrameActorId) {
       return;
     }
 
-    const requestId = gSequenceId();
     const cursor = this.getSelectionStart();
-    const input = value.substring(0, cursor);
-    const cache = this._autocompleteCache;
+    const input = inputValue.substring(0, cursor);
 
     // If the current input starts with the previous input, then we already
     // have a list of suggestions and we just need to filter the cached
-    // suggestions. When the current input ends with a non-alphanumeric
-    // character we ask the server again for suggestions.
+    // suggestions. When the current input ends with a non-alphanumeric character we ask
+    // the server again for suggestions.
 
     // Check if last character is non-alphanumeric
     if (!/[a-zA-Z0-9]$/.test(input) || frameActor != this._lastFrameActorId) {
       this._autocompleteQuery = null;
       this._autocompleteCache = null;
     }
+
     if (this._autocompleteQuery && input.startsWith(this._autocompleteQuery)) {
       let filterBy = input;
       // Find the last non-alphanumeric other than "_", ":", or "$" if it exists.
       const lastNonAlpha = input.match(/[^a-zA-Z0-9_$:][a-zA-Z0-9_$:]*$/);
       // If input contains non-alphanumerics, use the part after the last one
       // to filter the cache.
       if (lastNonAlpha) {
         filterBy = input.substring(input.lastIndexOf(lastNonAlpha) + 1);
       }
 
-      const newList = cache.sort().filter(l => l.startsWith(filterBy));
+      const newList = this._autocompleteCache.sort().filter(l => l.startsWith(filterBy));
 
-      this.lastCompletion = {
-        requestId: null,
-        completionType: type,
-        value: null,
-      };
-
-      const response = { matches: newList, matchProp: filterBy };
-      this._receiveAutocompleteProperties(null, callback, response);
+      this._receiveAutocompleteProperties(null, {
+        matches: newList,
+        matchProp: filterBy
+      });
       return;
     }
+    const requestId = gSequenceId();
     this._lastFrameActorId = frameActor;
-
-    this.lastCompletion = {
-      requestId: requestId,
-      completionType: type,
-      value: null,
-    };
-
-    const autocompleteCallback =
-      this._receiveAutocompleteProperties.bind(this, requestId, callback);
+    this.currentAutoCompletionRequestId = requestId;
 
-    this.webConsoleClient.autocomplete(
-      input, cursor, autocompleteCallback, frameActor);
-  }
-
-  getInputValueBeforeCursor() {
-    if (this.editor) {
-      return this.editor.getDoc().getRange({line: 0, ch: 0}, this.editor.getCursor());
-    }
-
-    if (this.inputNode) {
-      const cursor = this.inputNode.selectionStart;
-      return this.getInputValue().substring(0, cursor);
-    }
-
-    return null;
+    const message = await this.webConsoleClient.autocomplete(input, cursor, frameActor);
+    this._receiveAutocompleteProperties(requestId, message);
   }
 
   /**
    * Handler for the autocompletion results. This method takes
    * the completion result received from the server and updates the UI
    * accordingly.
    *
    * @param number requestId
    *        Request ID.
-   * @param function [callback=null]
-   *        Optional, function to invoke when the completion result is received.
    * @param object message
    *        The JSON message which holds the completion results received from
    *        the content process.
    */
-  _receiveAutocompleteProperties(requestId, callback, message) {
-    const inputValue = this.getInputValue();
-    if (this.lastCompletion.value == inputValue ||
-        requestId != this.lastCompletion.requestId) {
+  _receiveAutocompleteProperties(requestId, message) {
+    if (this.currentAutoCompletionRequestId !== requestId) {
       return;
     }
+    this.currentAutoCompletionRequestId = null;
+
     // Cache whatever came from the server if the last char is
     // alphanumeric or '.'
     const inputUntilCursor = this.getInputValueBeforeCursor();
 
     if (requestId != null && /[a-zA-Z0-9.]$/.test(inputUntilCursor)) {
       this._autocompleteCache = message.matches;
       this._autocompleteQuery = inputUntilCursor;
     }
 
     const matches = message.matches;
     const lastPart = message.matchProp;
     if (!matches.length) {
       this.clearCompletion();
-      callback && callback(this);
       this.emit("autocomplete-updated");
       return;
     }
 
     const popup = this.autocompletePopup;
     const items = matches.map(match => ({ preLabel: lastPart, label: match }));
     popup.setItems(items);
 
-    const completionType = this.lastCompletion.completionType;
-    this.lastCompletion = {
-      value: inputValue,
-      matchProp: lastPart,
-    };
+    const minimumAutoCompleteLength = 2;
 
-    if (items.length > 0) {
+    if (items.length >= minimumAutoCompleteLength) {
       let popupAlignElement;
       let xOffset;
       let yOffset;
 
       if (this.editor) {
         popupAlignElement = this.node.querySelector(".CodeMirror-cursor");
         // We need to show the popup at the ".".
         xOffset = -1 * lastPart.length * this._inputCharWidth;
@@ -1292,61 +1150,43 @@ class JSTerm extends Component {
           lastPart.length;
         xOffset = (offset * this._inputCharWidth) + this._chevronWidth;
         popupAlignElement = this.inputNode;
       }
 
       if (popupAlignElement) {
         popup.openPopup(popupAlignElement, xOffset, yOffset);
       }
-    } else if (items.length === 0 && popup.isOpen) {
+    } else if (items.length < minimumAutoCompleteLength && popup.isOpen) {
       popup.hidePopup();
     }
 
-    if (items.length == 1) {
-      popup.selectedIndex = 0;
+    if (items.length > 0) {
+      const suffix = items[0].label.substring(lastPart.length);
+      this.setAutoCompletionText(suffix);
     }
-
-    this.onAutocompleteSelect();
-
-    if (completionType != this.COMPLETE_HINT_ONLY && popup.itemCount == 1) {
-      this.acceptProposedCompletion();
-    } else if (completionType == this.COMPLETE_BACKWARD) {
-      popup.selectPreviousItem();
-    } else if (completionType == this.COMPLETE_FORWARD) {
-      popup.selectNextItem();
-    }
-
-    callback && callback(this);
     this.emit("autocomplete-updated");
   }
 
   onAutocompleteSelect() {
-    // Render the suggestion only if the cursor is at the end of the input.
-    if (this.getSelectionStart() != this.getInputValue().length) {
-      return;
-    }
-
-    const currentItem = this.autocompletePopup.selectedItem;
-    if (currentItem && this.lastCompletion.value) {
-      const suffix =
-        currentItem.label.substring(this.lastCompletion.matchProp.length);
-      this.updateCompleteNode(suffix);
+    const {selectedItem} = this.autocompletePopup;
+    if (selectedItem) {
+      const suffix = selectedItem.label.substring(selectedItem.preLabel.length);
+      this.setAutoCompletionText(suffix);
     } else {
-      this.updateCompleteNode("");
+      this.setAutoCompletionText("");
     }
   }
 
   /**
    * Clear the current completion information and close the autocomplete popup,
    * if needed.
    */
   clearCompletion() {
-    this.lastCompletion = { value: null };
-    this.updateCompleteNode("");
+    this.setAutoCompletionText("");
     if (this.autocompletePopup) {
       this.autocompletePopup.clearItems();
 
       if (this.autocompletePopup.isOpen) {
         // Trigger a blur/focus of the JSTerm input to force screen readers to read the
         // value again.
         if (this.inputNode) {
           this.inputNode.blur();
@@ -1362,30 +1202,49 @@ class JSTerm extends Component {
   /**
    * Accept the proposed input completion.
    *
    * @return boolean
    *         True if there was a selected completion item and the input value
    *         was updated, false otherwise.
    */
   acceptProposedCompletion() {
-    let updated = false;
+    let completionText = this.getAutoCompletionText();
+    // In some cases the completion text might not be displayed (e.g. there is some text
+    // just after the cursor so we can't display it). In those case, if the popup is
+    // open and has a selectedItem, we use it for completing the input.
+    if (
+      !completionText
+      && this.autocompletePopup.isOpen
+      && this.autocompletePopup.selectedItem
+    ) {
+      const {selectedItem} = this.autocompletePopup;
+      completionText = selectedItem.label.substring(selectedItem.preLabel.length);
+    }
 
-    const currentItem = this.autocompletePopup.selectedItem;
-    if (currentItem && this.lastCompletion.value) {
-      this.insertStringAtCursor(
-        currentItem.label.substring(this.lastCompletion.matchProp.length)
-      );
-
-      updated = true;
+    if (!completionText) {
+      return false;
     }
 
+    this.insertStringAtCursor(completionText);
     this.clearCompletion();
+    return true;
+  }
 
-    return updated;
+  getInputValueBeforeCursor() {
+    if (this.editor) {
+      return this.editor.getDoc().getRange({line: 0, ch: 0}, this.editor.getCursor());
+    }
+
+    if (this.inputNode) {
+      const cursor = this.inputNode.selectionStart;
+      return this.getInputValue().substring(0, cursor);
+    }
+
+    return null;
   }
 
   /**
    * Insert a string into the console at the cursor location,
    * moving the cursor to the end of the string.
    *
    * @param string str
    */
@@ -1407,33 +1266,87 @@ class JSTerm extends Component {
       this.editor.setCursor({
         line: editorCursor.line,
         ch: editorCursor.ch + str.length
       });
     }
   }
 
   /**
-   * Update the node that displays the currently selected autocomplete proposal.
+   * Set the autocompletion text of the input.
    *
    * @param string suffix
    *        The proposed suffix for the inputNode value.
    */
-  updateCompleteNode(suffix) {
+  setAutoCompletionText(suffix) {
+    if (suffix && !this.canDisplayAutoCompletionText()) {
+      suffix = "";
+    }
+
     if (this.completeNode) {
-      // completion prefix = input, with non-control chars replaced by spaces
-      const prefix = suffix ? this.getInputValue().replace(/[\S]/g, " ") : "";
-      this.completeNode.value = prefix + suffix;
+      const lines = this.getInputValueBeforeCursor().split("\n");
+      const lastLine = lines[lines.length - 1];
+      const prefix = ("\n".repeat(lines.length - 1)) + lastLine.replace(/[\S]/g, " ");
+      this.completeNode.value = suffix ? prefix + suffix : "";
     }
 
     if (this.editor) {
       this.editor.setAutoCompletionText(suffix);
     }
   }
 
+  getAutoCompletionText() {
+    if (this.completeNode) {
+      // Remove the spaces we set to align with the input value.
+      return this.completeNode.value.replace(/^\s+/gm, "");
+    }
+
+    if (this.editor) {
+      return this.editor.getAutoCompletionText();
+    }
+
+    return null;
+  }
+
+  /**
+   * Indicate if the input has an autocompletion suggestion, i.e. that there is either
+   * something in the autocompletion text or that there's a selected item in the
+   * autocomplete popup.
+   */
+  hasAutocompletionSuggestion() {
+    // We can have cases where the popup is opened but we can't display the autocompletion
+    // text.
+    return this.getAutoCompletionText() || (
+      this.autocompletePopup.isOpen &&
+      Number.isInteger(this.autocompletePopup.selectedIndex) &&
+      this.autocompletePopup.selectedIndex > -1
+    );
+  }
+
+  /**
+   * Returns a boolean indicating if we can display an autocompletion text in the input,
+   * i.e. if there is no characters displayed on the same line of the cursor and after it.
+   */
+  canDisplayAutoCompletionText() {
+    if (this.editor) {
+      const { ch, line } = this.editor.getCursor();
+      const lineContent = this.editor.getLine(line);
+      const textAfterCursor = lineContent.substring(ch);
+      return textAfterCursor === "";
+    }
+
+    if (this.inputNode) {
+      const value = this.getInputValue();
+      const textAfterCursor = value.substring(this.inputNode.selectionStart);
+      return textAfterCursor.split("\n")[0] === "";
+    }
+
+    return false;
+  }
+
   /**
    * Calculates and returns the width of a single character of the input box.
    * This will be used in opening the popup at the correct offset.
    *
    * @returns {Number|null}: Width off the "x" char, or null if the input does not exist.
    */
   _getInputCharWidth() {
     if (!this.inputNode && !this.node) {
--- a/devtools/shared/webconsole/client.js
+++ b/devtools/shared/webconsole/client.js
@@ -366,32 +366,30 @@ WebConsoleClient.prototype = {
 
   /**
    * Autocomplete a JavaScript expression.
    *
    * @param string string
    *        The code you want to autocomplete.
    * @param number cursor
    *        Cursor location inside the string. Index starts from 0.
-   * @param function onResponse
-   *        The function invoked when the response is received.
    * @param string frameActor
    *        The id of the frame actor that made the call.
    * @return request
    *         Request object that implements both Promise and EventEmitter interfaces
    */
-  autocomplete: function(string, cursor, onResponse, frameActor) {
+  autocomplete: function(string, cursor, frameActor) {
     const packet = {
       to: this._actor,
       type: "autocomplete",
       text: string,
       cursor: cursor,
       frameActor: frameActor,
     };
-    return this._client.request(packet, onResponse);
+    return this._client.request(packet);
   },
 
   /**
    * Clear the cache of messages (page errors and console API calls).
    *
    * @return request
    *         Request object that implements both Promise and EventEmitter interfaces
    */