Parsing Commands in the Omnibox
Turn an extension's omnibox keyword into a small command line: parsing subcommands and arguments, suggesting commands as the user types, showing usage hints, quoting, and dispatching to handlers safely.
Table of Contents
A tab-manager extension registers the keyword tm. Typing tm save reading-list should save the current window as a session, tm open reading-list should restore it, tm close dupes should close duplicate tabs, and plain tm github should just search tabs. That is a command line living in the address bar — fast for power users and invisible to everyone else. The omnibox API gives you raw text on each keystroke and on Enter; turning that into commands with arguments, live usage hints and safe dispatch is up to you. This guide builds a small parser and suggestion layer. It belongs to omnibox and address bar integration.
A command grammar for the omnibox
Keep the grammar simple enough to type without thinking: the first word is a command if it matches a known name or alias; the rest are arguments, split on spaces with quotes for arguments containing spaces. If the first word is not a command, the whole input is a default action (usually search). Each command declares its arguments, so the suggestion layer can show usage (“save ‹name›”) while the user types, and can complete argument values (existing session names, open tab titles). Enter dispatches the parsed command to a handler; unknown or incomplete commands fall back to showing help rather than doing something surprising.
Step-by-step: an omnibox command line
1. Declare commands as data
1// commands.js
2export const COMMANDS = [
3 { name: "save", aliases: ["s"], args: [{ name: "name", required: true }], help: "Save this window as a session",
4 run: ({ name }) => saveSession(name) },
5 { name: "open", aliases: ["o"], args: [{ name: "name", required: true, complete: sessionNames }], help: "Restore a saved session",
6 run: ({ name }, disposition) => openSession(name, disposition) },
7 { name: "close", aliases: [], args: [{ name: "what", required: true, choices: ["dupes", "others", "right"] }], help: "Close tabs",
8 run: ({ what }) => closeTabs(what) },
9 { name: "help", aliases: ["?"], args: [], help: "List commands",
10 run: () => chrome.tabs.create({ url: chrome.runtime.getURL("help.html#omnibox") }) },
11];
12export const byName = new Map(COMMANDS.flatMap((c) => [[c.name, c], ...c.aliases.map((a) => [a, c])]));
Execution context: the service worker. A declarative table gives the parser, the suggestions and the help page one source of truth. complete functions supply argument values; choices constrain them.
2. Tokenise with quotes
1export function tokenize(input) {
2 const tokens = [];
3 const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
4 for (const m of input.matchAll(re)) tokens.push(m[1] ?? m[2] ?? m[3]);
5 const trailingSpace = /\s$/.test(input);
6 return { tokens, trailingSpace };
7}
8// tokenize("open 'work tabs'") → { tokens: ["open", "work tabs"], trailingSpace: false }
Execution context: the service worker. Quoted arguments allow spaces in names. trailingSpace tells the suggestion layer whether the user is still typing the current token or has moved on to the next, which changes what to suggest.
3. Parse into a command and arguments
1export function parse(input) {
2 const { tokens, trailingSpace } = tokenize(input.trim() ? input : "");
3 const cmd = byName.get(tokens[0]?.toLowerCase());
4 if (!cmd) return { kind: "search", query: input.trim() };
5 const values = tokens.slice(1);
6 const args = {}; const errors = [];
7 cmd.args.forEach((spec, i) => {
8 const v = i === cmd.args.length - 1 ? values.slice(i).join(" ") : values[i]; // last arg takes the rest
9 if (!v && spec.required) errors.push(`missing ‹${spec.name}›`);
10 else if (v && spec.choices && !spec.choices.includes(v)) errors.push(`‹${spec.name}› must be ${spec.choices.join(", ")}`);
11 else if (v) args[spec.name] = v;
12 });
13 return { kind: "command", cmd, args, errors, trailingSpace, argIndex: Math.max(0, values.length - (trailingSpace ? 0 : 1)) };
14}
Execution context: the service worker. The last argument swallows remaining words, so save my reading list works without quotes. Errors are collected rather than thrown, so the suggestion layer can show them as hints.
4. Suggest commands and arguments as the user types
1chrome.omnibox.onInputChanged.addListener(async (text, suggest) => {
2 const p = parse(text);
3 if (p.kind === "search") {
4 const partial = COMMANDS.filter((c) => c.name.startsWith(text.trim().toLowerCase()) && text.trim());
5 chrome.omnibox.setDefaultSuggestion({ description: partial[0] ? usage(partial[0]) : `Search tabs for <match>${escapeXml(text)}</match>` });
6 return suggest([...partial.slice(1).map(cmdSuggestion), ...(await tabMatches(text))].slice(0, 6));
7 }
8 const spec = p.cmd.args[p.argIndex];
9 chrome.omnibox.setDefaultSuggestion({ description: p.errors.length ? `${usage(p.cmd)} <dim>— ${escapeXml(p.errors[0])}</dim>` : `${escapeXml(p.cmd.help)}: <match>${escapeXml(Object.values(p.args).join(" "))}</match>` });
10 if (spec?.complete || spec?.choices) {
11 const prefix = p.trailingSpace ? "" : (Object.values(p.args).at(-1) ?? "");
12 const values = spec.choices ?? (await spec.complete());
13 return suggest(values.filter((v) => v.toLowerCase().startsWith(prefix.toLowerCase())).slice(0, 6)
14 .map((v) => ({ content: `${p.cmd.name} ${quote(v)}`, description: `${p.cmd.name} <match>${escapeXml(v)}</match>` })));
15 }
16 suggest([]);
17});
18
19const usage = (c) => `<match>${c.name}</match> ${c.args.map((a) => `‹${a.name}›`).join(" ")} <dim>— ${escapeXml(c.help)}</dim>`;
20const quote = (v) => (/\s/.test(v) ? `"${v}"` : v);
Execution context: the service worker. The default row always shows either usage for the command being typed, an error hint, or what Enter will do. Argument completions put the full command line in content, so selecting one and pressing Enter dispatches the complete command. See escaping XML in omnibox descriptions.
5. Dispatch on Enter
1chrome.omnibox.onInputEntered.addListener(async (text, disposition) => {
2 const p = parse(text);
3 if (p.kind === "search") return searchTabs(p.query, disposition);
4 if (p.errors.length) return chrome.tabs.create({ url: chrome.runtime.getURL(`help.html#${p.cmd.name}`) });
5 try {
6 await p.cmd.run(p.args, disposition);
7 } catch (e) {
8 notifyError(chrome.i18n.getMessage("commandFailed", [p.cmd.name, e.message]));
9 }
10});
Execution context: the service worker. Incomplete commands open help for that command rather than guessing. Errors from handlers are reported, since the omnibox closes on Enter and offers no other feedback. Commands that open pages should respect disposition, as in handling omnibox input entered navigation.
6. Guard destructive commands
close others closes many tabs on a single Enter. For destructive commands, either make them undoable (save the closed tabs as a session and show a notification with “Undo”), or require an explicit confirmation word (close others!). Never let a fuzzy match on the command name trigger a destructive action — match command names exactly or by declared alias only.
7. Generate help from the table
1export const helpHtml = () => COMMANDS.map((c) =>
2 `<dt><kbd>tm ${c.name}</kbd> ${c.args.map((a) => `‹${a.name}›`).join(" ")}</dt><dd>${c.help}</dd>`).join("");
Execution context: the help page. Generating help from the same table guarantees it is never out of date.
Common mistakes
- Fuzzy-matching command names. Typos trigger the wrong command.
- No usage hints. Users cannot discover arguments.
contentwithout the command. Picking a completion then dispatches only the argument.- Silent failures. The omnibox closes; show a notification on error.
- Unescaped user text in descriptions. Breaks the XML.
Cross-browser variation
- Chrome / Edge: XML descriptions with
<match>,<dim>,<url>support usage hints. - Firefox: same events; descriptions are plain text, so usage hints must be written without markup.
- Safari: no omnibox API; put the command line in the popup’s search field.
Verification
- Type each command partially and confirm usage appears in the default row.
- Type
openand confirm session names are suggested and selecting one opens it. - Type
close maybeand confirm the choices hint appears and Enter opens help. - Type a non-command and confirm search runs.
FAQ
Can the keyword itself vary per command?
No. An extension registers one keyword; subcommands live inside it.
How do I let users discover the commands?
Show all commands when the input is empty, and add a help command and a help page.
Should arguments be case-sensitive?
Usually not for matching; preserve case when saving names.
Can commands take flags like --all?
Yes — treat tokens beginning with -- as flags before assigning positional arguments, and suggest them in completions. Keep flags rare; most omnibox commands are clearer with a subcommand instead.
How do I add a new command later?
Add one entry to the table with its arguments, help text and handler. Suggestions, parsing and the generated help page pick it up automatically, which is the main reason to keep commands declarative.
Related
- Registering an omnibox keyword — the keyword.
- Ranking omnibox suggestions — ordering completions.
- Building a command palette in an extension — the same idea in the popup.
- Omnibox and address bar integration — the parent topic.