]> git.donarmstrong.com Git - dactyl.git/blobdiff - common/content/commandline.js
Imported Upstream version 1.1+hg7904
[dactyl.git] / common / content / commandline.js
index 69e6a3cc24e6118d23725b31754ef6ff5a350b8f..9e66e533247f20e2024beafbb20444db92c92818 100644 (file)
@@ -1,6 +1,6 @@
 // Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
 // Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
-// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+// Copyright (c) 2008-2014 Kris Maglione <maglione.k@gmail.com>
 //
 // This work is licensed for reuse under an MIT license. Details are
 // given in the LICENSE.txt file included with this file.
@@ -14,56 +14,47 @@ var CommandWidgets = Class("CommandWidgets", {
     init: function init() {
         let s = "dactyl-statusline-field-";
 
-        XML.ignoreWhitespace = true;
-        util.overlayWindow(window, {
+        overlay.overlayWindow(window, {
             objects: {
                 eventTarget: commandline
             },
-            append: <e4x xmlns={XUL} xmlns:dactyl={NS}>
-                <vbox id={config.commandContainer}>
-                    <vbox class="dactyl-container" hidden="false" collapsed="true">
-                        <iframe class="dactyl-completions" id="dactyl-completions-dactyl-commandline" src="dactyl://content/buffer.xhtml"
-                                contextmenu="dactyl-contextmenu"
-                                flex="1" hidden="false" collapsed="false"
-                                highlight="Events" events="mowEvents" />
-                    </vbox>
-
-                    <stack orient="horizontal" align="stretch" class="dactyl-container" id="dactyl-container" highlight="CmdLine CmdCmdLine">
-                        <textbox class="plain" id="dactyl-strut"   flex="1" crop="end" collapsed="true"/>
-                        <textbox class="plain" id="dactyl-mode"    flex="1" crop="end"/>
-                        <textbox class="plain" id="dactyl-message" flex="1" readonly="true"/>
-
-                        <hbox id="dactyl-commandline" hidden="false" class="dactyl-container" highlight="Normal CmdNormal" collapsed="true">
-                            <label   id="dactyl-commandline-prompt"  class="dactyl-commandline-prompt  plain" flex="0" crop="end" value="" collapsed="true"/>
-                            <textbox id="dactyl-commandline-command" class="dactyl-commandline-command plain" flex="1" type="input" timeout="100"
-                                     highlight="Events" />
-                        </hbox>
-                    </stack>
-
-                    <vbox class="dactyl-container" hidden="false" collapsed="false" highlight="CmdLine">
-                        <textbox id="dactyl-multiline-input" class="plain" flex="1" rows="1" hidden="false" collapsed="true" multiline="true"
-                                 highlight="Normal Events" events="multilineInputEvents" />
-                    </vbox>
-                </vbox>
-
-                <stack id="dactyl-statusline-stack">
-                    <hbox id={s + "commandline"} hidden="false" class="dactyl-container" highlight="Normal StatusNormal" collapsed="true">
-                        <label id={s + "commandline-prompt"}    class="dactyl-commandline-prompt  plain" flex="0" crop="end" value="" collapsed="true"/>
-                        <textbox id={s + "commandline-command"} class="dactyl-commandline-command plain" flex="1" type="text" timeout="100"
-                                 highlight="Events" />
-                    </hbox>
-                </stack>
-            </e4x>.elements(),
-
-            before: <e4x xmlns={XUL} xmlns:dactyl={NS}>
-                <toolbar id={statusline.statusBar.id}>
-                    <vbox id={"dactyl-completions-" + s + "commandline-container"} class="dactyl-container" hidden="false" collapsed="true">
-                        <iframe class="dactyl-completions" id={"dactyl-completions-" + s + "commandline"} src="dactyl://content/buffer.xhtml"
-                                contextmenu="dactyl-contextmenu" flex="1" hidden="false" collapsed="false"
-                                highlight="Events" events="mowEvents" />
-                    </vbox>
-                </toolbar>
-            </e4x>.elements()
+            append: [
+                ["vbox", { id: config.ids.commandContainer, xmlns: "xul" },
+                    ["vbox", { class: "dactyl-container", hidden: "false", collapsed: "true" },
+                        ["iframe", { class: "dactyl-completions", id: "dactyl-completions-dactyl-commandline",
+                                     src: "dactyl://content/buffer.xhtml", contextmenu: "dactyl-contextmenu",
+                                     flex: "1", hidden: "false", collapsed: "false",
+                                     highlight: "Events", events: "mowEvents" }]],
+
+                    ["stack", { orient: "horizontal", align: "stretch", class: "dactyl-container",
+                                id: "dactyl-container", highlight: "CmdLine CmdCmdLine" },
+                        ["textbox", { class: "plain", id: "dactyl-strut",   flex: "1", crop: "end", collapsed: "true" }],
+                        ["textbox", { class: "plain", id: "dactyl-mode",    flex: "1", crop: "end" }],
+                        ["hbox", { id: "dactyl-message-box" },
+                            ["label", { class: "plain", id: "dactyl-message-pre", flex: "0", readonly: "true", highlight: "WarningMsg" }],
+                            ["textbox", { class: "plain", id: "dactyl-message", flex: "1", readonly: "true" }]],
+
+                        ["hbox", { id: "dactyl-commandline", hidden: "false", class: "dactyl-container", highlight: "Normal CmdNormal", collapsed: "true" },
+                            ["label", {   id: "dactyl-commandline-prompt",  class: "dactyl-commandline-prompt  plain", flex: "0", crop: "end", value: "", collapsed: "true" }],
+                            ["textbox", { id: "dactyl-commandline-command", class: "dactyl-commandline-command plain", flex: "1", type: "input", timeout: "100",
+                                          highlight: "Events" }]]],
+
+                    ["vbox", { class: "dactyl-container", hidden: "false", collapsed: "false", highlight: "CmdLine" },
+                        ["textbox", { id: "dactyl-multiline-input", class: "plain", flex: "1", rows: "1", hidden: "false", collapsed: "true",
+                                      multiline: "true", highlight: "Normal Events", events: "multilineInputEvents" }]]],
+
+                ["stack", { id: "dactyl-statusline-stack", xmlns: "xul" },
+                    ["hbox", { id: s + "commandline", hidden: "false", class: "dactyl-container", highlight: "Normal StatusNormal", collapsed: "true" },
+                        ["label", { id: s + "commandline-prompt",    class: "dactyl-commandline-prompt  plain", flex: "0", crop: "end", value: "", collapsed: "true" }],
+                        ["textbox", { id: s + "commandline-command", class: "dactyl-commandline-command plain", flex: "1", type: "text", timeout: "100",
+                                      highlight: "Events",  }]]]],
+
+            before: [
+                ["toolbar", { id: statusline.statusBar.id, xmlns: "xul" },
+                    ["vbox", { id: "dactyl-completions-" + s + "commandline-container", class: "dactyl-container", hidden: "false", collapsed: "true" },
+                        ["iframe", { class: "dactyl-completions", id: "dactyl-completions-" + s + "commandline", src: "dactyl://content/buffer.xhtml",
+                                     contextmenu: "dactyl-contextmenu", flex: "1", hidden: "false", collapsed: "false", highlight: "Events",
+                                     events: "mowEvents" }]]]]
         });
 
         this.elements = {};
@@ -135,12 +126,26 @@ var CommandWidgets = Class("CommandWidgets", {
                     return this.statusbar;
 
                 let statusElem = this.statusbar.message;
-                if (value && !value[2] && statusElem.editor && statusElem.editor.rootElement.scrollWidth > statusElem.scrollWidth)
+                // Currently doesn't work as expected with <hbox> parent.
+                if (false && value && !value[2] && statusElem.editor && statusElem.editor.rootElement.scrollWidth > statusElem.scrollWidth)
                     return this.commandbar;
                 return this.activeGroup.mode;
             }
         });
 
+        this.addElement({
+            name: "message-pre",
+            defaultGroup: "WarningMsg",
+            getGroup: function () this.activeGroup.message
+        });
+
+        this.addElement({
+            name: "message-box",
+            defaultGroup: "Normal",
+            getGroup: function () this.activeGroup.message,
+            getValue: function () this.message
+        });
+
         this.addElement({
             name: "mode",
             defaultGroup: "ModeMsg",
@@ -152,6 +157,9 @@ var CommandWidgets = Class("CommandWidgets", {
                 return this.commandbar;
             }
         });
+        this.updateVisibility();
+
+        this.initialized = true;
     },
     addElement: function addElement(obj) {
         const self = this;
@@ -159,11 +167,11 @@ var CommandWidgets = Class("CommandWidgets", {
 
         function get(prefix, map, id) (obj.getElement || util.identity)(map[id] || document.getElementById(prefix + id));
 
-        this.active.__defineGetter__(obj.name, function () self.activeGroup[obj.name][obj.name]);
-        this.activeGroup.__defineGetter__(obj.name, function () self.getGroup(obj.name));
+        this.active.__defineGetter__(obj.name, () => this.activeGroup[obj.name][obj.name]);
+        this.activeGroup.__defineGetter__(obj.name, () => this.getGroup(obj.name));
 
-        memoize(this.statusbar, obj.name, function () get("dactyl-statusline-field-", statusline.widgets, (obj.id || obj.name)));
-        memoize(this.commandbar, obj.name, function () get("dactyl-", {}, (obj.id || obj.name)));
+        memoize(this.statusbar, obj.name, () => get("dactyl-statusline-field-", statusline.widgets, (obj.id || obj.name)));
+        memoize(this.commandbar, obj.name, () => get("dactyl-", {}, (obj.id || obj.name)));
 
         if (!(obj.noValue || obj.getValue)) {
             Object.defineProperty(this, obj.name, Modes.boundProperty({
@@ -174,7 +182,7 @@ var CommandWidgets = Class("CommandWidgets", {
                     if (obj.value != null)
                         return [obj.value[0],
                                 obj.get ? obj.get.call(this, elem) : elem.value]
-                                .concat(obj.value.slice(2))
+                                .concat(obj.value.slice(2));
                     return null;
                 },
 
@@ -191,7 +199,7 @@ var CommandWidgets = Class("CommandWidgets", {
                             highlight.highlightNode(elem,
                                 (val[0] != null ? val[0] : obj.defaultGroup)
                                     .split(/\s/).filter(util.identity)
-                                    .map(function (g) g + " " + nodeSet.group + g)
+                                    .map(g => g + " " + nodeSet.group + g)
                                     .join(" "));
                             elem.value = val[1];
                             if (obj.onChange)
@@ -209,7 +217,8 @@ var CommandWidgets = Class("CommandWidgets", {
                 let elem = nodeSet[obj.name];
                 if (elem)
                     highlight.highlightNode(elem, obj.defaultGroup.split(/\s/)
-                                                     .map(function (g) g + " " + nodeSet.group + g).join(" "));
+                                                     .map(g => g + " " + nodeSet.group + g)
+                                                     .join(" "));
             });
         }
     },
@@ -221,6 +230,7 @@ var CommandWidgets = Class("CommandWidgets", {
     },
 
     updateVisibility: function updateVisibility() {
+        let changed = 0;
         for (let elem in values(this.elements))
             if (elem.getGroup) {
                 let value = elem.getValue ? elem.getValue.call(this)
@@ -231,6 +241,7 @@ var CommandWidgets = Class("CommandWidgets", {
                     let meth, node = group[elem.name];
                     let visible = (value && group === activeGroup);
                     if (node && !node.collapsed == !visible) {
+                        changed++;
                         node.collapsed = !visible;
                         if (elem.onVisibility)
                             elem.onVisibility.call(this, node, visible);
@@ -242,19 +253,24 @@ var CommandWidgets = Class("CommandWidgets", {
         // Might possibly be better to use a deck and programmatically
         // choose which element to select.
         function check(node) {
-            if (util.computedStyle(node).display === "-moz-stack") {
-                let nodes = Array.filter(node.children, function (n) !n.collapsed && n.boxObject.height);
-                nodes.forEach(function (node, i) node.style.opacity = (i == nodes.length - 1) ? "" : "0");
+            if (DOM(node).style.display === "-moz-stack") {
+                let nodes = Array.filter(node.children, n => !n.collapsed && n.boxObject.height);
+                nodes.forEach((node, i) => {
+                    node.style.opacity = (i == nodes.length - 1) ? "" : "0";
+                });
             }
             Array.forEach(node.children, check);
         }
         [this.commandbar.container, this.statusbar.container].forEach(check);
+
+        if (this.initialized && loaded.has("mow") && mow.visible)
+            mow.resize(false);
     },
 
-    active: Class.memoize(Object),
-    activeGroup: Class.memoize(Object),
-    commandbar: Class.memoize(function () ({ group: "Cmd" })),
-    statusbar: Class.memoize(function ()  ({ group: "Status" })),
+    active: Class.Memoize(Object),
+    activeGroup: Class.Memoize(Object),
+    commandbar: Class.Memoize(function () ({ group: "Cmd" })),
+    statusbar: Class.Memoize(function ()  ({ group: "Status" })),
 
     _ready: function _ready(elem) {
         return elem.contentDocument.documentURI === elem.getAttribute("src") &&
@@ -271,27 +287,29 @@ var CommandWidgets = Class("CommandWidgets", {
         yield elem;
     },
 
-    completionContainer: Class.memoize(function () this.completionList.parentNode),
+    completionContainer: Class.Memoize(function () this.completionList.parentNode),
 
-    contextMenu: Class.memoize(function () {
+    contextMenu: Class.Memoize(function () {
         ["copy", "copylink", "selectall"].forEach(function (tail) {
             // some host apps use "hostPrefixContext-copy" ids
-            let xpath = "//xul:menuitem[contains(@id, '" + "ontext-" + tail + "') and not(starts-with(@id, 'dactyl-'))]";
-            document.getElementById("dactyl-context-" + tail).style.listStyleImage =
-                util.computedStyle(util.evaluateXPath(xpath, document).snapshotItem(0)).listStyleImage;
+            let css   = "menuitem[id$='ontext-" + tail + "']:not([id^=dactyl-])";
+            let style = DOM(css, document).style;
+            DOM("#dactyl-context-" + tail, document).css({
+                listStyleImage: style.listStyleImage,
+                MozImageRegion: style.MozImageRegion
+            });
         });
         return document.getElementById("dactyl-contextmenu");
     }),
 
-    multilineOutput: Class.memoize(function () this._whenReady("dactyl-multiline-output", function (elem) {
-        elem.contentWindow.addEventListener("unload", function (event) { event.preventDefault(); }, true);
-        elem.contentDocument.documentElement.id = "dactyl-multiline-output-top";
-        elem.contentDocument.body.id = "dactyl-multiline-output-content";
+    multilineOutput: Class.Memoize(function () this._whenReady("dactyl-multiline-output",
+                                                               elem => {
+        highlight.highlightNode(elem.contentDocument.body, "MOW");
     }), true),
 
-    multilineInput: Class.memoize(function () document.getElementById("dactyl-multiline-input")),
+    multilineInput: Class.Memoize(() => document.getElementById("dactyl-multiline-input")),
 
-    mowContainer: Class.memoize(function () document.getElementById("dactyl-multiline-output-container"))
+    mowContainer: Class.Memoize(() => document.getElementById("dactyl-multiline-output-container"))
 }, {
     getEditor: function getEditor(elem) {
         elem.inputField.QueryInterface(Ci.nsIDOMNSEditableElement);
@@ -300,27 +318,37 @@ var CommandWidgets = Class("CommandWidgets", {
 });
 
 var CommandMode = Class("CommandMode", {
-    init: function init() {
+    init: function CM_init() {
         this.keepCommand = userContext.hidden_option_command_afterimage;
     },
 
+    get autocomplete() options["autocomplete"].length,
+
     get command() this.widgets.command[1],
     set command(val) this.widgets.command = val,
 
-    get prompt() this.widgets.prompt,
-    set prompt(val) this.widgets.prompt = val,
+    get prompt() this._open ? this.widgets.prompt : this._prompt,
+    set prompt(val) {
+        if (this._open)
+            this.widgets.prompt = val;
+        else
+            this._prompt = val;
+    },
 
-    open: function (command) {
+    open: function CM_open(command) {
         dactyl.assert(isinstance(this.mode, modes.COMMAND_LINE),
-                      "Not opening command line in non-command-line mode.");
+                      /*L*/"Not opening command line in non-command-line mode.",
+                      false);
 
         this.messageCount = commandline.messageCount;
-        modes.push(this.mode, this.extendedMode, this.closure);
+        modes.push(this.mode, this.extendedMode, this.bound);
 
         this.widgets.active.commandline.collapsed = false;
         this.widgets.prompt = this.prompt;
         this.widgets.command = command || "";
 
+        this._open = true;
+
         this.input = this.widgets.active.command.inputField;
         if (this.historyKey)
             this.history = CommandLine.History(this.input, this.historyKey, this);
@@ -340,7 +368,7 @@ var CommandMode = Class("CommandMode", {
 
     get widgets() commandline.widgets,
 
-    enter: function (stack) {
+    enter: function CM_enter(stack) {
         commandline.commandSession = this;
         if (stack.pop && commandline.command) {
             this.onChange(commandline.command);
@@ -349,24 +377,28 @@ var CommandMode = Class("CommandMode", {
         }
     },
 
-    leave: function (stack) {
+    leave: function CM_leave(stack) {
         if (!stack.push) {
             commandline.commandSession = null;
             this.input.dactylKeyPress = undefined;
 
+            let waiting = this.accepted && this.completions && this.completions.waiting;
+            if (waiting)
+                this.completions.onComplete = bind("onSubmit", this);
+
             if (this.completions)
                 this.completions.cleanup();
 
             if (this.history)
                 this.history.save();
 
-            this.resetCompletions();
             commandline.hideCompletions();
 
             modes.delay(function () {
                 if (!this.keepCommand || commandline.silent || commandline.quiet)
                     commandline.hide();
-                this[this.accepted ? "onSubmit" : "onCancel"](commandline.command);
+                if (!waiting)
+                    this[this.accepted ? "onSubmit" : "onCancel"](commandline.command);
                 if (commandline.messageCount === this.messageCount)
                     commandline.clearMessage();
             }, this);
@@ -374,7 +406,7 @@ var CommandMode = Class("CommandMode", {
     },
 
     events: {
-        input: function onInput(event) {
+        input: function CM_onInput(event) {
             if (this.completions) {
                 this.resetCompletions();
 
@@ -382,8 +414,8 @@ var CommandMode = Class("CommandMode", {
             }
             this.onChange(commandline.command);
         },
-        keyup: function onKeyUp(event) {
-            let key = events.toString(event);
+        keyup: function CM_onKeyUp(event) {
+            let key = DOM.Event.stringify(event);
             if (/-?Tab>$/.test(key) && this.completions)
                 this.completions.tabTimer.flush();
         }
@@ -391,28 +423,24 @@ var CommandMode = Class("CommandMode", {
 
     keepCommand: false,
 
-    onKeyPress: function onKeyPress(events) {
+    onKeyPress: function CM_onKeyPress(events) {
         if (this.completions)
             this.completions.previewClear();
 
         return true; /* Pass event */
     },
 
-    onCancel: function (value) {
-    },
+    onCancel: function (value) {},
 
-    onChange: function (value) {
-    },
+    onChange: function (value) {},
 
-    onSubmit: function (value) {
-    },
+    onHistory: function (value) {},
 
-    resetCompletions: function resetCompletions() {
-        if (this.completions) {
-            this.completions.context.cancelAll();
-            this.completions.wildIndex = -1;
-            this.completions.previewClear();
-        }
+    onSubmit: function (value) {},
+
+    resetCompletions: function CM_resetCompletions() {
+        if (this.completions)
+            this.completions.clear();
         if (this.history)
             this.history.reset();
     },
@@ -426,12 +454,17 @@ var CommandExMode = Class("CommandExMode", CommandMode, {
 
     prompt: ["Normal", ":"],
 
-    complete: function complete(context) {
-        context.fork("ex", 0, completion, "ex");
+    complete: function CEM_complete(context) {
+        try {
+            context.fork("ex", 0, completion, "ex");
+        }
+        catch (e) {
+            context.message = _("error.error", e);
+        }
     },
 
-    onSubmit: function onSubmit(command) {
-        contexts.withContext({ file: "[Command Line]", line: 1 },
+    onSubmit: function CEM_onSubmit(command) {
+        contexts.withContext({ file: /*L*/"[Command Line]", line: 1 },
                              function _onSubmit() {
             io.withSavedValues(["readHeredoc"], function _onSubmit() {
                 this.readHeredoc = commandline.readHeredoc;
@@ -449,9 +482,9 @@ var CommandPromptMode = Class("CommandPromptMode", CommandMode, {
         init.supercall(this);
     },
 
-    complete: function (context) {
+    complete: function CPM_complete(context, ...args) {
         if (this.completer)
-            context.forkapply("prompt", 0, this, "completer", Array.slice(arguments, 1));
+            context.forkapply("prompt", 0, this, "completer", args);
     },
 
     get mode() modes.PROMPT
@@ -465,8 +498,6 @@ var CommandPromptMode = Class("CommandPromptMode", CommandMode, {
  */
 var CommandLine = Module("commandline", {
     init: function init() {
-        const self = this;
-
         this._callbacks = {};
 
         memoize(this, "_store", function () storage.newMap("command-history", { store: true, privateData: true }));
@@ -575,7 +606,7 @@ var CommandLine = Module("commandline", {
         }, this);
     },
 
-    widgets: Class.memoize(function () CommandWidgets()),
+    widgets: Class.Memoize(() => CommandWidgets()),
 
     runSilently: function runSilently(func, self) {
         this.withSavedValues(["silent"], function () {
@@ -586,11 +617,16 @@ var CommandLine = Module("commandline", {
 
     get completionList() {
         let node = this.widgets.active.commandline;
+        if (this.commandSession && this.commandSession.completionList)
+            node = document.getElementById(this.commandSession.completionList);
+
         if (!node.completionList) {
             let elem = document.getElementById("dactyl-completions-" + node.id);
             util.waitFor(bind(this.widgets._ready, null, elem));
 
-            node.completionList = ItemList(elem.id);
+            node.completionList = ItemList(elem);
+            node.completionList.isAboveMow = node.id ==
+                this.widgets.statusbar.commandline.id;
         }
         return node.completionList;
     },
@@ -623,6 +659,7 @@ var CommandLine = Module("commandline", {
         if (!scroll || Date.now() - this._lastEchoTime > 5000)
             this.clearMessage();
         this._lastEchoTime = 0;
+        this.hiddenMessages = 0;
 
         if (!this.commandSession) {
             this.widgets.command = null;
@@ -637,22 +674,24 @@ var CommandLine = Module("commandline", {
     },
 
     clearMessage: function clearMessage() {
-        if (this.widgets.message && this.widgets.message[1] === this._lastClearable)
+        if (this.widgets.message && this.widgets.message[1] === this._lastClearable) {
             this.widgets.message = null;
+            this.hiddenMessages = 0;
+        }
     },
 
     /**
      * Displays the multi-line output of a command, preceded by the last
      * executed ex command string.
      *
-     * @param {XML} xml The output as an E4X XML object.
+     * @param {object} xml The output as a JSON XML object.
      */
     commandOutput: function commandOutput(xml) {
-        XML.ignoreWhitespace = false;
-        XML.prettyPrinting = false;
-        if (this.command)
-            this.echo(<>:{this.command}</>, this.HIGHLIGHT_NORMAL, this.FORCE_MULTILINE);
-        this.echo(xml, this.HIGHLIGHT_NORMAL, this.FORCE_MULTILINE);
+        if (!this.command)
+            this.echo(xml, this.HIGHLIGHT_NORMAL, this.FORCE_MULTILINE);
+        else
+            this.echo([["div", { xmlns: "html" }, ":" + this.command], "\n", xml],
+                      this.HIGHLIGHT_NORMAL, this.FORCE_MULTILINE);
         this.command = null;
     },
 
@@ -683,10 +722,20 @@ var CommandLine = Module("commandline", {
         let field = this.widgets.active.message.inputField;
         if (field.value && !forceSingle && field.editor.rootElement.scrollWidth > field.scrollWidth) {
             this.widgets.message = null;
-            mow.echo(<span highlight="Message">{str}</span>, highlightGroup, true);
+            mow.echo(["span", { highlight: "Message" }, str], highlightGroup, true);
         }
     },
 
+    _hiddenMessages: 0,
+    get hiddenMessages() this._hiddenMessages,
+    set hiddenMessages(val) {
+        this._hiddenMessages = val;
+        if (val)
+            this.widgets["message-pre"] = _("commandline.moreMessages", val) + " ";
+        else
+            this.widgets["message-pre"] = null;
+    },
+
     _lastEcho: null,
 
     /**
@@ -720,46 +769,74 @@ var CommandLine = Module("commandline", {
 
         highlightGroup = highlightGroup || this.HL_NORMAL;
 
-        if (flags & this.APPEND_TO_MESSAGES) {
-            let message = isObject(data) ? data : { message: data };
+        let appendToMessages = (data) => {
+            let message = isObject(data) && !DOM.isJSONXML(data) ? data : { message: data };
+
+            // Make sure the memoized message property is an instance property.
+            message.message;
             this._messageHistory.add(update({ highlight: highlightGroup }, message));
-            data = message.message;
+            return message.message;
         }
 
-        if ((flags & this.ACTIVE_WINDOW) &&
-            window != services.windowWatcher.activeWindow &&
-            services.windowWatcher.activeWindow.dactyl)
+        if (flags & this.APPEND_TO_MESSAGES)
+            data = appendToMessages(data);
+
+        if ((flags & this.ACTIVE_WINDOW) && window != overlay.activeWindow)
             return;
 
         if ((flags & this.DISALLOW_MULTILINE) && !this.widgets.mowContainer.collapsed)
             return;
 
-        let single = flags & (this.FORCE_SINGLELINE | this.DISALLOW_MULTILINE);
+        let forceSingle = flags & (this.FORCE_SINGLELINE | this.DISALLOW_MULTILINE);
         let action = this._echoLine;
 
-        if ((flags & this.FORCE_MULTILINE) || (/\n/.test(data) || !isString(data)) && !(flags & this.FORCE_SINGLELINE))
-            action = mow.closure.echo;
+        if ((flags & this.FORCE_MULTILINE) || (/\n/.test(data) || !isinstance(data, [_, "String"])) && !(flags & this.FORCE_SINGLELINE))
+            action = mow.bound.echo;
+
+        let checkSingleLine = () => action == this._echoLine;
 
-        if (single)
+        if (forceSingle) {
             this._lastEcho = null;
+            this.hiddenMessages = 0;
+        }
         else {
-            if (this.widgets.message && this.widgets.message[1] == this._lastEcho)
-                mow.echo(<span highlight="Message">{this._lastEcho}</span>,
-                         this.widgets.message[0], true);
-
-            if (action === this._echoLine && !(flags & this.FORCE_MULTILINE)
-                && !(dactyl.fullyInitialized && this.widgets.mowContainer.collapsed)) {
+            // So complicated...
+            if (checkSingleLine() && !this.widgets.mowContainer.collapsed) {
                 highlightGroup += " Message";
-                action = mow.closure.echo;
+                action = mow.bound.echo;
+            }
+            else if (!checkSingleLine() && this.widgets.mowContainer.collapsed) {
+                if (this._lastEcho && this.widgets.message && this.widgets.message[1] == this._lastEcho.msg) {
+                    if (!(this._lastEcho.flags & this.APPEND_TO_MESSAGES))
+                        appendToMessages(this._lastEcho.data);
+
+                    mow.echo(
+                        ["span", { highlight: "Message" },
+                            ["span", { highlight: "WarningMsg" },
+                                _("commandline.moreMessages", this.hiddenMessages + 1) + " "],
+                            this._lastEcho.msg],
+                        this.widgets.message[0], true);
+
+                    this.hiddenMessages = 0;
+                }
+            }
+            else if (this._lastEcho && this.widgets.message && this.widgets.message[1] == this._lastEcho.msg) {
+                if (!(this._lastEcho.flags & this.APPEND_TO_MESSAGES))
+                    appendToMessages(this._lastEcho.data);
+                if (checkSingleLine() && !(flags & this.APPEND_TO_MESSAGES))
+                    appendToMessages(data);
+
+                flags |= this.APPEND_TO_MESSAGES;
+                this.hiddenMessages++;
             }
-            this._lastEcho = (action == this._echoLine) && data;
+            this._lastEcho = checkSingleLine() && { flags: flags, msg: data, data: arguments[0] };
         }
 
         this._lastClearable = action === this._echoLine && String(data);
         this._lastEchoTime = (flags & this.FORCE_SINGLELINE) && Date.now();
 
         if (action)
-            action.call(this, data, highlightGroup, single);
+            action.call(this, data, highlightGroup, checkSingleLine());
     },
     _lastEchoTime: 0,
 
@@ -768,7 +845,6 @@ var CommandLine = Module("commandline", {
      * pop at any time to close the prompt.
      *
      * @param {string} prompt The input prompt to use.
-     * @param {function(string)} callback
      * @param {Object} extra
      * @... {function} onChange - A function to be called with the current
      *     input every time it changes.
@@ -779,17 +855,16 @@ var CommandLine = Module("commandline", {
      * @... {string} default - The initial value that will be returned
      *     if the user presses <CR> straightaway. @default ""
      */
-    input: function _input(prompt, callback, extra) {
-        extra = extra || {};
+    input: promises.withCallbacks(function _input([callback, reject], prompt, extra={}, thing={}) {
+        if (callable(extra))
+            // Deprecated.
+            [callback, extra] = [extra, thing];
 
-        CommandPromptMode(prompt, update({ onSubmit: callback }, extra)).open();
-    },
+        CommandPromptMode(prompt, update({ onSubmit: callback, onCancel: reject }, extra)).open();
+    }),
 
     readHeredoc: function readHeredoc(end) {
-        let args;
-        commandline.inputMultiline(end, function (res) { args = res; });
-        util.waitFor(function () args !== undefined);
-        return args;
+        return util.waitFor(commandline.inputMultiline(end));
     },
 
     /**
@@ -798,17 +873,25 @@ var CommandLine = Module("commandline", {
      * callback with that string as a parameter.
      *
      * @param {string} end
-     * @param {function(string)} callback
+     * @returns {Promise<string>}
      */
     // FIXME: Buggy, especially when pasting.
-    inputMultiline: function inputMultiline(end, callback) {
+    inputMultiline: promises.withCallbacks(function inputMultiline([callback], end) {
         let cmd = this.command;
+        let self = {
+            end: "\n" + end + "\n",
+            callback: callback
+        };
+
         modes.push(modes.INPUT_MULTILINE, null, {
-            mappingSelf: {
-                end: "\n" + end + "\n",
-                callback: callback
-            }
+            holdFocus: true,
+            leave: function leave() {
+                if (!self.done)
+                    self.callback(null);
+            },
+            mappingSelf: self
         });
+
         if (cmd != false)
             this._echoLine(cmd, this.HL_NORMAL);
 
@@ -819,13 +902,13 @@ var CommandLine = Module("commandline", {
         this._autosizeMultilineInputWidget();
 
         this.timeout(function () { dactyl.focus(this.widgets.multilineInput); }, 10);
-    },
+    }),
 
     get commandMode() this.commandSession && isinstance(modes.main, modes.COMMAND_LINE),
 
     events: update(
         iter(CommandMode.prototype.events).map(
-            function ([event, handler]) [
+            ([event, handler]) => [
                 event, function (event) {
                     if (this.commandMode)
                         handler.call(this.commandSession, event);
@@ -838,7 +921,7 @@ var CommandLine = Module("commandline", {
                     event.target.blur();
                     dactyl.beep();
                 }
-            },
+            }
         }
     ),
 
@@ -864,7 +947,7 @@ var CommandLine = Module("commandline", {
 
     updateOutputHeight: deprecated("mow.resize", function updateOutputHeight(open, extra) mow.resize(open, extra)),
 
-    withOutputToString: function withOutputToString(fn, self) {
+    withOutputToString: function withOutputToString(fn, self, ...args) {
         dactyl.registerObserver("echoLine", observe, true);
         dactyl.registerObserver("echoMultiline", observe, true);
 
@@ -874,9 +957,9 @@ var CommandLine = Module("commandline", {
         }
 
         this.savingOutput = true;
-        dactyl.trapErrors.apply(dactyl, [fn, self].concat(Array.slice(arguments, 2)));
+        dactyl.trapErrors.apply(dactyl, [fn, self].concat(args));
         this.savingOutput = false;
-        return output.map(function (elem) elem instanceof Node ? util.domToString(elem) : elem)
+        return output.map(elem => elem instanceof Node ? DOM.stringify(elem) : elem)
                      .join("\n");
     }
 }, {
@@ -908,17 +991,19 @@ var CommandLine = Module("commandline", {
         save: function save() {
             if (events.feedingKeys)
                 return;
+
             let str = this.input.value;
             if (/^\s*$/.test(str))
                 return;
-            this.store = this.store.filter(function (line) (line.value || line) != str);
-            try {
-                this.store.push({ value: str, timestamp: Date.now()*1000, privateData: this.checkPrivate(str) });
-            }
-            catch (e) {
-                dactyl.reportError(e);
-            }
-            this.store = this.store.slice(-options["history"]);
+
+            let privateData = this.checkPrivate(str);
+            if (privateData == "never-save")
+                return;
+
+            let store = Array.filter(this.store, line => (line.value || line) != str);
+            dactyl.trapErrors(
+                () => store.push({ value: str, timestamp: Date.now() * 1000, privateData: privateData }));
+            this.store = store.slice(Math.max(0, store.length - options["history"]));
         },
         /**
          * @property {function} Returns whether a data item should be
@@ -936,10 +1021,15 @@ var CommandLine = Module("commandline", {
          * @param {string} val The new value.
          */
         replace: function replace(val) {
-            this.input.dactylKeyPress = undefined;
-            if (this.completions)
-                this.completions.previewClear();
-            this.input.value = val;
+            editor.withSavedValues(["skipSave"], function () {
+                editor.skipSave = true;
+
+                this.input.dactylKeyPress = undefined;
+                if (this.completions)
+                    this.completions.previewClear();
+                this.input.value = val;
+                this.session.onHistory(val);
+            }, this);
         },
 
         /**
@@ -999,82 +1089,147 @@ var CommandLine = Module("commandline", {
      * @param {Object} input
      */
     Completions: Class("Completions", {
+        UP: {},
+        DOWN: {},
+        CTXT_UP: {},
+        CTXT_DOWN: {},
+        PAGE_UP: {},
+        PAGE_DOWN: {},
+        RESET: null,
+
         init: function init(input, session) {
+            let self = this;
+
             this.context = CompletionContext(input.QueryInterface(Ci.nsIDOMNSEditableElement).editor);
-            this.context.onUpdate = this.closure._reset;
+            this.context.onUpdate = function onUpdate() { self.asyncUpdate(this); };
+
             this.editor = input.editor;
             this.input = input;
             this.session = session;
-            this.selected = null;
+
             this.wildmode = options.get("wildmode");
             this.wildtypes = this.wildmode.value;
+
             this.itemList = commandline.completionList;
-            this.itemList.setItems(this.context);
+            this.itemList.open(this.context);
 
-            dactyl.registerObserver("events.doneFeeding", this.closure.onDoneFeeding, true);
+            dactyl.registerObserver("events.doneFeeding", this.bound.onDoneFeeding, true);
 
             this.autocompleteTimer = Timer(200, 500, function autocompleteTell(tabPressed) {
-                if (events.feedingKeys)
+                if (events.feedingKeys && !tabPressed)
                     this.ignoredCount++;
-                if (options["autocomplete"].length) {
+                else if (this.session.autocomplete) {
                     this.itemList.visible = true;
                     this.complete(true, false);
                 }
             }, this);
+
             this.tabTimer = Timer(0, 0, function tabTell(event) {
-                this.tab(event.shiftKey, event.altKey && options["altwildmode"]);
+                let tabCount = this.tabCount;
+                this.tabCount = 0;
+                this.tab(tabCount, event.altKey && options["altwildmode"]);
             }, this);
         },
 
-        cleanup: function () {
-            dactyl.unregisterObserver("events.doneFeeding", this.closure.onDoneFeeding);
-            this.previewClear();
-            this.tabTimer.reset();
-            this.autocompleteTimer.reset();
-            this.itemList.visible = false;
-            this.input.dactylKeyPress = undefined;
-        },
+        tabCount: 0,
 
         ignoredCount: 0,
+
+        /**
+         * @private
+         */
         onDoneFeeding: function onDoneFeeding() {
             if (this.ignoredCount)
                 this.autocompleteTimer.flush(true);
             this.ignoredCount = 0;
         },
 
-        UP: {},
-        DOWN: {},
-        PAGE_UP: {},
-        PAGE_DOWN: {},
-        RESET: null,
+        /**
+         * @private
+         */
+        onTab: function onTab(event) {
+            this.tabCount += event.shiftKey ? -1 : 1;
+            this.tabTimer.tell(event);
+        },
 
-        lastSubstring: "",
+        get activeContexts() this.context.contextList
+                                 .filter(c => c.items.length || c.incomplete),
 
+        /**
+         * Returns the current completion string relative to the
+         * offset of the currently selected context.
+         */
         get completion() {
-            let str = commandline.command;
-            return str.substring(this.prefix.length, str.length - this.suffix.length);
+            let offset = this.selected ? this.selected[0].offset : this.start;
+            return commandline.command.slice(offset, this.caret);
         },
-        set completion(completion) {
-            this.previewClear();
 
-            // Change the completion text.
-            // The second line is a hack to deal with some substring
-            // preview corner cases.
-            let value = this.prefix + completion + this.suffix;
-            commandline.widgets.active.command.value = value;
-            this.editor.selection.focusNode.textContent = value;
+        /**
+         * Updates the input field from *offset* to {@link #caret}
+         * with the value *value*. Afterward, the caret is moved
+         * just after the end of the updated text.
+         *
+         * @param {number} offset The offset in the original input
+         *      string at which to insert *value*.
+         * @param {string} value The value to insert.
+         */
+        setCompletion: function setCompletion(offset, value) {
+            editor.withSavedValues(["skipSave"], function () {
+                editor.skipSave = true;
+                this.previewClear();
+
+                if (value == null)
+                    var [input, caret] = [this.originalValue, this.originalCaret];
+                else {
+                    input = this.getCompletion(offset, value);
+                    caret = offset + value.length;
+                }
 
-            // Reset the caret to one position after the completion.
-            this.caret = this.prefix.length + completion.length;
-            this._caret = this.caret;
+                // Change the completion text.
+                // The second line is a hack to deal with some substring
+                // preview corner cases.
+                commandline.widgets.active.command.value = input;
+                this.editor.selection.focusNode.textContent = input;
 
-            this.input.dactylKeyPress = undefined;
+                this.caret = caret;
+                this._caret = this.caret;
+
+                this.input.dactylKeyPress = undefined;
+            }, this);
+        },
+
+        /**
+         * For a given offset and completion string, returns the
+         * full input value after selecting that item.
+         *
+         * @param {number} offset The offset at which to insert the
+         *      completion.
+         * @param {string} value The value to insert.
+         * @returns {string};
+         */
+        getCompletion: function getCompletion(offset, value) {
+            return this.originalValue.substr(0, offset)
+                 + value
+                 + this.originalValue.substr(this.originalCaret);
+        },
+
+        get selected() this.itemList.selected,
+        set selected(tuple) {
+            if (!array.equals(tuple || [],
+                              this.itemList.selected || []))
+                this.itemList.select(tuple);
+
+            if (!tuple)
+                this.setCompletion(null);
+            else {
+                let [ctxt, idx] = tuple;
+                this.setCompletion(ctxt.offset, ctxt.items[idx].result);
+            }
         },
 
         get caret() this.editor.selection.getRangeAt(0).startOffset,
         set caret(offset) {
-            this.editor.selection.getRangeAt(0).setStart(this.editor.rootElement.firstChild, offset);
-            this.editor.selection.getRangeAt(0).setEnd(this.editor.rootElement.firstChild, offset);
+            this.editor.selection.collapse(this.editor.rootElement.firstChild, offset);
         },
 
         get start() this.context.allItems.start,
@@ -1085,30 +1240,193 @@ var CommandLine = Module("commandline", {
 
         get wildtype() this.wildtypes[this.wildIndex] || "",
 
+        /**
+         * Cleanup resources used by this completion session. This
+         * instance should not be used again once this method is
+         * called.
+         */
+        cleanup: function cleanup() {
+            dactyl.unregisterObserver("events.doneFeeding", this.bound.onDoneFeeding);
+            this.previewClear();
+
+            this.tabTimer.reset();
+            this.autocompleteTimer.reset();
+            if (!this.onComplete)
+                this.context.cancelAll();
+
+            this.itemList.visible = false;
+            this.input.dactylKeyPress = undefined;
+            this.hasQuit = true;
+        },
+
+        /**
+         * Run the completer.
+         *
+         * @param {boolean} show Passed to {@link #reset}.
+         * @param {boolean} tabPressed Should be set to true if, and
+         *      only if, this function is being called in response
+         *      to a <Tab> press.
+         */
         complete: function complete(show, tabPressed) {
+            this.session.ignoredCount = 0;
+
+            this.waiting = null;
             this.context.reset();
             this.context.tabPressed = tabPressed;
+
             this.session.complete(this.context);
             if (!this.session.active)
                 return;
-            this.context.updateAsync = true;
+
             this.reset(show, tabPressed);
             this.wildIndex = 0;
             this._caret = this.caret;
         },
 
+        /**
+         * Clear any preview string and cancel any pending
+         * asynchronous context. Called when there is further input
+         * to be processed.
+         */
+        clear: function clear() {
+            this.context.cancelAll();
+            this.wildIndex = -1;
+            this.previewClear();
+        },
+
+        /**
+         * Saves the current input state. To be called before an
+         * item is selected in a new set of completion responses.
+         * @private
+         */
+        saveInput: function saveInput() {
+            this.originalValue = this.context.value;
+            this.originalCaret = this.caret;
+        },
+
+        /**
+         * Resets the completion state.
+         *
+         * @param {boolean} show If true and options allow the
+         *      completion list to be shown, show it.
+         */
+        reset: function reset(show) {
+            this.waiting = null;
+            this.wildIndex = -1;
+
+            this.saveInput();
+
+            if (show) {
+                this.itemList.update();
+                this.context.updateAsync = true;
+                if (this.haveType("list"))
+                    this.itemList.visible = true;
+                this.wildIndex = 0;
+            }
+
+            this.preview();
+        },
+
+        /**
+         * Calls when an asynchronous completion context has new
+         * results to return.
+         *
+         * @param {CompletionContext} context The changed context.
+         * @private
+         */
+        asyncUpdate: function asyncUpdate(context) {
+            if (this.hasQuit) {
+                let item = this.getItem(this.waiting);
+                if (item && this.waiting && this.onComplete) {
+                    util.trapErrors("onComplete", this,
+                                    this.getCompletion(this.waiting[0].offset,
+                                                       item.result));
+                    this.waiting = null;
+                    this.context.cancelAll();
+                }
+                return;
+            }
+
+            let value = this.editor.selection.focusNode.textContent;
+            this.saveInput();
+
+            if (this.itemList.visible)
+                this.itemList.updateContext(context);
+
+            if (this.waiting && this.waiting[0] == context)
+                this.select(this.waiting);
+            else if (!this.waiting) {
+                let cursor = this.selected;
+                if (cursor && cursor[0] == context) {
+                    let item = this.getItem(cursor);
+                    if (!item || this.completion != item.result)
+                        this.itemList.select(null);
+                }
+
+                this.preview();
+            }
+        },
+
+        /**
+         * Returns true if the currently selected 'wildmode' index
+         * has the given completion type.
+         */
         haveType: function haveType(type)
             this.wildmode.checkHas(this.wildtype, type == "first" ? "" : type),
 
+        /**
+         * Returns the completion item for the given selection
+         * tuple.
+         *
+         * @param {[CompletionContext,number]} tuple The spec of the
+         *      item to return.
+         *      @default {@link #selected}
+         * @returns {object}
+         */
+        getItem: function getItem(tuple=this.selected)
+            tuple && tuple[0] && tuple[0].items[tuple[1]],
+
+        /**
+         * Returns a tuple representing the next item, at the given
+         * *offset*, from *tuple*.
+         *
+         * @param {[CompletionContext,number]} tuple The offset from
+         *      which to search.
+         *      @default {@link #selected}
+         * @param {number} offset The positive or negative offset to
+         *      find.
+         *      @default 1
+         * @param {boolean} noWrap If true, and the search would
+         *      wrap, return null.
+         */
+        nextItem: function nextItem(tuple, offset, noWrap) {
+            if (tuple === undefined)
+                tuple = this.selected;
+
+            return this.itemList.getRelativeItem(offset || 1, tuple, noWrap);
+        },
+
+        /**
+         * The last previewed substring.
+         * @private
+         */
+        lastSubstring: "",
+
+        /**
+         * Displays a preview of the text provided by the next <Tab>
+         * press if the current input is an anchored substring of
+         * that result.
+         */
         preview: function preview() {
             this.previewClear();
-            if (this.wildIndex < 0 || this.suffix || !this.items.length)
+            if (this.wildIndex < 0 || this.caret < this.input.value.length
+                    || !this.activeContexts.length || this.waiting)
                 return;
 
             let substring = "";
             switch (this.wildtype.replace(/.*:/, "")) {
             case "":
-                substring = this.items[0].result;
+                var cursor = this.nextItem(null);
                 break;
             case "longest":
                 if (this.items.length > 1) {
@@ -1117,14 +1435,14 @@ var CommandLine = Module("commandline", {
                 }
                 // Fallthrough
             case "full":
-                let item = this.items[this.selected != null ? this.selected + 1 : 0];
-                if (item)
-                    substring = item.result;
+                cursor = this.nextItem();
                 break;
             }
+            if (cursor)
+                substring = this.getItem(cursor).result;
 
             // Don't show 1-character substrings unless we've just hit backspace
-            if (substring.length < 2 && this.lastSubstring.indexOf(substring) !== 0)
+            if (substring.length < 2 && this.lastSubstring.indexOf(substring))
                 return;
 
             this.lastSubstring = substring;
@@ -1132,21 +1450,26 @@ var CommandLine = Module("commandline", {
             let value = this.completion;
             if (util.compareIgnoreCase(value, substring.substr(0, value.length)))
                 return;
+
             substring = substring.substr(value.length);
             this.removeSubstring = substring;
 
-            let node = util.xmlToDom(<span highlight="Preview">{substring}</span>,
-                document);
-            let start = this.caret;
-            this.editor.insertNode(node, this.editor.rootElement, 1);
-            this.caret = start;
+            let node = DOM.fromJSON(["span", { highlight: "Preview" }, substring],
+                                    document);
+
+            this.withSavedValues(["caret"], function () {
+                this.editor.insertNode(node, this.editor.rootElement, 1);
+            });
         },
 
+        /**
+         * Clears the currently displayed next-<Tab> preview string.
+         */
         previewClear: function previewClear() {
             let node = this.editor.rootElement.firstChild;
             if (node && node.nextSibling) {
                 try {
-                    this.editor.deleteNode(node.nextSibling);
+                    DOM(node.nextSibling).remove();
                 }
                 catch (e) {
                     node.nextSibling.textContent = "";
@@ -1161,104 +1484,97 @@ var CommandLine = Module("commandline", {
             delete this.removeSubstring;
         },
 
-        reset: function reset(show) {
-            this.wildIndex = -1;
-
-            this.prefix = this.context.value.substring(0, this.start);
-            this.value  = this.context.value.substring(this.start, this.caret);
-            this.suffix = this.context.value.substring(this.caret);
-
-            if (show) {
-                this.itemList.reset();
-                if (this.haveType("list"))
-                    this.itemList.visible = true;
-                this.selected = null;
-                this.wildIndex = 0;
-            }
-
-            this.preview();
-        },
+        /**
+         * Selects a completion based on the value of *idx*.
+         *
+         * @param {[CompletionContext,number]|const object} The
+         *      (context,index) tuple of the item to select, or an
+         *      offset constant from this object.
+         * @param {number} count When given an offset constant,
+         *      select *count* units.
+         *      @default 1
+         * @param {boolean} fromTab If true, this function was
+         *      called by {@link #tab}.
+         *      @default false
+         *      @private
+         */
+        select: function select(idx, count=1, fromTab=false) {
+            switch (idx) {
+            case this.UP:
+            case this.DOWN:
+                idx = this.nextItem(this.waiting || this.selected,
+                                    idx == this.UP ? -count : count,
+                                    true);
+                break;
 
-        _reset: function _reset() {
-            let value = this.editor.selection.focusNode.textContent;
-            this.prefix = value.substring(0, this.start);
-            this.value  = value.substring(this.start, this.caret);
-            this.suffix = value.substring(this.caret);
+            case this.CTXT_UP:
+            case this.CTXT_DOWN:
+                let groups = this.itemList.activeGroups;
+                let i = Math.max(0, groups.indexOf(this.itemList.selectedGroup));
 
-            this.itemList.reset();
-            this.itemList.selectItem(this.selected);
+                i += idx == this.CTXT_DOWN ? 1 : -1;
+                i %= groups.length;
+                if (i < 0)
+                    i += groups.length;
 
-            this.preview();
-        },
-
-        select: function select(idx) {
-            switch (idx) {
-            case this.UP:
-                if (this.selected == null)
-                    idx = -2;
-                else
-                    idx = this.selected - 1;
+                var position = 0;
+                idx = [groups[i].context, 0];
                 break;
-            case this.DOWN:
-                if (this.selected == null)
-                    idx = 0;
-                else
-                    idx = this.selected + 1;
+
+            case this.PAGE_UP:
+            case this.PAGE_DOWN:
+                idx = this.itemList.getRelativePage(idx == this.PAGE_DOWN ? 1 : -1);
                 break;
+
             case this.RESET:
                 idx = null;
                 break;
+
             default:
-                idx = Math.constrain(idx, 0, this.items.length - 1);
                 break;
             }
 
-            if (idx == -1 || this.items.length && idx >= this.items.length || idx == null) {
-                // Wrapped. Start again.
-                this.selected = null;
-                this.completion = this.value;
-            }
-            else {
-                // Wait for contexts to complete if necessary.
-                // FIXME: Need to make idx relative to individual contexts.
-                let list = this.context.contextList;
-                if (idx == -2)
-                    list = list.slice().reverse();
-                let n = 0;
-                try {
-                    this.waiting = true;
-                    for (let [, context] in Iterator(list)) {
-                        let done = function done() !(idx >= n + context.items.length || idx == -2 && !context.items.length);
-
-                        util.waitFor(function () !context.incomplete || done())
-                        if (done())
-                            break;
+            if (!fromTab)
+                this.wildIndex = this.wildtypes.length - 1;
 
-                        n += context.items.length;
-                    }
-                }
-                finally {
-                    this.waiting = false;
+            if (idx && idx[1] >= idx[0].items.length) {
+                if (!idx[0].incomplete)
+                    this.waiting = null;
+                else {
+                    this.waiting = idx;
+                    statusline.progress = _("completion.waitingForResults");
                 }
+                return;
+            }
 
-                // See previous FIXME. This will break if new items in
-                // a previous context come in.
-                if (idx < 0)
-                    idx = this.items.length - 1;
-                if (this.items.length == 0)
-                    return;
+            this.waiting = null;
 
-                this.selected = idx;
-                this.completion = this.items[idx].result;
-            }
+            this.itemList.select(idx, null, position);
+            this.selected = idx;
 
-            this.itemList.selectItem(idx);
-        },
+            this.preview();
 
-        tabs: [],
+            if (this.selected == null)
+                statusline.progress = "";
+            else
+                statusline.progress = _("completion.matchIndex",
+                                        this.itemList.getOffset(idx),
+                                        this.itemList.itemCount);
+        },
 
-        tab: function tab(reverse, wildmode) {
+        /**
+         * Selects a completion result based on the 'wildmode'
+         * option, or the value of the *wildmode* parameter.
+         *
+         * @param {number} offset The positive or negative number of
+         *      tab presses to process.
+         * @param {[string]} wildmode A 'wildmode' value to
+         *      substitute for the value of the 'wildmode' option.
+         *      @optional
+         */
+        tab: function tab(offset, wildmode) {
             this.autocompleteTimer.flush();
+            this.ignoredCount = 0;
 
             if (this._caret != this.caret)
                 this.reset();
@@ -1268,27 +1584,28 @@ var CommandLine = Module("commandline", {
             if (this.context.waitingForTab || this.wildIndex == -1)
                 this.complete(true, true);
 
-            this.tabs.push([reverse, wildmode || options["wildmode"]]);
-            if (this.waiting)
-                return;
-
-            while (this.tabs.length) {
-                [reverse, this.wildtypes] = this.tabs.shift();
+            this.wildtypes = wildmode || options["wildmode"];
+            let count = Math.abs(offset);
+            let steps = Math.constrain(this.wildtypes.length - this.wildIndex,
+                                       1, count);
+            count = Math.max(1, count - steps);
 
+            while (steps--) {
                 this.wildIndex = Math.min(this.wildIndex, this.wildtypes.length - 1);
                 switch (this.wildtype.replace(/.*:/, "")) {
                 case "":
-                    this.select(0);
+                    this.select(this.nextItem(null));
                     break;
                 case "longest":
-                    if (this.items.length > 1) {
+                    if (this.itemList.itemCount > 1) {
                         if (this.substring && this.substring.length > this.completion.length)
-                            this.completion = this.substring;
+                            this.setCompletion(this.start, this.substring);
                         break;
                     }
                     // Fallthrough
                 case "full":
-                    this.select(reverse ? this.UP : this.DOWN);
+                    let c = steps ? 1 : count;
+                    this.select(offset < 0 ? this.UP : this.DOWN, c, true);
                     break;
                 }
 
@@ -1296,15 +1613,9 @@ var CommandLine = Module("commandline", {
                     this.itemList.visible = true;
 
                 this.wildIndex++;
-                this.preview();
-
-                if (this.selected == null)
-                    statusline.progress = "";
-                else
-                    statusline.progress = "match " + (this.selected + 1) + " of " + this.items.length;
             }
 
-            if (this.items.length == 0)
+            if (this.items.length == 0 && !this.waiting)
                 dactyl.beep();
         }
     }),
@@ -1324,12 +1635,14 @@ var CommandLine = Module("commandline", {
         arg = dactyl.userEval(arg);
         if (isObject(arg))
             arg = util.objectToString(arg, useColor);
-        else
-            arg = String(arg);
+        else if (callable(arg))
+            arg = String.replace(arg, "/* use strict */ \n", "/* use strict */ ");
+        else if (!isString(arg) && useColor)
+            arg = template.highlight(arg);
         return arg;
     }
 }, {
-    commands: function init_commands() {
+    commands: function initCommands() {
         [
             {
                 name: "ec[ho]",
@@ -1367,10 +1680,10 @@ var CommandLine = Module("commandline", {
                     commandline.echo(message.message, message.highlight, commandline.FORCE_SINGLELINE);
                 }
                 else if (commandline._messageHistory.length > 1) {
-                    XML.ignoreWhitespace = false;
                     commandline.commandOutput(
-                        template.map(commandline._messageHistory.messages, function (message)
-                            <div highlight={message.highlight + " Message"}>{message.message}</div>));
+                        template.map(commandline._messageHistory.messages, message =>
+                           ["div", { highlight: message.highlight + " Message" },
+                               message.message]));
                 }
             },
             { argCount: "0" });
@@ -1383,7 +1696,7 @@ var CommandLine = Module("commandline", {
         commands.add(["sil[ent]"],
             "Run a command silently",
             function (args) {
-                commandline.runSilently(function () commands.execute(args[0] || "", null, true));
+                commandline.runSilently(() => { commands.execute(args[0] || "", null, true); });
             }, {
                 completer: function (context) completion.ex(context),
                 literal: 0,
@@ -1391,6 +1704,8 @@ var CommandLine = Module("commandline", {
             });
     },
     modes: function initModes() {
+        initModes.require("editor");
+
         modes.addMode("COMMAND_LINE", {
             char: "c",
             description: "Active when the command line is focused",
@@ -1409,13 +1724,14 @@ var CommandLine = Module("commandline", {
         });
 
         modes.addMode("INPUT_MULTILINE", {
+            description: "Active when the command line's multiline input buffer is open",
             bases: [modes.INSERT]
         });
     },
-    mappings: function init_mappings() {
+    mappings: function initMappings() {
 
         mappings.add([modes.COMMAND],
-            [":"], "Enter command-line mode",
+            [":"], "Enter Command Line mode",
             function () { CommandExMode().open(""); });
 
         mappings.add([modes.INPUT_MULTILINE],
@@ -1427,16 +1743,24 @@ var CommandLine = Module("commandline", {
 
                 let index = text.indexOf(self.end);
                 if (index >= 0) {
+                    self.done = true;
                     text = text.substring(1, index);
                     modes.pop();
 
-                    return function () self.callback.call(commandline, text);
+                    return () => self.callback.call(commandline, text);
                 }
                 return Events.PASS;
             });
 
-        let bind = function bind()
-            mappings.add.apply(mappings, [[modes.COMMAND_LINE]].concat(Array.slice(arguments)))
+        let bind = function bind(...args) mappings.add.apply(mappings, [[modes.COMMAND_LINE]].concat(args));
+
+        bind(["<Esc>", "<C-[>"], "Stop waiting for completions or exit Command Line mode",
+             function ({ self }) {
+                 if (self.completions && self.completions.waiting)
+                     self.completions.waiting = null;
+                 else
+                     return Events.PASS;
+             });
 
         // Any "non-keyword" character triggers abbreviation expansion
         // TODO: Add "<CR>" and "<Tab>" to this list
@@ -1454,6 +1778,9 @@ var CommandLine = Module("commandline", {
 
         bind(["<Return>", "<C-j>", "<C-m>"], "Accept the current input",
              function ({ self }) {
+                 if (self.completions)
+                     self.completions.tabTimer.flush();
+
                  let command = commandline.command;
 
                  self.accepted = true;
@@ -1461,10 +1788,10 @@ var CommandLine = Module("commandline", {
              });
 
         [
-            [["<Up>", "<A-p>"],                   "previous matching", true,  true],
-            [["<S-Up>", "<C-p>", "<PageUp>"],     "previous",          true,  false],
-            [["<Down>", "<A-n>"],                 "next matching",     false, true],
-            [["<S-Down>", "<C-n>", "<PageDown>"], "next",              false, false]
+            [["<Up>", "<A-p>", "<cmd-prev-match>"],   "previous matching", true,  true],
+            [["<S-Up>", "<C-p>", "<cmd-prev>"],       "previous",          true,  false],
+            [["<Down>", "<A-n>", "<cmd-next-match>"], "next matching",     false, true],
+            [["<S-Down>", "<C-n>", "<cmd-next>"],     "next",              false, false]
         ].forEach(function ([keys, desc, up, search]) {
             bind(keys, "Recall the " + desc + " command line from the history list",
                  function ({ self }) {
@@ -1473,16 +1800,50 @@ var CommandLine = Module("commandline", {
                  });
         });
 
-        bind(["<A-Tab>", "<Tab>"], "Select the next matching completion item",
+        bind(["<A-Tab>", "<Tab>", "<A-compl-next>", "<compl-next>"],
+             "Select the next matching completion item",
              function ({ keypressEvents, self }) {
                  dactyl.assert(self.completions);
-                 self.completions.tabTimer.tell(keypressEvents[0]);
+                 self.completions.onTab(keypressEvents[0]);
              });
 
-        bind(["<A-S-Tab>", "<S-Tab>"], "Select the previous matching completion item",
+        bind(["<A-S-Tab>", "<S-Tab>", "<A-compl-prev>", "<compl-prev>"],
+             "Select the previous matching completion item",
              function ({ keypressEvents, self }) {
                  dactyl.assert(self.completions);
-                 self.completions.tabTimer.tell(keypressEvents[0]);
+                 self.completions.onTab(keypressEvents[0]);
+             });
+
+        bind(["<C-Tab>", "<A-f>", "<compl-next-group>"],
+             "Select the next matching completion group",
+             function ({ keypressEvents, self }) {
+                 dactyl.assert(self.completions);
+                 self.completions.tabTimer.flush();
+                 self.completions.select(self.completions.CTXT_DOWN);
+             });
+
+        bind(["<C-S-Tab>", "<A-S-f>", "<compl-prev-group>"],
+             "Select the previous matching completion group",
+             function ({ keypressEvents, self }) {
+                 dactyl.assert(self.completions);
+                 self.completions.tabTimer.flush();
+                 self.completions.select(self.completions.CTXT_UP);
+             });
+
+        bind(["<C-f>", "<PageDown>", "<compl-next-page>"],
+             "Select the next page of completions",
+             function ({ keypressEvents, self }) {
+                 dactyl.assert(self.completions);
+                 self.completions.tabTimer.flush();
+                 self.completions.select(self.completions.PAGE_DOWN);
+             });
+
+        bind(["<C-b>", "<PageUp>", "<compl-prev-page>"],
+             "Select the previous page of completions",
+             function ({ keypressEvents, self }) {
+                 dactyl.assert(self.completions);
+                 self.completions.tabTimer.flush();
+                 self.completions.select(self.completions.PAGE_UP);
              });
 
         bind(["<BS>", "<C-h>"], "Delete the previous character",
@@ -1496,7 +1857,7 @@ var CommandLine = Module("commandline", {
         bind(["<C-]>", "<C-5>"], "Expand command line abbreviation",
              function () { editor.expandAbbreviation(modes.COMMAND_LINE); });
     },
-    options: function init_options() {
+    options: function initOptions() {
         options.add(["history", "hi"],
             "Number of Ex commands and search patterns to store in the command-line history",
             "number", 500,
@@ -1512,7 +1873,7 @@ var CommandLine = Module("commandline", {
             "number", 100,
             { validator: function (value) value >= 0 });
     },
-    sanitizer: function init_sanitizer() {
+    sanitizer: function initSanitizer() {
         sanitizer.addItem("commandline", {
             description: "Command-line and search history",
             persistent: true,
@@ -1520,10 +1881,10 @@ var CommandLine = Module("commandline", {
                 let store = commandline._store;
                 for (let [k, v] in store) {
                     if (k == "command")
-                        store.set(k, v.filter(function (item)
+                        store.set(k, v.filter(item =>
                             !(timespan.contains(item.timestamp) && (!host || commands.hasDomain(item.value, host)))));
                     else if (!host)
-                        store.set(k, v.filter(function (item) !timespan.contains(item.timestamp)));
+                        store.set(k, v.filter(item => !timespan.contains(item.timestamp)));
                 }
             }
         });
@@ -1531,288 +1892,556 @@ var CommandLine = Module("commandline", {
         sanitizer.addItem("history", {
             action: function (timespan, host) {
                 commandline._store.set("command",
-                    commandline._store.get("command", []).filter(function (item)
+                    commandline._store.get("command", []).filter(item =>
                         !(timespan.contains(item.timestamp) && (host ? commands.hasDomain(item.value, host)
                                                                      : item.privateData))));
 
-                commandline._messageHistory.filter(function (item) !timespan.contains(item.timestamp * 1000) ||
-                    !item.domains && !item.privateData ||
-                    host && (!item.domains || !item.domains.some(function (d) util.isSubdomain(d, host))));
+                commandline._messageHistory.filter(item =>
+                    ( !timespan.contains(item.timestamp * 1000)
+                   || !item.domains && !item.privateData
+                   || host && ( !item.domains
+                             || !item.domains.some(d => util.isSubdomain(d, host)))));
             }
         });
         sanitizer.addItem("messages", {
             description: "Saved :messages",
             action: function (timespan, host) {
-                commandline._messageHistory.filter(function (item) !timespan.contains(item.timestamp * 1000) ||
-                    host && (!item.domains || !item.domains.some(function (d) util.isSubdomain(d, host))));
+                commandline._messageHistory.filter(item =>
+                    ( !timespan.contains(item.timestamp * 1000)
+                   || host && ( !item.domains
+                             || !item.domains.some(d => util.isSubdomain(d, host)))));
             }
         });
     }
 });
 
 /**
- * The list which is used for the completion box (and QuickFix window in
- * future).
+ * The list which is used for the completion box.
  *
  * @param {string} id The id of the iframe which will display the list. It
  *     must be in its own container element, whose height it will update as
  *     necessary.
  */
+
 var ItemList = Class("ItemList", {
-    init: function init(id) {
-        this._completionElements = [];
-
-        var iframe = document.getElementById(id);
-
-        this._doc = iframe.contentDocument;
-        this._win = iframe.contentWindow;
-        this._container = iframe.parentNode;
-
-        this._doc.documentElement.id = id + "-top";
-        this._doc.body.id = id + "-content";
-        this._doc.body.className = iframe.className + "-content";
-        this._doc.body.appendChild(this._doc.createTextNode(""));
-        this._doc.body.style.borderTop = "1px solid black"; // FIXME: For cases where completions/MOW are shown at once, or ls=0. Should use :highlight.
-
-        this._items = null;
-        this._startIndex = -1; // The index of the first displayed item
-        this._endIndex = -1;   // The index one *after* the last displayed item
-        this._selIndex = -1;   // The index of the currently selected element
-        this._div = null;
-        this._divNodes = {};
-        this._minHeight = 0;
+    CONTEXT_LINES: 2,
+
+    init: function init(frame) {
+        this.frame = frame;
+
+        this.doc = frame.contentDocument;
+        this.win = frame.contentWindow;
+        this.body = this.doc.body;
+        this.container = frame.parentNode;
+
+        highlight.highlightNode(this.doc.body, "Comp");
+
+        this._onResize = Timer(20, 400, function _onResize(event) {
+            if (this.visible)
+                this.onResize(event);
+        }, this);
+        this._resize = Timer(20, 400, function _resize(flags) {
+            if (this.visible)
+                this.resize(flags);
+        }, this);
+
+        DOM(this.win).resize(this._onResize.bound.tell);
     },
 
-    _dom: function _dom(xml, map) util.xmlToDom(xml instanceof XML ? xml : <>{xml}</>, this._doc, map),
+    get rootXML()
+        ["div", { highlight: "Normal", style: "white-space: nowrap", key: "root" },
+            ["div", { key: "wrapper" },
+                ["div", { highlight: "Completions", key: "noCompletions" },
+                    ["span", { highlight: "Title" },
+                        _("completion.noCompletions")]],
+                ["div", { key: "completions" }]],
+
+            ["div", { highlight: "Completions" },
+                template.map(util.range(0, options["maxitems"] * 2), i =>
+                    ["div", { highlight: "CompItem NonText" },
+                        "~"])]],
+
+    get itemCount() this.context.contextList
+                        .reduce((acc, ctxt) => acc + ctxt.items.length, 0),
+
+    get visible() !this.container.collapsed,
+    set visible(val) this.container.collapsed = !val,
+
+    get activeGroups() this.context.contextList
+                           .filter(c => c.items.length || c.message || c.incomplete)
+                           .map(this.getGroup, this),
+
+    get selected() let (g = this.selectedGroup) g && g.selectedIdx != null
+        ? [g.context, g.selectedIdx] : null,
+
+    getRelativeItem: function getRelativeItem(offset, tuple, noWrap) {
+        let groups = this.activeGroups;
+        if (!groups.length)
+            return null;
+
+        let group = this.selectedGroup || groups[0];
+        let start = group.selectedIdx || 0;
+        if (tuple === null) { // Kludge.
+            if (offset > 0)
+                tuple = [this.activeGroups[0], -1];
+            else {
+                let group = this.activeGroups.slice(-1)[0];
+                tuple = [group, group.itemCount];
+            }
+        }
+        if (tuple)
+            [group, start] = tuple;
 
-    _autoSize: function _autoSize() {
-        if (!this._div)
-            return;
+        group = this.getGroup(group);
 
-        if (this._container.collapsed)
-            this._div.style.minWidth = document.getElementById("dactyl-commandline").scrollWidth + "px";
+        start = (group.offsets.start + start + offset);
+        if (!noWrap)
+            start %= this.itemCount || 1;
+        if (start < 0 && (!noWrap || arguments[1] === null))
+            start += this.itemCount;
 
-        this._minHeight = Math.max(this._minHeight,
-            this._win.scrollY + this._divNodes.completions.getBoundingClientRect().bottom);
+        if (noWrap && offset > 0) {
+            // Check if we've passed any incomplete contexts
 
-        if (this._container.collapsed)
-            this._div.style.minWidth = "";
+            let i = groups.indexOf(group);
+            util.assert(i >= 0, undefined, false);
+            for (; i < groups.length; i++) {
+                let end = groups[i].offsets.start + groups[i].itemCount;
+                if (start >= end && groups[i].context.incomplete)
+                    return [groups[i].context, start - groups[i].offsets.start];
 
-        // FIXME: Belongs elsewhere.
-        mow.resize(false, Math.max(0, this._minHeight - this._container.height));
+                if (start >= end);
+                    break;
+            }
+        }
 
-        this._container.height = this._minHeight;
-        this._container.height -= mow.spaceNeeded;
-        mow.resize(false);
-        this.timeout(function () {
-            this._container.height -= mow.spaceNeeded;
-        });
-    },
+        if (start < 0 || start >= this.itemCount)
+            return null;
 
-    _getCompletion: function _getCompletion(index) this._completionElements.snapshotItem(index - this._startIndex),
-
-    _init: function _init() {
-        this._div = this._dom(
-            <div class="ex-command-output" highlight="Normal" style="white-space: nowrap">
-                <div highlight="Completions" key="noCompletions"><span highlight="Title">No Completions</span></div>
-                <div key="completions"/>
-                <div highlight="Completions">
-                {
-                    template.map(util.range(0, options["maxitems"] * 2), function (i)
-                    <div highlight="CompItem NonText">
-                        <li>~</li>
-                    </div>)
-                }
-                </div>
-            </div>, this._divNodes);
-        this._doc.body.replaceChild(this._div, this._doc.body.firstChild);
-        util.scrollIntoView(this._div, true);
-
-        this._items.contextList.forEach(function init_eachContext(context) {
-            delete context.cache.nodes;
-            if (!context.items.length && !context.message && !context.incomplete)
-                return;
-            context.cache.nodes = [];
-            this._dom(<div key="root" highlight="CompGroup">
-                    <div highlight="Completions">
-                        { context.createRow(context.title || [], "CompTitle") }
-                    </div>
-                    <div highlight="CompTitleSep"/>
-                    <div key="message" highlight="CompMsg"/>
-                    <div key="up" highlight="CompLess"/>
-                    <div key="items" highlight="Completions"/>
-                    <div key="waiting" highlight="CompMsg">{ItemList.WAITING_MESSAGE}</div>
-                    <div key="down" highlight="CompMore"/>
-                </div>, context.cache.nodes);
-            this._divNodes.completions.appendChild(context.cache.nodes.root);
-        }, this);
+        group = groups.find(g => let (i = start - g.offsets.start) i >= 0 && i < g.itemCount);
+        return [group.context, start - group.offsets.start];
+    },
 
-        this.timeout(this._autoSize);
+    getRelativePage: function getRelativePage(offset, tuple, noWrap) {
+        offset *= this.maxItems;
+        // Try once with wrapping disabled.
+        let res = this.getRelativeItem(offset, tuple, true);
+
+        if (!res) {
+            // Wrapped.
+            let sign = offset / Math.abs(offset);
+
+            let off = this.getOffset(tuple === null ? null : tuple || this.selected);
+            if (off == null)
+                // Unselected. Defer to getRelativeItem.
+                res = this.getRelativeItem(offset, null, noWrap);
+            else if (~[0, this.itemCount - 1].indexOf(off))
+                // At start or end. Jump to other end.
+                res = this.getRelativeItem(sign, null, noWrap);
+            else
+                // Wrapped. Go to beginning or end.
+                res = this.getRelativeItem(-sign, null);
+        }
+        return res;
     },
 
     /**
-     * Uses the entries in "items" to fill the listbox and does incremental
-     * filling to speed up things.
+     * Initializes the ItemList for use with a new root completion
+     * context.
      *
-     * @param {number} offset Start at this index and show options["maxitems"].
+     * @param {CompletionContext} context The new root context.
      */
-    _fill: function _fill(offset) {
-        XML.ignoreWhiteSpace = false;
-        let diff = offset - this._startIndex;
-        if (this._items == null || offset == null || diff == 0 || offset < 0)
-            return false;
+    open: function open(context) {
+        this.context = context;
+        this.nodes = {};
+        this.container.height = 0;
+        this.minHeight = 0;
+        this.maxItems  = options["maxitems"];
 
-        this._startIndex = offset;
-        this._endIndex = Math.min(this._startIndex + options["maxitems"], this._items.allItems.items.length);
-
-        let haveCompletions = false;
-        let off = 0;
-        let end = this._startIndex + options["maxitems"];
-        function getRows(context) {
-            function fix(n) Math.constrain(n, 0, len);
-            let len = context.items.length;
-            let start = off;
-            end -= !!context.message + context.incomplete;
-            off += len;
-
-            let s = fix(offset - start), e = fix(end - start);
-            return [s, e, context.incomplete && e >= offset && off - 1 < end];
+        DOM(this.rootXML, this.doc, this.nodes)
+            .appendTo(DOM(this.body).empty());
+
+        this.update();
+    },
+
+    /**
+     * Updates the absolute result indices of all groups after
+     * results have changed.
+     * @private
+     */
+    updateOffsets: function updateOffsets() {
+        let total = this.itemCount;
+        let count = 0;
+        for (let group in values(this.activeGroups)) {
+            group.offsets = { start: count, end: total - count - group.itemCount };
+            count += group.itemCount;
         }
+    },
 
-        this._items.contextList.forEach(function fill_eachContext(context) {
-            let nodes = context.cache.nodes;
-            if (!nodes)
-                return;
-            haveCompletions = true;
+    /**
+     * Updates the set and state of active groups for a new set of
+     * completion results.
+     */
+    update: function update() {
+        DOM(this.nodes.completions).empty();
+
+        let container = DOM(this.nodes.completions);
+        let groups = this.activeGroups;
+        for (let group in values(groups)) {
+            group.reset();
+            container.append(group.nodes.root);
+        }
 
-            let root = nodes.root;
-            let items = nodes.items;
-            let [start, end, waiting] = getRows(context);
+        this.updateOffsets();
 
-            if (context.message) {
-                nodes.message.textContent = "";
-                nodes.message.appendChild(this._dom(context.message));
-            }
-            nodes.message.style.display = context.message ? "block" : "none";
-            nodes.waiting.style.display = waiting ? "block" : "none";
-            nodes.up.style.opacity = "0";
-            nodes.down.style.display = "none";
-
-            for (let [i, row] in Iterator(context.getRows(start, end, this._doc)))
-                nodes[i] = row;
-            for (let [i, row] in array.iterItems(nodes)) {
-                if (!row)
-                    continue;
-                let display = (i >= start && i < end);
-                if (display && row.parentNode != items) {
-                    do {
-                        var next = nodes[++i];
-                        if (next && next.parentNode != items)
-                            next = null;
-                    }
-                    while (!next && i < end)
-                    items.insertBefore(row, next);
-                }
-                else if (!display && row.parentNode == items)
-                    items.removeChild(row);
-            }
-            if (context.items.length == 0)
-                return;
-            nodes.up.style.opacity = (start == 0) ? "0" : "1";
-            if (end != context.items.length)
-                nodes.down.style.display = "block";
-            else
-                nodes.up.style.display = "block";
-            if (start == end) {
-                nodes.up.style.display = "none";
-                nodes.down.style.display = "none";
-            }
-        }, this);
+        DOM(this.nodes.noCompletions).toggle(!groups.length);
 
-        this._divNodes.noCompletions.style.display = haveCompletions ? "none" : "block";
+        this.startPos = null;
+        this.select(groups[0] && groups[0].context, null);
 
-        this._completionElements = util.evaluateXPath("//xhtml:div[@dactyl:highlight='CompItem']", this._doc);
+        this._resize.tell();
+    },
 
-        return true;
+    /**
+     * Updates the group for *context* after an asynchronous update
+     * push.
+     *
+     * @param {CompletionContext} context The context which has
+     *      changed.
+     */
+    updateContext: function updateContext(context) {
+        let group = this.getGroup(context);
+        this.updateOffsets();
+
+        if (~this.activeGroups.indexOf(group))
+            group.update();
+        else {
+            DOM(group.nodes.root).remove();
+            if (this.selectedGroup == group)
+                this.selectedGroup = null;
+        }
+
+        let g = this.selectedGroup;
+        this.select(g, g && g.selectedIdx);
     },
 
-    clear: function clear() { this.setItems(); this._doc.body.innerHTML = ""; },
-    get visible() !this._container.collapsed,
-    set visible(val) this._container.collapsed = !val,
+    /**
+     * Updates the DOM to reflect the current state of all groups.
+     * @private
+     */
+    draw: function draw() {
+        for (let group in values(this.activeGroups))
+            group.draw();
+
+        // We need to collect all of the rescrolling functions in
+        // one go, as the height calculation that they need to do
+        // would force an expensive reflow after each call due to
+        // DOM modifications, otherwise.
+        this.activeGroups.filter(g => !g.collapsed)
+            .map(g => g.rescrollFunc)
+            .forEach(call);
+
+        if (!this.selected)
+            this.win.scrollTo(0, 0);
+
+        this._resize.tell(ItemList.RESIZE_BRIEF);
+    },
 
-    reset: function reset(brief) {
-        this._startIndex = this._endIndex = this._selIndex = -1;
-        this._div = null;
-        if (!brief)
-            this.selectItem(-1);
+    onResize: function onResize() {
+        if (this.selectedGroup)
+            this.selectedGroup.rescrollFunc();
     },
 
-    // if @param selectedItem is given, show the list and select that item
-    setItems: function setItems(newItems, selectedItem) {
-        if (this._selItem > -1)
-            this._getCompletion(this._selItem).removeAttribute("selected");
-        if (this._container.collapsed) {
-            this._minHeight = 0;
-            this._container.height = 0;
-        }
-        this._startIndex = this._endIndex = this._selIndex = -1;
-        this._items = newItems;
-        this.reset(true);
-        if (typeof selectedItem == "number") {
-            this.selectItem(selectedItem);
-            this.visible = true;
+    minHeight: 0,
+
+    /**
+     * Resizes the list after an update.
+     * @private
+     */
+    resize: function resize(flags) {
+        let { completions, root } = this.nodes;
+
+        if (!this.visible)
+            root.style.minWidth = document.getElementById("dactyl-commandline").scrollWidth + "px";
+
+        let { minHeight } = this;
+        if (mow.visible && this.isAboveMow) // Kludge.
+            minHeight -= mow.wantedHeight;
+
+        let needed = this.win.scrollY + DOM(completions).rect.bottom;
+        this.minHeight = Math.max(minHeight, needed);
+
+        if (!this.visible)
+            root.style.minWidth = "";
+
+        let height = this.visible ? parseFloat(this.container.height) : 0;
+        if (this.minHeight <= minHeight || !mow.visible)
+            this.container.height = Math.min(this.minHeight,
+                                             height + config.outputHeight - mow.spaceNeeded);
+        else {
+            // FIXME: Belongs elsewhere.
+            mow.resize(false, Math.max(0, this.minHeight - this.container.height));
+
+            this.container.height = this.minHeight - mow.spaceNeeded;
+            mow.resize(false);
+            this.timeout(function () {
+                this.container.height -= mow.spaceNeeded;
+            });
         }
     },
 
-    // select index, refill list if necessary
-    selectItem: function selectItem(index) {
-        //let now = Date.now();
+    /**
+     * Selects the item at the given *group* and *index*.
+     *
+     * @param {CompletionContext|[CompletionContext,number]} *group* The
+     *      completion context to select, or a tuple specifying the
+     *      context and item index.
+     * @param {number} index The item index in *group* to select.
+     * @param {number} position If non-null, try to position the
+     *      selected item at the *position*th row from the top of
+     *      the screen. Note that at least {@link #CONTEXT_LINES}
+     *      lines will be visible above and below the selected item
+     *      unless there aren't enough results to make this possible.
+     *      @optional
+     */
+    select: function select(group, index, position) {
+        if (isArray(group))
+            [group, index] = group;
 
-        if (this._div == null)
-            this._init();
+        group = this.getGroup(group);
 
-        let sel = this._selIndex;
-        let len = this._items.allItems.items.length;
-        let newOffset = this._startIndex;
-        let maxItems = options["maxitems"];
-        let contextLines = Math.min(3, parseInt((maxItems - 1) / 2));
+        if (this.selectedGroup && (!group || group != this.selectedGroup))
+            this.selectedGroup.selectedIdx = null;
 
-        if (index == -1 || index == null || index == len) { // wrapped around
-            if (this._selIndex < 0)
-                newOffset = 0;
-            this._selIndex = -1;
-            index = -1;
-        }
-        else {
-            if (index <= this._startIndex + contextLines)
-                newOffset = index - contextLines;
-            if (index >= this._endIndex - contextLines)
-                newOffset = index + contextLines - maxItems + 1;
+        this.selectedGroup = group;
 
-            newOffset = Math.min(newOffset, len - maxItems);
-            newOffset = Math.max(newOffset, 0);
+        if (group)
+            group.selectedIdx = index;
 
-            this._selIndex = index;
-        }
+        let groups = this.activeGroups;
 
-        if (sel > -1)
-            this._getCompletion(sel).removeAttribute("selected");
-        this._fill(newOffset);
-        if (index >= 0) {
-            this._getCompletion(index).setAttribute("selected", "true");
-            if (this._container.height != 0)
-                util.scrollIntoView(this._getCompletion(index));
-        }
+        if (position != null || !this.startPos && groups.length)
+            this.startPos = [group || groups[0], position || 0];
+
+        if (groups.length) {
+            group = group || groups[0];
+            let idx = groups.indexOf(group);
+
+            let start  = this.startPos[0].getOffset(this.startPos[1]);
+            if (group) {
+                let idx = group.selectedIdx || 0;
+                let off = group.getOffset(idx);
+
+                start = Math.constrain(start,
+                                       off + Math.min(this.CONTEXT_LINES,
+                                                      group.itemCount - idx + group.offsets.end)
+                                           - this.maxItems + 1,
+                                       off - Math.min(this.CONTEXT_LINES,
+                                                      idx + group.offsets.start));
+            }
+
+            let count = this.maxItems;
+            for (let group in values(groups)) {
+                let off = Math.max(0, start - group.offsets.start);
+
+                group.count = Math.constrain(group.itemCount - off, 0, count);
+                count -= group.count;
+
+                group.collapsed = group.offsets.start >= start + this.maxItems
+                               || group.offsets.start + group.itemCount < start;
+
+                group.range = ItemList.Range(off, off + group.count);
 
-        //if (index == 0)
-        //    this.start = now;
-        //if (index == Math.min(len - 1, 100))
-        //    util.dump({ time: Date.now() - this.start });
+                if (!startPos)
+                    var startPos = [group, group.range.start];
+            }
+            this.startPos = startPos;
+        }
+        this.draw();
     },
 
-    onKeyPress: function onKeyPress(event) false
+    /**
+     * Returns an ItemList group for the given completion context,
+     * creating one if necessary.
+     *
+     * @param {CompletionContext} context
+     * @returns {ItemList.Group}
+     */
+    getGroup: function getGroup(context)
+        context instanceof ItemList.Group ? context
+                                          : context && context.getCache("itemlist-group",
+                                                                        () => ItemList.Group(this, context)),
+
+    getOffset: function getOffset(tuple) tuple && this.getGroup(tuple[0]).getOffset(tuple[1])
 }, {
-    WAITING_MESSAGE: "Generating results..."
+    RESIZE_BRIEF: 1 << 0,
+
+    WAITING_MESSAGE: _("completion.generating"),
+
+    Group: Class("ItemList.Group", {
+        init: function init(parent, context) {
+            this.parent  = parent;
+            this.context = context;
+            this.offsets = {};
+            this.range   = ItemList.Range(0, 0);
+        },
+
+        get rootXML()
+            ["div", { key: "root", highlight: "CompGroup" },
+                ["div", { highlight: "Completions" },
+                    this.context.createRow(this.context.title || [], "CompTitle")],
+                ["div", { highlight: "CompTitleSep" }],
+                ["div", { key: "contents" },
+                    ["div", { key: "up", highlight: "CompLess" }],
+                    ["div", { key: "message", highlight: "CompMsg" },
+                        this.context.message || []],
+                    ["div", { key: "itemsContainer", class: "completion-items-container" },
+                        ["div", { key: "items", highlight: "Completions" }]],
+                    ["div", { key: "waiting", highlight: "CompMsg" },
+                        ItemList.WAITING_MESSAGE],
+                    ["div", { key: "down", highlight: "CompMore" }]]],
+
+        get doc() this.parent.doc,
+        get win() this.parent.win,
+        get maxItems() this.parent.maxItems,
+
+        get itemCount() this.context.items.length,
+
+        /**
+         * Returns a function which will update the scroll offsets
+         * and heights of various DOM members.
+         * @private
+         */
+        get rescrollFunc() {
+            let container = this.nodes.itemsContainer;
+            let pos    = DOM(container).rect.top;
+            let start  = DOM(this.getRow(this.range.start)).rect.top;
+            let height = DOM(this.getRow(this.range.end - 1)).rect.bottom - start || 0;
+            let scroll = start + container.scrollTop - pos;
+
+            let win = this.win;
+            let row = this.selectedRow;
+            if (row && this.parent.minHeight) {
+                let { rect } = DOM(this.selectedRow);
+                var scrollY = this.win.scrollY + rect.bottom - this.win.innerHeight;
+            }
+
+            return function () {
+                container.style.height = height + "px";
+                container.scrollTop = scroll;
+                if (scrollY != null)
+                    win.scrollTo(0, Math.max(scrollY, 0));
+            };
+        },
+
+        /**
+         * Reset this group for use with a new set of results.
+         */
+        reset: function reset() {
+            this.nodes = {};
+            this.generatedRange = ItemList.Range(0, 0);
+
+            DOM.fromJSON(this.rootXML, this.doc, this.nodes);
+        },
+
+        /**
+         * Update this group after an asynchronous results push.
+         */
+        update: function update() {
+            this.generatedRange = ItemList.Range(0, 0);
+            DOM(this.nodes.items).empty();
+
+            if (this.context.message)
+                DOM(this.nodes.message).empty()
+                    .append(DOM.fromJSON(this.context.message, this.doc));
+
+            if (this.selectedIdx > this.itemCount)
+                this.selectedIdx = null;
+        },
+
+        /**
+         * Updates the DOM to reflect the current state of this
+         * group.
+         * @private
+         */
+        draw: function draw() {
+            DOM(this.nodes.contents).toggle(!this.collapsed);
+            if (this.collapsed)
+                return;
+
+            DOM(this.nodes.message).toggle(this.context.message && this.range.start == 0);
+            DOM(this.nodes.waiting).toggle(this.context.incomplete && this.range.end <= this.itemCount);
+            DOM(this.nodes.up).toggle(this.range.start > 0);
+            DOM(this.nodes.down).toggle(this.range.end < this.itemCount);
+
+            if (!this.generatedRange.contains(this.range)) {
+                if (this.generatedRange.end == 0)
+                    var [start, end] = this.range;
+                else {
+                    start = this.range.start - (this.range.start <= this.generatedRange.start
+                                                    ? this.maxItems / 2 : 0);
+                    end   = this.range.end   + (this.range.end > this.generatedRange.end
+                                                    ? this.maxItems / 2 : 0);
+                }
+
+                let range = ItemList.Range(Math.max(0, start - start % 2),
+                                           Math.min(this.itemCount, end));
+
+                let first;
+                for (let [i, row] in this.context.getRows(this.generatedRange.start,
+                                                          this.generatedRange.end,
+                                                          this.doc))
+                    if (!range.contains(i))
+                        DOM(row).remove();
+                    else if (!first)
+                        first = row;
+
+                let container = DOM(this.nodes.items);
+                let before    = first ? DOM(first).bound.before
+                                      : DOM(this.nodes.items).bound.append;
+
+                for (let [i, row] in this.context.getRows(range.start, range.end,
+                                                          this.doc)) {
+                    if (i < this.generatedRange.start)
+                        before(row);
+                    else if (i >= this.generatedRange.end)
+                        container.append(row);
+                    if (i == this.selectedIdx)
+                        this.selectedIdx = this.selectedIdx;
+                }
+
+                this.generatedRange = range;
+            }
+        },
+
+        getRow: function getRow(idx) this.context.getRow(idx, this.doc),
+
+        getOffset: function getOffset(idx) this.offsets.start + (idx || 0),
+
+        get selectedRow() this.getRow(this._selectedIdx),
+
+        get selectedIdx() this._selectedIdx,
+        set selectedIdx(idx) {
+            if (this.selectedRow && this._selectedIdx != idx)
+                DOM(this.selectedRow).attr("selected", null);
+
+            this._selectedIdx = idx;
+
+            if (this.selectedRow)
+                DOM(this.selectedRow).attr("selected", true);
+        }
+    }),
+
+    Range: Class.Memoize(function () {
+        let Range = Struct("ItemList.Range", "start", "end");
+        update(Range.prototype, {
+            contains: function contains(idx)
+                typeof idx == "number" ? idx >= this.start && idx < this.end
+                                       : this.contains(idx.start) &&
+                                         idx.end >= this.start && idx.end <= this.end
+        });
+        return Range;
+    })
 });
 
-// vim: set fdm=marker sw=4 ts=4 et:
+// vim: set fdm=marker sw=4 sts=4 ts=8 et: