Bug 773227 - GCLI should open a help menu on enter for non-VALID (like pressing F1); r=dcamp
authorJoe Walker <jwalker@mozilla.com>
Sat, 25 Aug 2012 21:18:56 +0100
changeset 105445 4d6471c1cdd925abdbffc40019df0bba6de833d8
parent 105444 e11af99c55665e44df7318661f524742a0b8952b
child 105446 bc23ba35391382a3de4dd325ad6baaaa88292ec3
push id55
push usershu@rfrn.org
push dateThu, 30 Aug 2012 01:33:09 +0000
reviewersdcamp
bugs773227
milestone17.0a1
Bug 773227 - GCLI should open a help menu on enter for non-VALID (like pressing F1); r=dcamp
browser/devtools/commandline/gcli.jsm
browser/devtools/commandline/test/browser_gcli_web.js
--- a/browser/devtools/commandline/gcli.jsm
+++ b/browser/devtools/commandline/gcli.jsm
@@ -101,20 +101,18 @@ define('gcli/index', ['require', 'export
   require('gcli/ui/focus').startup();
   require('gcli/ui/fields/basic').startup();
   require('gcli/ui/fields/javascript').startup();
   require('gcli/ui/fields/selection').startup();
 
   require('gcli/commands/help').startup();
   require('gcli/commands/pref').startup();
 
-
   var Cc = Components.classes;
   var Ci = Components.interfaces;
-  var Cu = Components.utils;
   var prefSvc = "@mozilla.org/preferences-service;1";
   var prefService = Cc[prefSvc].getService(Ci.nsIPrefService);
   var prefBranch = prefService.getBranch(null).QueryInterface(Ci.nsIPrefBranch2);
 
   // The API for use by command authors
   exports.addCommand = require('gcli/canon').addCommand;
   exports.removeCommand = require('gcli/canon').removeCommand;
   exports.lookup = mozl10n.lookup;
@@ -137,16 +135,17 @@ define('gcli/index', ['require', 'export
   exports.createDisplay = function(opts) {
     var FFDisplay = require('gcli/ui/ffdisplay').FFDisplay;
     return new FFDisplay(opts);
   };
 
   exports.hiddenByChromePref = function() {
     return !prefBranch.prefHasUserValue("devtools.chrome.enabled");
   };
+
 });
 /*
  * Copyright 2012, Mozilla Foundation and contributors
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
@@ -4017,52 +4016,64 @@ exports.JavascriptType = JavascriptType;
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
 
-define('gcli/types/node', ['require', 'exports', 'module' , 'gcli/host', 'gcli/l10n', 'gcli/types'], function(require, exports, module) {
+define('gcli/types/node', ['require', 'exports', 'module' , 'gcli/host', 'gcli/l10n', 'gcli/types', 'gcli/argument'], function(require, exports, module) {
 
 
 var host = require('gcli/host');
 var l10n = require('gcli/l10n');
 var types = require('gcli/types');
 var Type = require('gcli/types').Type;
 var Status = require('gcli/types').Status;
 var Conversion = require('gcli/types').Conversion;
+var BlankArgument = require('gcli/argument').BlankArgument;
 
 
 /**
  * Registration and de-registration.
  */
 exports.startup = function() {
   types.registerType(NodeType);
+  types.registerType(NodeListType);
 };
 
 exports.shutdown = function() {
   types.unregisterType(NodeType);
+  types.unregisterType(NodeListType);
 };
 
 /**
  * The object against which we complete, which is usually 'window' if it exists
  * but could be something else in non-web-content environments.
  */
 var doc;
 if (typeof document !== 'undefined') {
   doc = document;
 }
 
 /**
+ * For testing only.
+ * The fake empty NodeList used when there are no matches, we replace this with
+ * something that looks better as soon as we have a document, so not only
+ * should you not use this, but you shouldn't cache it either.
+ */
+exports._empty = [];
+
+/**
  * Setter for the document that contains the nodes we're matching
  */
 exports.setDocument = function(document) {
   doc = document;
+  exports._empty = doc.querySelectorAll('x>:root');
 };
 
 /**
  * Undo the effects of setDocument()
  */
 exports.unsetDocument = function() {
   doc = undefined;
 };
@@ -4123,16 +4134,76 @@ NodeType.prototype.parse = function(arg)
 
   return new Conversion(undefined, arg, Status.ERROR,
           l10n.lookupFormat('nodeParseMultiple', [ nodes.length ]));
 };
 
 NodeType.prototype.name = 'node';
 
 
+
+/**
+ * A CSS expression that refers to a node list.
+ *
+ * The 'allowEmpty' option ensures that we do not complain if the entered CSS
+ * selector is valid, but does not match any nodes. There is some overlap
+ * between this option and 'defaultValue'. What the user wants, in most cases,
+ * would be to use 'defaultText' (i.e. what is typed rather than the value that
+ * it represents). However this isn't a concept that exists yet and should
+ * probably be a part of GCLI if/when it does.
+ * All NodeListTypes have an automatic defaultValue of an empty NodeList so
+ * they can easily be used in named parameters.
+ */
+function NodeListType(typeSpec) {
+  if ('allowEmpty' in typeSpec && typeof typeSpec.allowEmpty !== 'boolean') {
+    throw new Error('Legal values for allowEmpty are [true|false]');
+  }
+
+  this.allowEmpty = typeSpec.allowEmpty;
+}
+
+NodeListType.prototype = Object.create(Type.prototype);
+
+NodeListType.prototype.getBlank = function() {
+  return new Conversion(exports._empty, new BlankArgument(), Status.VALID);
+};
+
+NodeListType.prototype.stringify = function(value) {
+  if (value == null) {
+    return '';
+  }
+  return value.__gcliQuery || 'Error';
+};
+
+NodeListType.prototype.parse = function(arg) {
+  if (arg.text === '') {
+    return new Conversion(undefined, arg, Status.INCOMPLETE);
+  }
+
+  var nodes;
+  try {
+    nodes = doc.querySelectorAll(arg.text);
+  }
+  catch (ex) {
+    return new Conversion(undefined, arg, Status.ERROR,
+            l10n.lookup('nodeParseSyntax'));
+  }
+
+  if (nodes.length === 0 && !this.allowEmpty) {
+    return new Conversion(undefined, arg, Status.INCOMPLETE,
+        l10n.lookup('nodeParseNone'));
+  }
+
+  host.flashNodes(nodes, false);
+  return new Conversion(nodes, arg, Status.VALID, '');
+};
+
+NodeListType.prototype.name = 'nodelist';
+
+
 });
 /*
  * Copyright 2012, Mozilla Foundation and contributors
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
@@ -5779,54 +5850,22 @@ Requisition.prototype.complete = functio
 
   this.onTextChange();
   this.onTextChange.resumeFire();
 };
 
 /**
  * Pressing TAB sometimes requires that we add a space to denote that we're on
  * to the 'next thing'.
- * The question is, where does the space go? The obvious thing to do is to add
- * it to the suffix of the completed argument, but that's wrong because spaces
- * are attached to the start of the next argument rather than the end of the
- * previous one (and this matters to getCurrentAssignment).
- * However there might not be a 'next' argument (if we've at the end of the
- * input), in which case we really do use this one.
- * Also if there is already a space in those positions, don't add another
- * In addition to all of this, we need to know what the 'next' argument is.
- * We can't use the argument defined just after the thing that is being
- * completed, because we could be completing a named argument, so we need to
- * look for the first blank positional parameter, but if there isn't one of
- * those then we just add to the suffix of the current.
- * @param assignment The 'last' assignment to which to append the space if
- * there is no 'next' assignment to which we can prepend a space
+ * @param assignment The assignment to which to append the space
  */
 Requisition.prototype._addSpace = function(assignment) {
-  var nextAssignment = this._getFirstBlankPositionalAssignment();
-  if (nextAssignment) {
-    // Add a space onto the next argument (if there isn't one there already)
-    var nextArg = nextAssignment.conversion.arg;
-    if (nextArg.prefix.charAt(0) !== ' ') {
-      nextArg = new Argument(nextArg.text, ' ' + nextArg.prefix, nextArg.suffix);
-      this.setAssignment(nextAssignment, nextArg);
-    }
-  }
-  else {
-    // There is no next argument, this must be the last assignment, so just
-    // add the space to the prefix of this argument
-    var newArg = assignment.conversion.arg.beget({ suffixSpace: true });
-    if (newArg !== assignment.conversion.arg) {
-      // It's tempting to think - "we're calling setAssignment twice in one
-      // call to complete, the first time to complete the text, the second
-      // to add a space, why not save the event cascade and do it once"
-      // However if we're setting up the command, the number of parameters
-      // changes as a result, so our call to getFirstBlankPositionalAssignment
-      // will produce the wrong answer
-      this.setAssignment(assignment, newArg);
-    }
+  var arg = assignment.conversion.arg.beget({ suffixSpace: true });
+  if (arg !== assignment.conversion.arg) {
+    this.setAssignment(assignment, arg);
   }
 };
 
 /**
  * Replace the current value with the lower value if such a concept exists.
  */
 Requisition.prototype.decrement = function(assignment) {
   var replacement = assignment.param.type.decrement(assignment.conversion.value);
@@ -6160,17 +6199,17 @@ Requisition.prototype.exec = function(in
   });
 
   this.commandOutputManager.onOutput({ output: output });
 
   try {
     var context = exports.createExecutionContext(this);
     var reply = command.exec(args, context);
 
-    if (reply != null && reply.isPromise) {
+    if (reply != null && typeof reply.then === 'function') {
       reply.then(
           function(data) { output.complete(data); },
           function(error) { output.error = true; output.complete(error); });
 
       output.promise = reply;
       // Add progress to our promise and add a handler for it here
       // See bug 659300
     }
@@ -6987,16 +7026,21 @@ FocusManager.prototype.addMonitoredEleme
     element: element,
     where: where,
     onFocus: function() { this._reportFocus(where); }.bind(this),
     onBlur: function() { this._reportBlur(where); }.bind(this)
   };
 
   element.addEventListener('focus', monitor.onFocus, true);
   element.addEventListener('blur', monitor.onBlur, true);
+
+  if (this._document.activeElement === element) {
+    this._reportFocus(where);
+  }
+
   this._monitoredElements.push(monitor);
 };
 
 /**
  * Undo the effects of addMonitoredElement()
  * @param element The element to stop tracking
  * @param where Optional source string for debugging only
  */
@@ -7104,19 +7148,19 @@ FocusManager.prototype._reportBlur = fun
  * The setting has changed
  */
 FocusManager.prototype._eagerHelperChanged = function() {
   this._checkShow();
 };
 
 /**
  * The inputter tells us about keyboard events so we can decide to delay
- * showing the tooltip element, (or if the keypress is F1, show it now)
- */
-FocusManager.prototype.onInputChange = function(ev) {
+ * showing the tooltip element
+ */
+FocusManager.prototype.onInputChange = function() {
   this._recentOutput = false;
   this._checkShow();
 };
 
 /**
  * Generally called for something like a F1 key press, when the user explicitly
  * wants help
  */
@@ -7200,25 +7244,25 @@ FocusManager.prototype._checkShow = func
 };
 
 /**
  * Calculate if we should be showing or hidden taking into account all the
  * available inputs
  */
 FocusManager.prototype._shouldShowTooltip = function() {
   if (!this._hasFocus) {
-    return { visible: false, reason: '!hasFocus' };
+    return { visible: false, reason: 'notHasFocus' };
   }
 
   if (eagerHelper.value === Eagerness.NEVER) {
-    return { visible: false, reason: 'eagerHelper !== NEVER' };
+    return { visible: false, reason: 'eagerHelperNever' };
   }
 
   if (eagerHelper.value === Eagerness.ALWAYS) {
-    return { visible: true, reason: 'eagerHelper !== ALWAYS' };
+    return { visible: true, reason: 'eagerHelperAlways' };
   }
 
   if (this._isError) {
     return { visible: true, reason: 'isError' };
   }
 
   if (this._helpRequested) {
     return { visible: true, reason: 'helpRequested' };
@@ -7232,17 +7276,17 @@ FocusManager.prototype._shouldShowToolti
 };
 
 /**
  * Calculate if we should be showing or hidden taking into account all the
  * available inputs
  */
 FocusManager.prototype._shouldShowOutput = function() {
   if (!this._hasFocus) {
-    return { visible: false, reason: '!hasFocus' };
+    return { visible: false, reason: 'notHasFocus' };
   }
 
   if (this._recentOutput) {
     return { visible: true, reason: 'recentOutput' };
   }
 
   return { visible: false, reason: 'default' };
 };
@@ -8212,27 +8256,31 @@ Menu.prototype.setChoiceIndex = function
   }
 
   nodes.item(choice).classList.add('gcli-menu-highlight');
 };
 
 /**
  * Allow the inputter to use RETURN to chose the current menu item when
  * it can't execute the command line
+ * @return true if an item was 'clicked', false otherwise
  */
 Menu.prototype.selectChoice = function() {
   var selected = this.element.querySelector('.gcli-menu-highlight .gcli-menu-name');
-  if (selected) {
-    var name = selected.innerHTML;
-    var arg = new Argument(name);
-    arg.suffix = ' ';
-
-    var conversion = this.type.parse(arg);
-    this.onItemClick({ conversion: conversion });
-  }
+  if (!selected) {
+    return false;
+  }
+
+  var name = selected.innerHTML;
+  var arg = new Argument(name);
+  arg.suffix = ' ';
+
+  var conversion = this.type.parse(arg);
+  this.onItemClick({ conversion: conversion });
+  return true;
 };
 
 /**
  * Hide the menu
  */
 Menu.prototype.hide = function() {
   this.element.style.display = 'none';
 };
@@ -8452,19 +8500,20 @@ SelectionTooltipField.prototype.getConve
  */
 SelectionTooltipField.prototype.setChoiceIndex = function(choice) {
   this.menu.setChoiceIndex(choice);
 };
 
 /**
  * Allow the inputter to use RETURN to chose the current menu item when
  * it can't execute the command line
+ * @return true if an item was 'clicked', false otherwise
  */
 SelectionTooltipField.prototype.selectChoice = function() {
-  this.menu.selectChoice();
+  return this.menu.selectChoice();
 };
 
 Object.defineProperty(SelectionTooltipField.prototype, 'isImportant', {
   get: function() {
     return this.type.name !== 'command';
   },
   enumerable: true
 });
@@ -9487,17 +9536,17 @@ Inputter.prototype.onKeyDown = function(
   if (ev.keyCode === KeyEvent.DOM_VK_F1 ||
       ev.keyCode === KeyEvent.DOM_VK_ESCAPE ||
       ev.keyCode === KeyEvent.DOM_VK_UP ||
       ev.keyCode === KeyEvent.DOM_VK_DOWN) {
     return;
   }
 
   if (this.focusManager) {
-    this.focusManager.onInputChange(ev);
+    this.focusManager.onInputChange();
   }
 
   if (ev.keyCode === KeyEvent.DOM_VK_TAB) {
     this.lastTabDownAt = 0;
     if (!ev.shiftKey) {
       ev.preventDefault();
       // Record the timestamp of this TAB down so onKeyUp can distinguish
       // focus from TAB in the CLI.
@@ -9538,17 +9587,17 @@ Inputter.prototype.onKeyUp = function(ev
     }
     else {
       // If the user is on a valid value, then we increment the value, but if
       // they've typed something that's not right we page through predictions
       if (this.assignment.getStatus() === Status.VALID) {
         this.requisition.increment(assignment);
         // See notes on focusManager.onInputChange in onKeyDown
         if (this.focusManager) {
-          this.focusManager.onInputChange(ev);
+          this.focusManager.onInputChange();
         }
       }
       else {
         this.changeChoice(-1);
       }
     }
     return;
   }
@@ -9562,17 +9611,17 @@ Inputter.prototype.onKeyUp = function(ev
       this.requisition.update(this.history.forward());
     }
     else {
       // See notes above for the UP key
       if (this.assignment.getStatus() === Status.VALID) {
         this.requisition.decrement(assignment);
         // See notes on focusManager.onInputChange in onKeyDown
         if (this.focusManager) {
-          this.focusManager.onInputChange(ev);
+          this.focusManager.onInputChange();
         }
       }
       else {
         this.changeChoice(+1);
       }
     }
     return;
   }
@@ -9584,20 +9633,19 @@ Inputter.prototype.onKeyUp = function(ev
     if (worst === Status.VALID) {
       this._scrollingThroughHistory = false;
       this.history.add(this.element.value);
       this.requisition.exec();
     }
     else {
       // If we can't execute the command, but there is a menu choice to use
       // then use it.
-      this.tooltip.selectChoice();
-
-      // See bug 664135 - On pressing return with an invalid input, GCLI
-      // should select the incorrect part of the input for an easy fix
+      if (!this.tooltip.selectChoice()) {
+        this.focusManager.setError(true);
+      }
     }
 
     this._choice = null;
     return;
   }
 
   if (ev.keyCode === KeyEvent.DOM_VK_TAB && !ev.shiftKey) {
     // Being able to complete 'nothing' is OK if there is some context, but
@@ -10232,21 +10280,23 @@ Tooltip.prototype.choiceChanged = functi
     var choice = this.assignment.conversion.constrainPredictionIndex(ev.choice);
     this.field.setChoiceIndex(choice);
   }
 };
 
 /**
  * Allow the inputter to use RETURN to chose the current menu item when
  * it can't execute the command line
+ * @return true if there was a selection to use, false otherwise
  */
 Tooltip.prototype.selectChoice = function(ev) {
   if (this.field && this.field.selectChoice) {
-    this.field.selectChoice();
-  }
+    return this.field.selectChoice();
+  }
+  return false;
 };
 
 /**
  * Called by the onFieldChange event on the current Field
  */
 Tooltip.prototype.fieldChanged = function(ev) {
   this.assignment.setConversion(ev.conversion);
 
--- a/browser/devtools/commandline/test/browser_gcli_web.js
+++ b/browser/devtools/commandline/test/browser_gcli_web.js
@@ -150,21 +150,53 @@ define('gclitest/index', ['require', 'ex
       settings.setDefaults(options.settings);
     }
 
     window.display = new Display(options);
     var requisition = window.display.requisition;
 
     // setTimeout keeps stack traces clear of RequireJS frames
     window.setTimeout(function() {
-      exports.run({
+      var options = {
         window: window,
         display: window.display,
         hideExec: true
-      });
+      };
+      exports.run(options);
+
+      window.createDebugCheck = function() {
+        require([ 'gclitest/helpers' ], function(helpers) {
+          helpers.setup(options);
+          console.log(helpers._createDebugCheck());
+          helpers.shutdown(options);
+        });
+      };
+
+      window.summaryJson = function() {
+        var args = [ 'Requisition: ' ];
+        var summary = display.requisition._summaryJson;
+        Object.keys(summary).forEach(function(name) {
+          args.push(' ' + name + '=');
+          args.push(summary[name]);
+        });
+        console.log.apply(console, args);
+
+        console.log('Focus: ' +
+                    'tooltip=', display.focusManager._shouldShowTooltip(),
+                    'output=', display.focusManager._shouldShowOutput());
+      };
+
+      document.addEventListener('keyup', function(ev) {
+        if (ev.keyCode === 113 /*F2*/) {
+          window.createDebugCheck();
+        }
+        if (ev.keyCode === 115 /*F4*/) {
+          window.summaryJson();
+        }
+      }, true);
 
       window.testCommands = function() {
         require([ 'gclitest/mockCommands' ], function(mockCommands) {
           mockCommands.setup();
         });
       };
       window.testCommands();
     }, 10);
@@ -192,38 +224,40 @@ define('gclitest/index', ['require', 'ex
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
 
-define('gclitest/suite', ['require', 'exports', 'module' , 'gcli/index', 'test/examiner', 'gclitest/testCanon', 'gclitest/testCli', 'gclitest/testCompletion', 'gclitest/testExec', 'gclitest/testHelp', 'gclitest/testHistory', 'gclitest/testInputter', 'gclitest/testIncomplete', 'gclitest/testIntro', 'gclitest/testJs', 'gclitest/testKeyboard', 'gclitest/testMenu', 'gclitest/testPref', 'gclitest/testRequire', 'gclitest/testResource', 'gclitest/testScratchpad', 'gclitest/testSettings', 'gclitest/testSpell', 'gclitest/testSplit', 'gclitest/testTokenize', 'gclitest/testTooltip', 'gclitest/testTypes', 'gclitest/testUtil'], function(require, exports, module) {
+define('gclitest/suite', ['require', 'exports', 'module' , 'gcli/index', 'test/examiner', 'gclitest/testCanon', 'gclitest/testCli', 'gclitest/testCompletion', 'gclitest/testExec', 'gclitest/testFocus', 'gclitest/testHelp', 'gclitest/testHistory', 'gclitest/testInputter', 'gclitest/testIncomplete', 'gclitest/testIntro', 'gclitest/testJs', 'gclitest/testKeyboard', 'gclitest/testMenu', 'gclitest/testNode', 'gclitest/testPref', 'gclitest/testRequire', 'gclitest/testResource', 'gclitest/testScratchpad', 'gclitest/testSettings', 'gclitest/testSpell', 'gclitest/testSplit', 'gclitest/testTokenize', 'gclitest/testTooltip', 'gclitest/testTypes', 'gclitest/testUtil'], function(require, exports, module) {
 
   // We need to make sure GCLI is initialized before we begin testing it
   require('gcli/index');
 
   var examiner = require('test/examiner');
 
   // It's tempting to want to unify these strings and make addSuite() do the
   // call to require(), however that breaks the build system which looks for
   // the strings passed to require
   examiner.addSuite('gclitest/testCanon', require('gclitest/testCanon'));
   examiner.addSuite('gclitest/testCli', require('gclitest/testCli'));
   examiner.addSuite('gclitest/testCompletion', require('gclitest/testCompletion'));
   examiner.addSuite('gclitest/testExec', require('gclitest/testExec'));
+  examiner.addSuite('gclitest/testFocus', require('gclitest/testFocus'));
   examiner.addSuite('gclitest/testHelp', require('gclitest/testHelp'));
   examiner.addSuite('gclitest/testHistory', require('gclitest/testHistory'));
   examiner.addSuite('gclitest/testInputter', require('gclitest/testInputter'));
   examiner.addSuite('gclitest/testIncomplete', require('gclitest/testIncomplete'));
   examiner.addSuite('gclitest/testIntro', require('gclitest/testIntro'));
   examiner.addSuite('gclitest/testJs', require('gclitest/testJs'));
   examiner.addSuite('gclitest/testKeyboard', require('gclitest/testKeyboard'));
   examiner.addSuite('gclitest/testMenu', require('gclitest/testMenu'));
+  examiner.addSuite('gclitest/testNode', require('gclitest/testNode'));
   examiner.addSuite('gclitest/testPref', require('gclitest/testPref'));
   examiner.addSuite('gclitest/testRequire', require('gclitest/testRequire'));
   examiner.addSuite('gclitest/testResource', require('gclitest/testResource'));
   examiner.addSuite('gclitest/testScratchpad', require('gclitest/testScratchpad'));
   examiner.addSuite('gclitest/testSettings', require('gclitest/testSettings'));
   examiner.addSuite('gclitest/testSpell', require('gclitest/testSpell'));
   examiner.addSuite('gclitest/testSplit', require('gclitest/testSplit'));
   examiner.addSuite('gclitest/testTokenize', require('gclitest/testTokenize'));
@@ -742,16 +776,24 @@ define('test/status', ['require', 'expor
  */
 
 define('gclitest/testCanon', ['require', 'exports', 'module' , 'gclitest/helpers', 'gcli/canon', 'test/assert'], function(require, exports, module) {
 
   var helpers = require('gclitest/helpers');
   var canon = require('gcli/canon');
   var test = require('test/assert');
 
+  exports.setup = function(options) {
+    helpers.setup(options);
+  };
+
+  exports.shutdown = function(options) {
+    helpers.shutdown(options);
+  };
+
   exports.testAddRemove = function(options) {
     var startCount = canon.getCommands().length;
     var events = 0;
 
     var canonChange = function(ev) {
       events++;
     };
     canon.onCanonChange.add(canonChange);
@@ -783,17 +825,19 @@ define('gclitest/testCanon', ['require',
       typed: 'testadd',
       outputMatch: /^2$/
     });
 
     canon.removeCommand('testadd');
 
     test.is(canon.getCommands().length, startCount, 'remove command success');
     test.is(events, 3, 'remove event');
-    helpers.status(options, {
+
+    helpers.setInput('testadd');
+    helpers.check({
       typed: 'testadd',
       status: 'ERROR'
     });
 
     canon.addCommand({
       name: 'testadd',
       exec: function() {
         return 3;
@@ -808,17 +852,19 @@ define('gclitest/testCanon', ['require',
     });
 
     canon.removeCommand({
       name: 'testadd'
     });
 
     test.is(canon.getCommands().length, startCount, 'reremove command success');
     test.is(events, 5, 'reremove event');
-    helpers.status(options, {
+
+    helpers.setInput('testadd');
+    helpers.check({
       typed: 'testadd',
       status: 'ERROR'
     });
 
     canon.removeCommand({ name: 'nonexistant' });
     test.is(canon.getCommands().length, startCount, 'nonexistant1 command success');
     test.is(events, 5, 'nonexistant1 event');
 
@@ -847,120 +893,179 @@ define('gclitest/testCanon', ['require',
  */
 
 define('gclitest/helpers', ['require', 'exports', 'module' , 'test/assert', 'gcli/util'], function(require, exports, module) {
 
 
 var test = require('test/assert');
 var util = require('gcli/util');
 
-
-var cachedOptions = undefined;
-
-exports.setup = function(opts) {
-  cachedOptions = opts;
-};
-
-exports.shutdown = function(opts) {
-  cachedOptions = undefined;
+var helpers = exports;
+
+helpers._display = undefined;
+
+helpers.setup = function(options) {
+  helpers._display = options.display;
+};
+
+helpers.shutdown = function(options) {
+  helpers._display = undefined;
 };
 
 /**
- * Check that we can parse command input.
- * Doesn't execute the command, just checks that we grok the input properly:
- *
- * helpers.status({
- *   // Test inputs
- *   typed: "ech",           // Required
- *   cursor: 3,              // Optional cursor position
- *
- *   // Thing to check
- *   status: "INCOMPLETE",   // One of "VALID", "ERROR", "INCOMPLETE"
- *   hints: The hint text, i.e. a concatenation of the directTabText, the
- *     emptyParameters and the arrowTabText. The text as inserted into the UI
- *     will include NBSP and Unicode RARR characters, these should be
- *     represented using normal space and '->' for the arrow
- *   markup: "VVVIIIEEE",    // What state should the error markup be in
- * });
+ * Various functions to return the actual state of the command line
  */
-exports.status = function(options, checks) {
-  var requisition = options.display.requisition;
-  var inputter = options.display.inputter;
-  var completer = options.display.completer;
-
-  if (checks.typed != null) {
-    inputter.setInput(checks.typed);
+helpers._actual = {
+  input: function() {
+    return helpers._display.inputter.element.value;
+  },
+
+  hints: function() {
+    var templateData = helpers._display.completer._getCompleterTemplateData();
+    var actualHints = templateData.directTabText +
+                      templateData.emptyParameters.join('') +
+                      templateData.arrowTabText;
+    return actualHints.replace(/\u00a0/g, ' ')
+                      .replace(/\u21E5/, '->')
+                      .replace(/ $/, '');
+  },
+
+  markup: function() {
+    var cursor = helpers._display.inputter.element.selectionStart;
+    var statusMarkup = helpers._display.requisition.getInputStatusMarkup(cursor);
+    return statusMarkup.map(function(s) {
+      return Array(s.string.length + 1).join(s.status.toString()[0]);
+    }).join('');
+  },
+
+  cursor: function() {
+    return helpers._display.inputter.element.selectionStart;
+  },
+
+  current: function() {
+    return helpers._display.requisition.getAssignmentAt(helpers._actual.cursor()).param.name;
+  },
+
+  status: function() {
+    return helpers._display.requisition.getStatus().toString();
+  },
+
+  outputState: function() {
+    var outputData = helpers._display.focusManager._shouldShowOutput();
+    return outputData.visible + ':' + outputData.reason;
+  },
+
+  tooltipState: function() {
+    var tooltipData = helpers._display.focusManager._shouldShowTooltip();
+    return tooltipData.visible + ':' + tooltipData.reason;
+  }
+};
+
+helpers._directToString = [ 'boolean', 'undefined', 'number' ];
+
+helpers._createDebugCheck = function() {
+  var requisition = helpers._display.requisition;
+  var command = requisition.commandAssignment.value;
+  var input = helpers._actual.input();
+  var padding = Array(input.length + 1).join(' ');
+
+  var output = '';
+  output += 'helpers.setInput(\'' + input + '\');\n';
+  output += 'helpers.check({\n';
+  output += '  input:  \'' + input + '\',\n';
+  output += '  hints:  ' + padding + '\'' + helpers._actual.hints() + '\',\n';
+  output += '  markup: \'' + helpers._actual.markup() + '\',\n';
+  output += '  cursor: ' + helpers._actual.cursor() + ',\n';
+  output += '  current: \'' + helpers._actual.current() + '\',\n';
+  output += '  status: \'' + helpers._actual.status() + '\',\n';
+  output += '  outputState: \'' + helpers._actual.outputState() + '\',\n';
+
+  if (command) {
+    output += '  tooltipState: \'' + helpers._actual.tooltipState() + '\',\n';
+    output += '  args: {\n';
+    output += '    command: { name: \'' + command.name + '\' },\n';
+
+    requisition.getAssignments().forEach(function(assignment) {
+      output += '    ' + assignment.param.name + ': { ';
+
+      if (typeof assignment.value === 'string') {
+        output += 'value: \'' + assignment.value + '\', ';
+      }
+      else if (helpers._directToString.indexOf(typeof assignment.value) !== -1) {
+        output += 'value: ' + assignment.value + ', ';
+      }
+      else if (assignment.value === null) {
+        output += 'value: ' + assignment.value + ', ';
+      }
+      else {
+        output += '/*value:' + assignment.value + ',*/ ';
+      }
+
+      output += 'arg: \'' + assignment.arg + '\', ';
+      output += 'status: \'' + assignment.getStatus().toString() + '\', ';
+      output += 'message: \'' + assignment.getMessage() + '\'';
+      output += ' },\n';
+    });
+
+    output += '  }\n';
   }
   else {
-    test.ok(false, "Missing typed for " + JSON.stringify(checks));
-    return;
-  }
-
-  if (checks.cursor != null) {
-    inputter.setCursor(checks.cursor);
-  }
-
-  if (checks.status != null) {
-    test.is(requisition.getStatus().toString(),
-            checks.status,
-            "status for " + checks.typed);
+    output += '  tooltipState: \'' + helpers._actual.tooltipState() + '\'\n';
   }
-
-  var actual = completer._getCompleterTemplateData();
-
-  if (checks.hints != null) {
-    var actualHints = actual.directTabText +
-                      actual.emptyParameters.join('') +
-                      actual.arrowTabText;
-    actualHints = actualHints.replace(/\u00a0/g, ' ')
-                             .replace(/\u21E5/, '->')
-                             .replace(/ $/, '');
-    test.is(actualHints,
-            checks.hints,
-            'hints');
-  }
-
-  if (checks.markup != null) {
-    var cursor = checks.cursor ? checks.cursor.start : checks.typed.length;
-    var statusMarkup = requisition.getInputStatusMarkup(cursor);
-    var actualMarkup = statusMarkup.map(function(s) {
-      return Array(s.string.length + 1).join(s.status.toString()[0]);
-    }).join('');
-
-    test.is(checks.markup,
-            actualMarkup,
-            'markup for ' + checks.typed);
-  }
+  output += '});';
+
+  return output;
 };
 
 /**
  * We're splitting status into setup() which alters the state of the system
  * and check() which ensures that things are in the right place afterwards.
  */
-exports.setInput = function(typed, cursor) {
-  cachedOptions.display.inputter.setInput(typed);
+helpers.setInput = function(typed, cursor) {
+  helpers._display.inputter.setInput(typed);
 
   if (cursor) {
-    cachedOptions.display.inputter.setCursor({ start: cursor, end: cursor });
+    helpers._display.inputter.setCursor({ start: cursor, end: cursor });
   }
+
+  helpers._display.focusManager.onInputChange();
+};
+
+/**
+ * Simulate focusing the input field
+ */
+helpers.focusInput = function() {
+  helpers._display.inputter.focus();
 };
 
 /**
  * Simulate pressing TAB in the input field
  */
-exports.pressTab = function() {
-  // requisition.complete({ start: 5, end: 5 }, 0);
-
+helpers.pressTab = function() {
+  helpers.pressKey(9 /*KeyEvent.DOM_VK_TAB*/);
+};
+
+/**
+ * Simulate pressing RETURN in the input field
+ */
+helpers.pressReturn = function() {
+  helpers.pressKey(13 /*KeyEvent.DOM_VK_RETURN*/);
+};
+
+/**
+ * Simulate pressing a key by keyCode in the input field
+ */
+helpers.pressKey = function(keyCode) {
   var fakeEvent = {
-    keyCode: util.KeyEvent.DOM_VK_TAB,
+    keyCode: keyCode,
     preventDefault: function() { },
     timeStamp: new Date().getTime()
   };
-  cachedOptions.display.inputter.onKeyDown(fakeEvent);
-  cachedOptions.display.inputter.onKeyUp(fakeEvent);
+  helpers._display.inputter.onKeyDown(fakeEvent);
+  helpers._display.inputter.onKeyUp(fakeEvent);
 };
 
 /**
  * check() is the new status. Similar API except that it doesn't attempt to
  * alter the display/requisition at all, and it makes extra checks.
  * Available checks:
  *   input: The text displayed in the input field
  *   cursor: The position of the start of the cursor
@@ -975,114 +1080,101 @@ exports.pressTab = function() {
  *     type: Argument/BlankArgument/MergedArgument/etc i.e. what's assigned
  *           Care should be taken with this since it's something of an
  *           implementation detail
  *     arg: The toString value of the argument
  *     status: i.e. assignment.getStatus
  *     message: i.e. assignment.getMessage
  *     name: For commands - checks assignment.value.name
  */
-exports.check = function(checks) {
-  var requisition = cachedOptions.display.requisition;
-  var completer = cachedOptions.display.completer;
-  var actual = completer._getCompleterTemplateData();
-
-  if (checks.input != null) {
-    test.is(cachedOptions.display.inputter.element.value,
-            checks.input,
-            'input');
+helpers.check = function(checks) {
+  if ('input' in checks) {
+    test.is(helpers._actual.input(), checks.input, 'input');
+  }
+
+  if ('cursor' in checks) {
+    test.is(helpers._actual.cursor(), checks.cursor, 'cursor');
   }
 
-  if (checks.cursor != null) {
-    test.is(cachedOptions.display.inputter.element.selectionStart,
-            checks.cursor,
-            'cursor');
+  if ('current' in checks) {
+    test.is(helpers._actual.current(), checks.current, 'current');
   }
 
-  if (checks.status != null) {
-    test.is(requisition.getStatus().toString(),
-            checks.status,
-            'status');
+  if ('status' in checks) {
+    test.is(helpers._actual.status(), checks.status, 'status');
   }
 
-  if (checks.markup != null) {
-    var cursor = cachedOptions.display.inputter.element.selectionStart;
-    var statusMarkup = requisition.getInputStatusMarkup(cursor);
-    var actualMarkup = statusMarkup.map(function(s) {
-      return Array(s.string.length + 1).join(s.status.toString()[0]);
-    }).join('');
-
-    test.is(checks.markup,
-            actualMarkup,
-            'markup');
+  if ('markup' in checks) {
+    test.is(helpers._actual.markup(), checks.markup, 'markup');
+  }
+
+  if ('hints' in checks) {
+    test.is(helpers._actual.hints(), checks.hints, 'hints');
   }
 
-  if (checks.hints != null) {
-    var actualHints = actual.directTabText +
-                      actual.emptyParameters.join('') +
-                      actual.arrowTabText;
-    actualHints = actualHints.replace(/\u00a0/g, ' ')
-                             .replace(/\u21E5/, '->')
-                             .replace(/ $/, '');
-    test.is(actualHints,
-            checks.hints,
-            'hints');
+  if ('tooltipState' in checks) {
+    test.is(helpers._actual.tooltipState(), checks.tooltipState, 'tooltipState');
+  }
+
+  if ('outputState' in checks) {
+    test.is(helpers._actual.outputState(), checks.outputState, 'outputState');
   }
 
   if (checks.args != null) {
+    var requisition = helpers._display.requisition;
     Object.keys(checks.args).forEach(function(paramName) {
       var check = checks.args[paramName];
 
       var assignment;
       if (paramName === 'command') {
         assignment = requisition.commandAssignment;
       }
       else {
         assignment = requisition.getAssignment(paramName);
       }
 
       if (assignment == null) {
         test.ok(false, 'Unknown arg: ' + paramName);
         return;
       }
 
-      if (check.value != null) {
+      if ('value' in check) {
         test.is(assignment.value,
                 check.value,
-                'arg[\'' + paramName + '\'].value');
+                'arg.' + paramName + '.value');
       }
 
-      if (check.name != null) {
+      if ('name' in check) {
         test.is(assignment.value.name,
                 check.name,
-                'arg[\'' + paramName + '\'].name');
+                'arg.' + paramName + '.name');
       }
 
-      if (check.type != null) {
+      if ('type' in check) {
         test.is(assignment.arg.type,
                 check.type,
-                'arg[\'' + paramName + '\'].type');
+                'arg.' + paramName + '.type');
       }
 
-      if (check.arg != null) {
+      if ('arg' in check) {
         test.is(assignment.arg.toString(),
                 check.arg,
-                'arg[\'' + paramName + '\'].arg');
+                'arg.' + paramName + '.arg');
       }
 
-      if (check.status != null) {
+      if ('status' in check) {
         test.is(assignment.getStatus().toString(),
                 check.status,
-                'arg[\'' + paramName + '\'].status');
+                'arg.' + paramName + '.status');
       }
 
-      if (check.message != null) {
+      if ('message' in check) {
         test.is(assignment.getMessage(),
                 check.message,
-                'arg[\'' + paramName + '\'].message');
+                'arg.' + paramName + '.message');
       }
     });
   }
 };
 
 /**
  * Execute a command:
  *
@@ -1091,17 +1183,17 @@ exports.check = function(checks) {
  *   typed: "echo hi",        // Optional, uses existing if undefined
  *
  *   // Thing to check
  *   args: { message: "hi" }, // Check that the args were understood properly
  *   outputMatch: /^hi$/,     // Regex to test against textContent of output
  *   blankOutput: true,       // Special checks when there is no output
  * });
  */
-exports.exec = function(options, tests) {
+helpers.exec = function(options, tests) {
   var requisition = options.display.requisition;
   var inputter = options.display.inputter;
 
   tests = tests || {};
 
   if (tests.typed) {
     inputter.setInput(tests.typed);
   }
@@ -1152,17 +1244,17 @@ exports.exec = function(options, tests) 
     return;
   }
 
   var div = options.window.document.createElement('div');
   output.toDom(div);
   var displayed = div.textContent.trim();
 
   if (tests.outputMatch) {
-    function doTest(match, against) {
+    var doTest = function(match, against) {
       if (!match.test(against)) {
         test.ok(false, "html output for " + typed + " against " + match.source);
         console.log("Actual textContent");
         console.log(against);
       }
     }
     if (Array.isArray(tests.outputMatch)) {
       tests.outputMatch.forEach(function(match) {
@@ -1687,16 +1779,17 @@ exports.setup = function() {
   exports.option1.type = types.getType('string');
   exports.option2.type = types.getType('number');
 
   types.registerType(exports.optionType);
   types.registerType(exports.optionValue);
 
   canon.addCommand(exports.tsv);
   canon.addCommand(exports.tsr);
+  canon.addCommand(exports.tso);
   canon.addCommand(exports.tse);
   canon.addCommand(exports.tsj);
   canon.addCommand(exports.tsb);
   canon.addCommand(exports.tss);
   canon.addCommand(exports.tsu);
   canon.addCommand(exports.tsn);
   canon.addCommand(exports.tsnDif);
   canon.addCommand(exports.tsnExt);
@@ -1713,16 +1806,17 @@ exports.setup = function() {
   canon.addCommand(exports.tshidden);
   canon.addCommand(exports.tscook);
   canon.addCommand(exports.tslong);
 };
 
 exports.shutdown = function() {
   canon.removeCommand(exports.tsv);
   canon.removeCommand(exports.tsr);
+  canon.removeCommand(exports.tso);
   canon.removeCommand(exports.tse);
   canon.removeCommand(exports.tsj);
   canon.removeCommand(exports.tsb);
   canon.removeCommand(exports.tss);
   canon.removeCommand(exports.tsu);
   canon.removeCommand(exports.tsn);
   canon.removeCommand(exports.tsnDif);
   canon.removeCommand(exports.tsnExt);
@@ -1806,19 +1900,34 @@ exports.tsv = {
 };
 
 exports.tsr = {
   name: 'tsr',
   params: [ { name: 'text', type: 'string' } ],
   exec: createExec('tsr')
 };
 
+exports.tso = {
+  name: 'tso',
+  params: [ { name: 'text', type: 'string', defaultValue: null } ],
+  exec: createExec('tso')
+};
+
 exports.tse = {
   name: 'tse',
-  params: [ { name: 'node', type: 'node' } ],
+  params: [
+    { name: 'node', type: 'node' },
+    {
+      group: 'options',
+      params: [
+        { name: 'nodes', type: { name: 'nodelist' } },
+        { name: 'nodes2', type: { name: 'nodelist', allowEmpty: true } }
+      ]
+    }
+  ],
   exec: createExec('tse')
 };
 
 exports.tsj = {
   name: 'tsj',
   params: [ { name: 'javascript', type: 'javascript' } ],
   exec: createExec('tsj')
 };
@@ -2454,16 +2563,37 @@ exports.testOutstanding = function(optio
   helpers.check({
     input:  'tsg --txt1 ddd ',
     hints:                 'aaa [options]',
     markup: 'VVVVVVVVVVVVVVV'
   });
   */
 };
 
+exports.testCompleteIntoOptional = function(options) {
+  // From bug 779816
+  helpers.setInput('tso ');
+  helpers.check({
+    typed:  'tso ',
+    hints:      '[text]',
+    markup: 'VVVV',
+    status: 'VALID'
+  });
+
+  helpers.setInput('tso');
+  helpers.pressTab();
+  helpers.check({
+    typed:  'tso ',
+    hints:      '[text]',
+    markup: 'VVVV',
+    status: 'VALID'
+  });
+};
+
+
 });
 /*
  * Copyright 2012, Mozilla Foundation and contributors
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
@@ -2484,16 +2614,17 @@ var canon = require('gcli/canon');
 var mockCommands = require('gclitest/mockCommands');
 var nodetype = require('gcli/types/node');
 
 var test = require('test/assert');
 
 var actualExec;
 var actualOutput;
 var hideExec = false;
+var skip = 'skip';
 
 exports.setup = function() {
   mockCommands.setup();
   mockCommands.onCommandExec.add(commandExeced);
   canon.commandOutputManager.onOutput.add(commandOutputed);
 };
 
 exports.shutdown = function() {
@@ -2531,16 +2662,20 @@ function exec(command, expectedArgs) {
   }
 
   test.is(Object.keys(expectedArgs).length, Object.keys(actualExec.args).length,
           'Arg count: ' + command);
   Object.keys(expectedArgs).forEach(function(arg) {
     var expectedArg = expectedArgs[arg];
     var actualArg = actualExec.args[arg];
 
+    if (expectedArg === skip) {
+      return;
+    }
+
     if (Array.isArray(expectedArg)) {
       if (!Array.isArray(actualArg)) {
         test.ok(false, 'actual is not an array. ' + command + '/' + arg);
         return;
       }
 
       test.is(expectedArg.length, actualArg.length,
               'Array length: ' + command + '/' + arg);
@@ -2586,17 +2721,17 @@ exports.testExec = function(options) {
   exec('tsu --num 10', { num: 10 });
 
   // Bug 704829 - Enable GCLI Javascript parameters
   // The answer to this should be 2
   exec('tsj { 1 + 1 }', { javascript: '1 + 1' });
 
   var origDoc = nodetype.getDocument();
   nodetype.setDocument(mockDoc);
-  exec('tse :root', { node: mockBody });
+  exec('tse :root', { node: mockBody, nodes: skip, nodes2: skip });
   nodetype.setDocument(origDoc);
 
   exec('tsn dif fred', { text: 'fred' });
   exec('tsn exten fred', { text: 'fred' });
   exec('tsn extend fred', { text: 'fred' });
 
   exec('tselarr 1', { num: '1', arr: [ ] });
   exec('tselarr 1 a', { num: '1', arr: [ 'a' ] });
@@ -2617,23 +2752,79 @@ var mockDoc = {
     if (css === ':root') {
       return {
         length: 1,
         item: function(i) {
           return mockBody;
         }
       };
     }
-    throw new Error('mockDoc.querySelectorAll(\'' + css + '\') error');
+    else {
+      return {
+        length: 0,
+        item: function() { return null; }
+      };
+    }
   }
 };
 
 
 });
 /*
+ * Copyright 2009-2011 Mozilla Foundation and contributors
+ * Licensed under the New BSD license. See LICENSE.txt or:
+ * http://opensource.org/licenses/BSD-3-Clause
+ */
+
+define('gclitest/testFocus', ['require', 'exports', 'module' , 'gclitest/helpers', 'gclitest/mockCommands'], function(require, exports, module) {
+
+
+var helpers = require('gclitest/helpers');
+var mockCommands = require('gclitest/mockCommands');
+
+exports.setup = function(options) {
+  mockCommands.setup();
+  helpers.setup(options);
+};
+
+exports.shutdown = function(options) {
+  mockCommands.shutdown();
+  helpers.shutdown(options);
+};
+
+exports.testBasic = function(options) {
+  helpers.focusInput();
+  helpers.exec(options, 'help');
+
+  helpers.setInput('tsn deep');
+  helpers.check({
+    input:  'tsn deep',
+    hints:          '',
+    markup: 'IIIVIIII',
+    cursor: 8,
+    status: 'ERROR',
+    outputState: 'false:default',
+    tooltipState: 'false:default'
+  });
+
+  helpers.pressReturn();
+  helpers.check({
+    input:  'tsn deep',
+    hints:          '',
+    markup: 'IIIVIIII',
+    cursor: 8,
+    status: 'ERROR',
+    outputState: 'false:default',
+    tooltipState: 'true:isError'
+  });
+};
+
+
+});
+/*
  * Copyright 2012, Mozilla Foundation and contributors
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
  * http://www.apache.org/licenses/LICENSE-2.0
  *
@@ -2643,32 +2834,61 @@ var mockDoc = {
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
 
 define('gclitest/testHelp', ['require', 'exports', 'module' , 'gclitest/helpers'], function(require, exports, module) {
 
   var helpers = require('gclitest/helpers');
 
+  exports.setup = function(options) {
+    helpers.setup(options);
+  };
+
+  exports.shutdown = function(options) {
+    helpers.shutdown(options);
+  };
+
   exports.testHelpStatus = function(options) {
-    helpers.status(options, {
+    helpers.setInput('help');
+    helpers.check({
       typed:  'help',
       hints:      ' [search]',
       markup: 'VVVV',
       status: 'VALID'
     });
 
-    helpers.status(options, {
+    helpers.setInput('help ');
+    helpers.check({
+      typed:  'help ',
+      hints:       '[search]',
+      markup: 'VVVVV',
+      status: 'VALID'
+    });
+
+    // From bug 779816
+    helpers.setInput('help');
+    helpers.pressTab();
+    helpers.check({
+      typed:  'help ',
+      hints:       '[search]',
+      markup: 'VVVVV',
+      status: 'VALID'
+    });
+
+    helpers.setInput('help foo');
+    helpers.check({
       typed:  'help foo',
       markup: 'VVVVVVVV',
       status: 'VALID',
       hints:  ''
     });
 
-    helpers.status(options, {
+    helpers.setInput('help foo bar');
+    helpers.check({
       typed:  'help foo bar',
       markup: 'VVVVVVVVVVVV',
       status: 'VALID',
       hints:  ''
     });
   };
 
   exports.testHelpExec = function(options) {
@@ -2955,17 +3175,17 @@ exports.testBasic = function(options) {
 };
 
 exports.testCompleted = function(options) {
   helpers.setInput('tsela');
   helpers.pressTab();
   helpers.check({
     args: {
       command: { name: 'tselarr', type: 'Argument' },
-      num: { type: 'Argument' },
+      num: { type: 'BlankArgument' },
       arr: { type: 'ArrayArgument' },
     }
   });
 
   helpers.setInput('tsn dif ');
   helpers.check({
     input:  'tsn dif ',
     hints:          '<text>',
@@ -2983,65 +3203,65 @@ exports.testCompleted = function(options
   helpers.check({
     input:  'tsn dif ',
     hints:          '<text>',
     markup: 'VVVVVVVV',
     cursor: 8,
     status: 'ERROR',
     args: {
       command: { name: 'tsn dif', type: 'Argument' },
-      text: { type: 'Argument', status: 'INCOMPLETE' }
+      text: { type: 'BlankArgument', status: 'INCOMPLETE' }
     }
   });
 
   // The above 2 tests take different routes to 'tsn dif '. The results should
   // be similar. The difference is in args.command.type.
 
   helpers.setInput('tsg -');
   helpers.check({
     input:  'tsg -',
     hints:       '-txt1 <solo> [options]',
     markup: 'VVVVI',
     cursor: 5,
     status: 'ERROR',
     args: {
       solo: { value: undefined, status: 'INCOMPLETE' },
       txt1: { value: undefined, status: 'VALID' },
-      bool: { value: undefined, status: 'VALID' },
+      bool: { value: false, status: 'VALID' },
       txt2: { value: undefined, status: 'VALID' },
       num: { value: undefined, status: 'VALID' }
     }
   });
 
   helpers.pressTab();
   helpers.check({
     input:  'tsg --txt1 ',
     hints:             '<string> <solo> [options]',
     markup: 'VVVVIIIIIIV',
     cursor: 11,
     status: 'ERROR',
     args: {
       solo: { value: undefined, status: 'INCOMPLETE' },
       txt1: { value: undefined, status: 'INCOMPLETE' },
-      bool: { value: undefined, status: 'VALID' },
+      bool: { value: false, status: 'VALID' },
       txt2: { value: undefined, status: 'VALID' },
       num: { value: undefined, status: 'VALID' }
     }
   });
 
   helpers.setInput('tsg --txt1 fred');
   helpers.check({
     input:  'tsg --txt1 fred',
     hints:                 ' <solo> [options]',
     markup: 'VVVVVVVVVVVVVVV',
     status: 'ERROR',
     args: {
       solo: { value: undefined, status: 'INCOMPLETE' },
       txt1: { value: 'fred', status: 'VALID' },
-      bool: { value: undefined, status: 'VALID' },
+      bool: { value: false, status: 'VALID' },
       txt2: { value: undefined, status: 'VALID' },
       num: { value: undefined, status: 'VALID' }
     }
   });
 
   helpers.setInput('tscook key value --path path --');
   helpers.check({
     input:  'tscook key value --path path --',
@@ -3078,17 +3298,17 @@ exports.testCase = function(options) {
   helpers.check({
     input:  'tsg AA',
     hints:        ' [options] -> aaa',
     markup: 'VVVVII',
     status: 'ERROR',
     args: {
       solo: { value: undefined, text: 'AA', status: 'INCOMPLETE' },
       txt1: { value: undefined, status: 'VALID' },
-      bool: { value: undefined, status: 'VALID' },
+      bool: { value: false, status: 'VALID' },
       txt2: { value: undefined, status: 'VALID' },
       num: { value: undefined, status: 'VALID' }
     }
   });
 };
 
 exports.testIncomplete = function(options) {
   var requisition = options.display.requisition;
@@ -3132,82 +3352,82 @@ exports.testHidden = function(options) {
   helpers.check({
     input:  'tshidden',
     hints:          ' [options]',
     markup: 'VVVVVVVV',
     status: 'VALID',
     args: {
       visible: { value: undefined, status: 'VALID' },
       invisiblestring: { value: undefined, status: 'VALID' },
-      invisibleboolean: { value: undefined, status: 'VALID' }
+      invisibleboolean: { value: false, status: 'VALID' }
     }
   });
 
   helpers.setInput('tshidden --vis');
   helpers.check({
     input:  'tshidden --vis',
     hints:                'ible [options]',
     markup: 'VVVVVVVVVIIIII',
     status: 'ERROR',
     args: {
       visible: { value: undefined, status: 'VALID' },
       invisiblestring: { value: undefined, status: 'VALID' },
-      invisibleboolean: { value: undefined, status: 'VALID' }
+      invisibleboolean: { value: false, status: 'VALID' }
     }
   });
 
   helpers.setInput('tshidden --invisiblestrin');
   helpers.check({
     input:  'tshidden --invisiblestrin',
     hints:                           ' [options]',
     markup: 'VVVVVVVVVEEEEEEEEEEEEEEEE',
     status: 'ERROR',
     args: {
       visible: { value: undefined, status: 'VALID' },
       invisiblestring: { value: undefined, status: 'VALID' },
-      invisibleboolean: { value: undefined, status: 'VALID' }
+      invisibleboolean: { value: false, status: 'VALID' }
     }
   });
 
   helpers.setInput('tshidden --invisiblestring');
   helpers.check({
     input:  'tshidden --invisiblestring',
     hints:                            ' <string> [options]',
     markup: 'VVVVVVVVVIIIIIIIIIIIIIIIII',
     status: 'ERROR',
     args: {
       visible: { value: undefined, status: 'VALID' },
       invisiblestring: { value: undefined, status: 'INCOMPLETE' },
-      invisibleboolean: { value: undefined, status: 'VALID' }
+      invisibleboolean: { value: false, status: 'VALID' }
     }
   });
 
   helpers.setInput('tshidden --invisiblestring x');
   helpers.check({
     input:  'tshidden --invisiblestring x',
     hints:                              ' [options]',
     markup: 'VVVVVVVVVVVVVVVVVVVVVVVVVVVV',
     status: 'VALID',
     args: {
       visible: { value: undefined, status: 'VALID' },
       invisiblestring: { value: 'x', status: 'VALID' },
-      invisibleboolean: { value: undefined, status: 'VALID' }
+      invisibleboolean: { value: false, status: 'VALID' }
     }
   });
 
   helpers.setInput('tshidden --invisibleboolea');
   helpers.check({
     input:  'tshidden --invisibleboolea',
     hints:                            ' [options]',
     markup: 'VVVVVVVVVEEEEEEEEEEEEEEEEE',
     status: 'ERROR',
     args: {
       visible: { value: undefined, status: 'VALID' },
       invisiblestring: { value: undefined, status: 'VALID' },
-      invisibleboolean: { value: undefined, status: 'VALID' }
+      invisibleboolean: { value: false, status: 'VALID' }
     }
   });
 
   helpers.setInput('tshidden --invisibleboolean');
   helpers.check({
     input:  'tshidden --invisibleboolean',
     hints:                             ' [options]',
     markup: 'VVVVVVVVVVVVVVVVVVVVVVVVVVV',
@@ -3223,17 +3443,17 @@ exports.testHidden = function(options) {
   helpers.check({
     input:  'tshidden --visible xxx',
     markup: 'VVVVVVVVVVVVVVVVVVVVVV',
     status: 'VALID',
     hints:  '',
     args: {
       visible: { value: 'xxx', status: 'VALID' },
       invisiblestring: { value: undefined, status: 'VALID' },
-      invisibleboolean: { value: undefined, status: 'VALID' }
+      invisibleboolean: { value: false, status: 'VALID' }
     }
   });
 };
 
 });
 /*
  * Copyright 2012, Mozilla Foundation and contributors
  *
@@ -3250,30 +3470,40 @@ exports.testHidden = function(options) {
  * limitations under the License.
  */
 
 define('gclitest/testIntro', ['require', 'exports', 'module' , 'gclitest/helpers', 'test/assert'], function(require, exports, module) {
 
   var helpers = require('gclitest/helpers');
   var test = require('test/assert');
 
+  exports.setup = function(options) {
+    helpers.setup(options);
+  };
+
+  exports.shutdown = function(options) {
+    helpers.shutdown(options);
+  };
+
   exports.testIntroStatus = function(options) {
     if (options.isFirefox) {
       test.log('Skipping testIntroStatus in Firefox.');
       return;
     }
 
-    helpers.status(options, {
+    helpers.setInput('intro');
+    helpers.check({
       typed:  'intro',
       markup: 'VVVVV',
       status: 'VALID',
       hints: ''
     });
 
-    helpers.status(options, {
+    helpers.setInput('intro foo');
+    helpers.check({
       typed:  'intro foo',
       markup: 'VVVVVVEEE',
       status: 'ERROR',
       hints: ''
     });
   };
 
   exports.testIntroExec = function(options) {
@@ -3671,20 +3901,19 @@ exports.testIncrDecr = function() {
 
 });
 /*
  * Copyright 2009-2011 Mozilla Foundation and contributors
  * Licensed under the New BSD license. See LICENSE.txt or:
  * http://opensource.org/licenses/BSD-3-Clause
  */
 
-define('gclitest/testMenu', ['require', 'exports', 'module' , 'test/assert', 'gclitest/helpers', 'gclitest/mockCommands'], function(require, exports, module) {
-
-
-var test = require('test/assert');
+define('gclitest/testMenu', ['require', 'exports', 'module' , 'gclitest/helpers', 'gclitest/mockCommands'], function(require, exports, module) {
+
+
 var helpers = require('gclitest/helpers');
 var mockCommands = require('gclitest/mockCommands');
 
 
 exports.setup = function(options) {
   mockCommands.setup();
   helpers.setup(options);
 };
@@ -3700,28 +3929,303 @@ exports.testOptions = function(options) 
     input:  'tslong',
     markup: 'VVVVVV',
     status: 'ERROR',
     hints: ' <msg> [options]',
     args: {
       msg: { value: undefined, status: 'INCOMPLETE' },
       num: { value: undefined, status: 'VALID' },
       sel: { value: undefined, status: 'VALID' },
-      bool: { value: undefined, status: 'VALID' },
-      bool2: { value: undefined, status: 'VALID' },
+      bool: { value: false, status: 'VALID' },
+      bool2: { value: false, status: 'VALID' },
       sel2: { value: undefined, status: 'VALID' },
       num2: { value: undefined, status: 'VALID' }
     }
   });
 };
 
 
 });
 
 /*
+ * Copyright 2009-2011 Mozilla Foundation and contributors
+ * Licensed under the New BSD license. See LICENSE.txt or:
+ * http://opensource.org/licenses/BSD-3-Clause
+ */
+
+define('gclitest/testNode', ['require', 'exports', 'module' , 'test/assert', 'gclitest/helpers', 'gclitest/mockCommands'], function(require, exports, module) {
+
+
+var test = require('test/assert');
+var helpers = require('gclitest/helpers');
+var mockCommands = require('gclitest/mockCommands');
+
+
+exports.setup = function(options) {
+  mockCommands.setup();
+  helpers.setup(options);
+};
+
+exports.shutdown = function(options) {
+  mockCommands.shutdown();
+  helpers.shutdown(options);
+};
+
+exports.testNode = function(options) {
+  var requisition = options.display.requisition;
+
+  helpers.setInput('tse ');
+  helpers.check({
+    input:  'tse ',
+    hints:      '<node> [options]',
+    markup: 'VVVV',
+    cursor: 4,
+    current: 'node',
+    status: 'ERROR',
+    args: {
+      command: { name: 'tse' },
+      node: { status: 'INCOMPLETE', message: '' },
+      nodes: { status: 'VALID' },
+      nodes2: { status: 'VALID' }
+    }
+  });
+
+  helpers.setInput('tse :');
+  helpers.check({
+    input:  'tse :',
+    hints:       ' [options]',
+    markup: 'VVVVE',
+    cursor: 5,
+    current: 'node',
+    status: 'ERROR',
+    args: {
+      command: { name: 'tse' },
+      node: {
+        arg: ' :',
+        status: 'ERROR',
+        message: 'Syntax error in CSS query'
+      },
+      nodes: { status: 'VALID' },
+      nodes2: { status: 'VALID' }
+    }
+  });
+
+  helpers.setInput('tse :root');
+  helpers.check({
+    input:  'tse :root',
+    hints:           ' [options]',
+    markup: 'VVVVVVVVV',
+    cursor: 9,
+    current: 'node',
+    status: 'VALID',
+    args: {
+      command: { name: 'tse' },
+      node: { arg: ' :root', status: 'VALID' },
+      nodes: { status: 'VALID' },
+      nodes2: { status: 'VALID' }
+    }
+  });
+
+  helpers.setInput('tse :root ');
+  helpers.check({
+    input:  'tse :root ',
+    hints:            '[options]',
+    markup: 'VVVVVVVVVV',
+    cursor: 10,
+    current: 'node',
+    status: 'VALID',
+    args: {
+      command: { name: 'tse' },
+      node: { arg: ' :root ', status: 'VALID' },
+      nodes: { status: 'VALID' },
+      nodes2: { status: 'VALID' }
+    }
+  });
+  test.is(requisition.getAssignment('node').value.tagName,
+          'HTML',
+          'root id');
+
+  helpers.setInput('tse #gcli-nomatch');
+  helpers.check({
+    input:  'tse #gcli-nomatch',
+    hints:                   ' [options]',
+    markup: 'VVVVIIIIIIIIIIIII',
+    cursor: 17,
+    current: 'node',
+    status: 'ERROR',
+    args: {
+      command: { name: 'tse' },
+      node: {
+        value: undefined,
+        arg: ' #gcli-nomatch',
+        status: 'INCOMPLETE',
+        message: 'No matches'
+      },
+      nodes: { status: 'VALID' },
+      nodes2: { status: 'VALID' }
+    }
+  });
+
+  helpers.setInput('tse #');
+  helpers.check({
+    input:  'tse #',
+    hints:       ' [options]',
+    markup: 'VVVVE',
+    cursor: 5,
+    current: 'node',
+    status: 'ERROR',
+    args: {
+      command: { name: 'tse' },
+      node: {
+        value: undefined,
+        arg: ' #',
+        status: 'ERROR',
+        message: 'Syntax error in CSS query'
+      },
+      nodes: { status: 'VALID' },
+      nodes2: { status: 'VALID' }
+    }
+  });
+
+  helpers.setInput('tse .');
+  helpers.check({
+    input:  'tse .',
+    hints:       ' [options]',
+    markup: 'VVVVE',
+    cursor: 5,
+    current: 'node',
+    status: 'ERROR',
+    args: {
+      command: { name: 'tse' },
+      node: {
+        value: undefined,
+        arg: ' .',
+        status: 'ERROR',
+        message: 'Syntax error in CSS query'
+      },
+      nodes: { status: 'VALID' },
+      nodes2: { status: 'VALID' }
+    }
+  });
+
+  helpers.setInput('tse *');
+  helpers.check({
+    input:  'tse *',
+    hints:       ' [options]',
+    markup: 'VVVVE',
+    cursor: 5,
+    current: 'node',
+    status: 'ERROR',
+    args: {
+      command: { name: 'tse' },
+      node: {
+        value: undefined,
+        arg: ' *',
+        status: 'ERROR',
+        // message: 'Too many matches (128)'
+      },
+      nodes: { status: 'VALID' },
+      nodes2: { status: 'VALID' }
+    }
+  });
+};
+
+exports.testNodes = function(options) {
+  var requisition = options.display.requisition;
+
+  helpers.setInput('tse :root --nodes *');
+  helpers.check({
+    input:  'tse :root --nodes *',
+    hints:                       ' [options]',
+    markup: 'VVVVVVVVVVVVVVVVVVV',
+    current: 'nodes',
+    status: 'VALID',
+    args: {
+      command: { name: 'tse' },
+      node: { arg: ' :root', status: 'VALID' },
+      nodes: { arg: ' --nodes *', status: 'VALID' },
+      nodes2: { status: 'VALID' }
+    }
+  });
+  test.is(requisition.getAssignment('node').value.tagName,
+          'HTML',
+          '#gcli-input id');
+
+  helpers.setInput('tse :root --nodes2 div');
+  helpers.check({
+    input:  'tse :root --nodes2 div',
+    hints:                       ' [options]',
+    markup: 'VVVVVVVVVVVVVVVVVVVVVV',
+    cursor: 22,
+    current: 'nodes2',
+    status: 'VALID',
+    args: {
+      command: { name: 'tse' },
+      node: { arg: ' :root', status: 'VALID' },
+      nodes: { status: 'VALID' },
+      nodes2: { arg: ' --nodes2 div', status: 'VALID' }
+    }
+  });
+  test.is(requisition.getAssignment('node').value.tagName,
+          'HTML',
+          'root id');
+
+  helpers.setInput('tse --nodes ffff');
+  helpers.check({
+    input:  'tse --nodes ffff',
+    hints:                  ' <node> [options]',
+    markup: 'VVVVIIIIIIIVIIII',
+    cursor: 16,
+    current: 'nodes',
+    status: 'ERROR',
+    outputState: 'false:default',
+    tooltipState: 'true:isError',
+    args: {
+      command: { name: 'tse' },
+      node: { value: undefined, arg: '', status: 'INCOMPLETE', message: '' },
+      nodes: { value: undefined, arg: ' --nodes ffff', status: 'INCOMPLETE', message: 'No matches' },
+      nodes2: { arg: '', status: 'VALID', message: '' },
+    }
+  });
+  /*
+  test.is(requisition.getAssignment('nodes2').value.constructor.name,
+          'NodeList',
+          '#gcli-input id');
+  */
+
+  helpers.setInput('tse --nodes2 ffff');
+  helpers.check({
+    input:  'tse --nodes2 ffff',
+    hints:                   ' <node> [options]',
+    markup: 'VVVVVVVVVVVVVVVVV',
+    cursor: 17,
+    current: 'nodes2',
+    status: 'ERROR',
+    outputState: 'false:default',
+    tooltipState: 'false:default',
+    args: {
+      command: { name: 'tse' },
+      node: { value: undefined, arg: '', status: 'INCOMPLETE', message: '' },
+      nodes: { arg: '', status: 'VALID', message: '' },
+      nodes2: { arg: ' --nodes2 ffff', status: 'VALID', message: '' },
+    }
+  });
+  /*
+  test.is(requisition.getAssignment('nodes').value.constructor.name,
+          'NodeList',
+          '#gcli-input id');
+  test.is(requisition.getAssignment('nodes2').value.constructor.name,
+          'NodeList',
+          '#gcli-input id');
+  */
+};
+
+
+});
+/*
  * Copyright 2012, Mozilla Foundation and contributors
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
  * http://www.apache.org/licenses/LICENSE-2.0
  *
@@ -3737,134 +4241,152 @@ define('gclitest/testPref', ['require', 
 
 var pref = require('gcli/commands/pref');
 var helpers = require('gclitest/helpers');
 var mockSettings = require('gclitest/mockSettings');
 var test = require('test/assert');
 
 
 exports.setup = function(options) {
+  helpers.setup(options);
+
   if (!options.isFirefox) {
     mockSettings.setup();
   }
   else {
     test.log('Skipping testPref in Firefox.');
   }
 };
 
 exports.shutdown = function(options) {
+  helpers.shutdown(options);
+
   if (!options.isFirefox) {
     mockSettings.shutdown();
   }
 };
 
 exports.testPrefShowStatus = function(options) {
   if (options.isFirefox) {
     test.log('Skipping testPrefShowStatus in Firefox.');
     return;
   }
 
-  helpers.status(options, {
+  helpers.setInput('pref s');
+  helpers.check({
     typed:  'pref s',
     hints:        'et',
     markup: 'IIIIVI',
     status: 'ERROR'
   });
 
-  helpers.status(options, {
+  helpers.setInput('pref show');
+  helpers.check({
     typed:  'pref show',
     hints:           ' <setting>',
     markup: 'VVVVVVVVV',
     status: 'ERROR'
   });
 
-  helpers.status(options, {
+  helpers.setInput('pref show ');
+  helpers.check({
     typed:  'pref show ',
     hints:            'allowSet',
     markup: 'VVVVVVVVVV',
     status: 'ERROR'
   });
 
-  helpers.status(options, {
+  helpers.setInput('pref show tempTBo');
+  helpers.check({
     typed:  'pref show tempTBo',
     hints:                   'ol',
     markup: 'VVVVVVVVVVIIIIIII',
     status: 'ERROR'
   });
 
-  helpers.status(options, {
+  helpers.setInput('pref show tempTBool');
+  helpers.check({
     typed:  'pref show tempTBool',
     markup: 'VVVVVVVVVVVVVVVVVVV',
     status: 'VALID',
     hints:  ''
   });
 
-  helpers.status(options, {
+  helpers.setInput('pref show tempTBool 4');
+  helpers.check({
     typed:  'pref show tempTBool 4',
     markup: 'VVVVVVVVVVVVVVVVVVVVE',
     status: 'ERROR',
     hints:  ''
   });
 
-  helpers.status(options, {
+  helpers.setInput('pref show tempNumber 4');
+  helpers.check({
     typed:  'pref show tempNumber 4',
     markup: 'VVVVVVVVVVVVVVVVVVVVVE',
     status: 'ERROR',
     hints:  ''
   });
 };
 
 exports.testPrefSetStatus = function(options) {
   if (options.isFirefox) {
     test.log('Skipping testPrefSetStatus in Firefox.');
     return;
   }
 
-  helpers.status(options, {
+  helpers.setInput('pref s');
+  helpers.check({
     typed:  'pref s',
     hints:        'et',
     markup: 'IIIIVI',
     status: 'ERROR',
   });
 
-  helpers.status(options, {
+  helpers.setInput('pref set');
+  helpers.check({
     typed:  'pref set',
     hints:          ' <setting> <value>',
     markup: 'VVVVVVVV',
     status: 'ERROR'
   });
 
-  helpers.status(options, {
+  helpers.setInput('pref xxx');
+  helpers.check({
     typed:  'pref xxx',
     markup: 'EEEEVEEE',
     status: 'ERROR'
   });
 
-  helpers.status(options, {
+  helpers.setInput('pref set ');
+  helpers.check({
     typed:  'pref set ',
     hints:           'allowSet <value>',
     markup: 'VVVVVVVVV',
     status: 'ERROR'
   });
 
-  helpers.status(options, {
+  helpers.setInput('pref set tempTBo');
+  helpers.check({
     typed:  'pref set tempTBo',
     hints:                  'ol <value>',
     markup: 'VVVVVVVVVIIIIIII',
     status: 'ERROR'
   });
 
-  helpers.status(options, {
+  helpers.setInput('pref set tempTBool 4');
+  helpers.check({
     typed:  'pref set tempTBool 4',
     markup: 'VVVVVVVVVVVVVVVVVVVE',
     status: 'ERROR',
     hints: ''
   });
 
-  helpers.status(options, {
+  helpers.setInput('pref set tempNumber 4');
+  helpers.check({
     typed:  'pref set tempNumber 4',
     markup: 'VVVVVVVVVVVVVVVVVVVVV',
     status: 'VALID',
     hints: ''
   });
 };
 
 exports.testPrefExec = function(options) {
@@ -5062,16 +5584,20 @@ exports.testDefault = function(options) 
     // boolean and array types are exempt from needing undefined blank values
     if (type.name === 'boolean') {
       test.is(blank, false, 'blank boolean is false');
     }
     else if (type.name === 'array') {
       test.ok(Array.isArray(blank), 'blank array is array');
       test.is(blank.length, 0, 'blank array is empty');
     }
+    else if (type.name === 'nodelist') {
+      test.ok(typeof blank.item, 'function', 'blank.item is function');
+      test.is(blank.length, 0, 'blank nodelist is empty');
+    }
     else {
       test.is(blank, undefined, 'default defined for ' + type.name);
     }
   });
 };
 
 exports.testNullDefault = function(options) {
   forEachType({ defaultValue: null }, function(type) {
@@ -5815,24 +6341,26 @@ let testModuleNames = [
   'test/assert',
   'test/status',
   'gclitest/testCanon',
   'gclitest/helpers',
   'gclitest/testCli',
   'gclitest/mockCommands',
   'gclitest/testCompletion',
   'gclitest/testExec',
+  'gclitest/testFocus',
   'gclitest/testHelp',
   'gclitest/testHistory',
   'gclitest/testInputter',
   'gclitest/testIncomplete',
   'gclitest/testIntro',
   'gclitest/testJs',
   'gclitest/testKeyboard',
   'gclitest/testMenu',
+  'gclitest/testNode',
   'gclitest/testPref',
   'gclitest/mockSettings',
   'gclitest/testRequire',
   'gclitest/requirable',
   'gclitest/testResource',
   'gclitest/testScratchpad',
   'gclitest/testSettings',
   'gclitest/testSpell',