Chrome Extension Omnibox Api — Best Practices
43 min readChrome Extension Omnibox API Patterns
The Chrome Omnibox API allows extensions to integrate deeply with the browser’s address bar, providing custom suggestions and commands. This guide covers practical patterns for building powerful omnibox experiences.
Overview
The Omnibox API enables your extension to:
- Provide keyword-triggered suggestions in the address bar
- Execute commands or search through custom data
- Navigate users to specific pages or perform actions
Manifest Declaration:
{
"omnibox": {
"keyword": "ext"
}
}
Pattern 1: Basic Omnibox Setup
The foundation of any omnibox implementation involves three core event handlers that manage the user interaction lifecycle.
Manifest Configuration
Define the keyword that triggers your extension in the omnibox:
{
"name": "My Extension",
"version": "1.0.0",
"omnibox": {
"keyword": "ext"
}
}
Event Handlers
The omnibox interaction follows a clear lifecycle: activation → typing → selection.
// src/omnibox/index.ts
import { OmniboxService } from '../services/OmniboxService';
const omniboxService = new OmniboxService();
/**
* Fired when the user starts interacting with your extension's omnibox
* by typing your keyword and pressing Tab or Space.
*/
chrome.omnibox.onInputStarted.addListener(() => {
console.log('[Omnibox] Input started - user activated extension');
omniboxService.onInputStarted();
});
/**
* Fired whenever the user's input changes. Use this to provide
* suggestions based on the current input text.
*/
chrome.omnibox.onInputChanged.addListener((text, suggest) => {
omniboxService.onInputChanged(text, suggest);
});
/**
* Fired when the user accepts one of your suggestions.
* This is where you execute the actual action.
*/
chrome.omnibox.onInputEntered.addListener((text, disposition) => {
omniboxService.onInputEntered(text, disposition);
});
/**
* Optional: Handle when the user clears the omnibox
*/
chrome.omnibox.onInputCancelled.addListener(() => {
console.log('[Omnibox] Input cancelled');
});
Service Implementation
// src/services/OmniboxService.ts
import { suggest, Suggestion } from '@theluckystrike/webext-omnibox';
export class OmniboxService {
private defaultSuggestions: Suggestion[] = [
{ content: 'help', description: 'Show help' },
{ content: 'search ', description: 'Search...' },
];
onInputStarted(): void {
// Initialize any state needed
console.log('Omnibox session started');
}
async onInputChanged(text: string, suggest: (suggestions: Suggestion[]) => void): Promise<void> {
if (!text) {
// Show default suggestions when no input
suggest(this.defaultSuggestions);
return;
}
// Filter suggestions based on input
const filtered = this.defaultSuggestions.filter(s =>
s.content.toLowerCase().includes(text.toLowerCase())
);
suggest(filtered);
}
onInputEntered(text: string, disposition: chrome.omnibox.OnInputEnteredDisposition): void {
console.log(`User selected: ${text}, disposition: ${disposition}`);
switch (text) {
case 'help':
this.openHelpPage();
break;
default:
if (text.startsWith('search ')) {
this.performSearch(text.replace('search ', ''));
}
}
}
private openHelpPage(): void {
chrome.tabs.create({ url: 'pages/help.html' });
}
private performSearch(query: string): void {
chrome.tabs.update({ url: `https://example.com/search?q=${encodeURIComponent(query)}` });
}
}
Pattern 2: Typed Suggestion Provider
Provide contextual suggestions as users type, with proper XML formatting for rich display.
SuggestResult Interface
import { Suggestion } from '@theluckystrike/webext-omnibox';
interface SuggestResult {
/** The text displayed in the omnibox when selected */
content: string;
/** The description shown in the suggestion dropdown - supports XML tags */
description: string;
/** Whether the suggestion can be deleted by the user */
deletable?: boolean;
/** Optional: URL for navigation suggestions */
url?: string;
}
XML Formatting Tags
The description supports several XML tags for styling:
<match>- Highlights matching text in bold<dim>- Displays text in gray/secondary color<url>- Displays text as a URL (blue, underlined)
// src/services/SuggestionProvider.ts
export interface SearchResult {
id: string;
title: string;
url: string;
type: 'page' | 'bookmark' | 'recent';
}
export class SuggestionProvider {
/**
* Convert search results to omnibox suggestions with XML formatting
*/
resultsToSuggestions(results: SearchResult[], query: string): Suggestion[] {
return results.slice(0, 5).map((result, index) => ({
content: result.url,
description: this.formatDescription(result, query, index === 0),
deletable: result.type === 'recent',
}));
}
/**
* Format description with XML tags for rich display
*/
private formatDescription(result: SearchResult, query: string, isFirst: boolean): string {
const typeIcon = result.type === 'bookmark' ? '★ ' : result.type === 'recent' ? '⏱ ' : '📄 ';
const title = this.highlightMatch(result.title, query);
return `${typeIcon}${title}<dim> — ${this.truncateUrl(result.url)}</dim>`;
}
/**
* Highlight matching text using <match> tag
*/
private highlightMatch(text: string, query: string): string {
if (!query) return this.escapeXml(text);
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
const index = lowerText.indexOf(lowerQuery);
if (index === -1) return this.escapeXml(text);
const before = this.escapeXml(text.substring(0, index));
const match = this.escapeXml(text.substring(index, index + query.length));
const after = this.escapeXml(text.substring(index + query.length));
return `${before}<match>${match}</match>${after}`;
}
/**
* Escape XML special characters
*/
private escapeXml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* Truncate long URLs for display
*/
private truncateUrl(url: string, maxLength: number = 50): string {
if (url.length <= maxLength) return url;
return url.substring(0, maxLength - 3) + '...';
}
}
Usage Example
// src/omnibox/search.ts
import { SuggestionProvider, SearchResult } from '../services/SuggestionProvider';
import { storage } from '@theluckystrike/webext-storage';
const provider = new SuggestionProvider();
chrome.omnibox.onInputChanged.addListener(async (text, suggest) => {
if (!text || text.length < 2) {
suggest([]);
return;
}
// Fetch from storage or search index
const results = await searchBookmarks(text);
const suggestions = provider.resultsToSuggestions(results, text);
suggest(suggestions);
});
async function searchBookmarks(query: string): Promise<SearchResult[]> {
// Search implementation
return [];
}
Pattern 3: Async Search with Debounce
Avoid excessive API calls by debouncing user input and showing loading states.
Debounce Implementation
// src/utils/debounce.ts
export function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = null;
}, delay);
};
}
Async Search Service
// src/services/AsyncSearchService.ts
import { debounce } from '../utils/debounce';
import { Suggestion } from '@theluckystrike/webext-omnibox';
import { storage } from '@theluckystrike/webext-storage';
export interface SearchableItem {
id: string;
title: string;
url: string;
tags: string[];
}
export class AsyncSearchService {
private debouncedSearch: ReturnType<typeof debounce>;
private lastQuery: string = '';
constructor() {
// Debounce input changes by 150ms
this.debouncedSearch = debounce(this.performSearch.bind(this), 150);
}
async handleInput(text: string, suggest: (suggestions: Suggestion[]) => void): Promise<void> {
if (!text.trim()) {
// Show recent searches when empty
const recent = await this.getRecentSearches();
suggest(this.recentToSuggestions(recent));
return;
}
// Show loading placeholder while fetching
suggest([{
content: '',
description: '<dim>Searching...</dim>',
}]);
this.lastQuery = text;
this.debouncedSearch(text, suggest);
}
private async performSearch(
text: string,
suggest: (suggestions: Suggestion[]) => void
): Promise<void> {
// Skip if query changed while debouncing
if (text !== this.lastQuery) return;
try {
const results = await this.searchItems(text);
const suggestions = this.formatResults(results);
suggest(suggestions);
} catch (error) {
console.error('Search failed:', error);
suggest([{
content: '',
description: '<dim>Search failed. Try again.</dim>',
}]);
}
}
private async searchItems(query: string): Promise<SearchableItem[]> {
// Example: search from chrome.storage
const data = await storage.get<{ items: SearchableItem[] }>('searchIndex');
const items = data?.items ?? [];
const lowerQuery = query.toLowerCase();
return items.filter(item =>
item.title.toLowerCase().includes(lowerQuery) ||
item.tags.some(tag => tag.toLowerCase().includes(lowerQuery))
);
}
private formatResults(results: SearchableItem[]): Suggestion[] {
return results.slice(0, 5).map(item => ({
content: item.url,
description: this.escapeXml(item.title),
deletable: true,
}));
}
private async getRecentSearches(): Promise<string[]> {
const data = await storage.get<{ recent: string[] }>('recentSearches');
return data?.recent ?? [];
}
private recentToSuggestions(recent: string[]): Suggestion[] {
return recent.slice(0, 5).map(query => ({
content: query,
description: `<dim>Recent:</dim> ${this.escapeXml(query)}`,
}));
}
private escapeXml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
}
Pattern 4: Command Router Pattern
Implement subcommands for a more powerful CLI-like interface in the omnibox.
Command Router
// src/services/CommandRouter.ts
export interface Command {
name: string;
description: string;
usage: string;
handler: CommandHandler;
}
export type CommandHandler = (
args: string,
disposition: chrome.omnibox.OnInputEnteredDisposition
) => void | Promise<void>;
export interface ParsedCommand {
command: string;
args: string;
}
export class CommandRouter {
private commands: Map<string, Command> = new Map();
register(command: Command): void {
this.commands.set(command.name, command);
}
/**
* Parse input into command and arguments
* Input: "search my query" -> { command: "search", args: "my query" }
*/
parse(input: string): ParsedCommand {
const parts = input.trim().split(/\s+/);
const command = parts[0]?.toLowerCase() ?? '';
const args = parts.slice(1).join(' ');
return { command, args };
}
/**
* Route input to appropriate command handler
*/
async route(
input: string,
disposition: chrome.omnibox.OnInputEnteredDisposition
): Promise<void> {
const { command, args } = this.parse(input);
const handler = this.commands.get(command);
if (handler) {
await handler.handler(args, disposition);
} else {
console.warn(`Unknown command: ${command}`);
}
}
/**
* Get suggestions for command input
*/
getSuggestions(input: string): Array<{ content: string; description: string }> {
const { command, args } = this.parse(input);
if (!command) {
// Show all available commands
return Array.from(this.commands.values()).map(cmd => ({
content: cmd.name,
description: `${cmd.description} <dim>${cmd.usage}</dim>`,
}));
}
// Filter commands by prefix
const matches = Array.from(this.commands.values()).filter(cmd =>
cmd.name.startsWith(command.toLowerCase())
);
if (matches.length === 1 && matches[0].name === command) {
// Show command-specific argument suggestions
return this.getArgumentSuggestions(matches[0], args);
}
return matches.map(cmd => ({
content: cmd.name,
description: `${cmd.description} <dim>${cmd.usage}</dim>`,
}));
}
private getArgumentSuggestions(
command: Command,
args: string
): Array<{ content: string; description: string }> {
// Override in subclass for custom argument completion
return [];
}
}
Complete Implementation
// src/omnibox/commands.ts
import { CommandRouter, Command } from '../services/CommandRouter';
import { storage } from '@theluckystrike/webext-storage';
import { messenger } from '@theluckystrike/webext-messaging';
const router = new CommandRouter();
// Register commands
router.register({
name: 'search',
description: 'Search bookmarks and history',
usage: 'search <query>',
handler: async (args) => {
if (!args) {
console.log('Search requires a query');
return;
}
const url = `https://example.com/search?q=${encodeURIComponent(args)}`;
chrome.tabs.update({ url });
},
});
router.register({
name: 'settings',
description: 'Open extension settings',
usage: 'settings',
handler: () => {
chrome.tabs.create({ url: 'pages/settings.html' });
},
});
router.register({
name: 'history',
description: 'Show recent searches',
usage: 'history',
handler: async (_, disposition) => {
const tab = await chrome.tabs.query({ active: true, currentWindow: true });
const recent = await storage.get<{ searches: string[] }>('recentSearches');
// Display recent in side panel or popup
messenger.sendToContent('show-history', { searches: recent?.searches ?? [] });
},
});
router.register({
name: 'clear',
description: 'Clear search history',
usage: 'clear history',
handler: async () => {
await storage.set('recentSearches', { searches: [] });
console.log('History cleared');
},
});
router.register({
name: 'open',
description: 'Open a saved bookmark',
usage: 'open <name>',
handler: async (args) => {
const bookmarks = await storage.get<{ bookmarks: Record<string, string> }>('bookmarks');
const url = bookmarks?.bookmarks?.[args.toLowerCase()];
if (url) {
chrome.tabs.update({ url });
}
},
});
// Omnibox event handlers
chrome.omnibox.onInputChanged.addListener((text, suggest) => {
const suggestions = router.getSuggestions(text);
suggest(suggestions);
});
chrome.omnibox.onInputEntered.addListener((text, disposition) => {
router.route(text, disposition);
});
Pattern 5: History and Recent Searches
Store and display recent queries for better user experience.
History Service
// src/services/HistoryService.ts
import { storage } from '@theluckystrike/webext-storage';
const MAX_HISTORY = 20;
export interface SearchHistoryEntry {
query: string;
timestamp: number;
resultCount: number;
}
export class HistoryService {
private storageKey = 'omniboxHistory';
/**
* Add a search query to history
*/
async addToHistory(query: string, resultCount: number = 0): Promise<void> {
if (!query.trim()) return;
const history = await this.getHistory();
// Remove duplicate if exists
const filtered = history.filter(entry => entry.query !== query);
// Add new entry at the beginning
filtered.unshift({
query: query.trim(),
timestamp: Date.now(),
resultCount,
});
// Trim to max size
const trimmed = filtered.slice(0, MAX_HISTORY);
await storage.set(this.storageKey, { history: trimmed });
}
/**
* Get all history entries
*/
async getHistory(): Promise<SearchHistoryEntry[]> {
const data = await storage.get<{ history: SearchHistoryEntry[] }>(this.storageKey);
return data?.history ?? [];
}
/**
* Get recent queries for display
*/
async getRecentQueries(limit: number = 5): Promise<string[]> {
const history = await this.getHistory();
return history.slice(0, limit).map(entry => entry.query);
}
/**
* Clear all history
*/
async clearHistory(): Promise<void> {
await storage.set(this.storageKey, { history: [] });
}
/**
* Search within history
*/
async searchHistory(query: string): Promise<SearchHistoryEntry[]> {
const history = await this.getHistory();
const lowerQuery = query.toLowerCase();
return history.filter(entry =>
entry.query.toLowerCase().includes(lowerQuery)
);
}
}
Integration with Omnibox
// src/omnibox/withHistory.ts
import { HistoryService } from '../services/HistoryService';
import { Suggestion } from '@theluckystrike/webext-omnibox';
const historyService = new HistoryService();
export async function handleOmniboxInput(
text: string,
suggest: (suggestions: Suggestion[]) => void
): Promise<void> {
if (!text.trim()) {
// Show recent searches when empty
const recent = await historyService.getRecentQueries(5);
const suggestions: Suggestion[] = recent.map(query => ({
content: query,
description: `<dim>Recent search:</dim> ${escapeXml(query)}`,
}));
suggest(suggestions);
return;
}
// Regular search with history tracking
const results = await performSearch(text);
// Add to history when user selects a result
chrome.omnibox.onInputEntered.addListener((selected, disposition) => {
if (selected) {
historyService.addToHistory(text, results.length);
}
});
suggest(formatResults(results));
}
function escapeXml(text: string): string {
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
async function performSearch(query: string): Promise<unknown[]> {
// Implementation
return [];
}
function formatResults(results: unknown[]): Suggestion[] {
// Implementation
return [];
}
Pattern 6: Rich Result Formatting
Create visually appealing suggestions with proper XML formatting and truncation.
Formatting Utilities
// src/utils/formatting.ts
export interface FormattedSuggestion {
content: string;
description: string;
deletable?: boolean;
}
/**
* Escape XML special characters in user-provided text
*/
export function escapeXml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* Highlight matching text in suggestions
*/
export function highlightMatch(text: string, query: string): string {
if (!query) return escapeXml(text);
const escaped = escapeXml(text);
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
let result = '';
let lastIndex = 0;
let index = lowerText.indexOf(lowerQuery);
while (index !== -1) {
// Add text before match
result += escapeXml(text.substring(lastIndex, index));
// Add highlighted match
result += `<match>${escapeXml(text.substring(index, index + query.length))}</match>`;
lastIndex = index + query.length;
index = lowerText.indexOf(lowerQuery, lastIndex);
}
// Add remaining text
result += escapeXml(text.substring(lastIndex));
return result;
}
/**
* Truncate text to maximum length with ellipsis
*/
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
}
/**
* Format a URL for display
*/
export function formatUrl(url: string): string {
try {
const parsed = new URL(url);
const path = parsed.pathname + parsed.search;
const display = parsed.hostname + (path !== '/' ? path : '');
return truncate(display, 50);
} catch {
return truncate(url, 50);
}
}
/**
* Create a styled suggestion with various elements
*/
export function createSuggestion(
content: string,
options: {
title?: string;
subtitle?: string;
url?: string;
icon?: string;
match?: string;
dim?: boolean;
}
): FormattedSuggestion {
const parts: string[] = [];
// Add icon if provided
if (options.icon) {
parts.push(options.icon + ' ');
}
// Add title with optional match highlighting
if (options.title) {
const title = options.match
? highlightMatch(options.title, options.match)
: escapeXml(options.title);
parts.push(options.dim ? `<dim>${title}</dim>` : title);
}
// Add subtitle
if (options.subtitle) {
parts.push(`<dim>${escapeXml(options.subtitle)}</dim>`);
}
// Add URL
if (options.url) {
parts.push(`<url>${formatUrl(options.url)}</url>`);
}
return {
content,
description: parts.join(' — '),
};
}
Complete Example
// src/services/RichSuggestionService.ts
import {
FormattedSuggestion,
createSuggestion,
highlightMatch,
escapeXml,
formatUrl
} from '../utils/formatting';
interface Bookmark {
id: string;
title: string;
url: string;
folder: string;
}
export class RichSuggestionService {
/**
* Convert bookmarks to rich suggestions
*/
bookmarkToSuggestions(
bookmarks: Bookmark[],
query: string
): FormattedSuggestion[] {
return bookmarks.slice(0, 5).map(bookmark =>
createSuggestion(bookmark.url, {
title: bookmark.title,
subtitle: bookmark.folder,
url: bookmark.url,
match: query,
})
);
}
/**
* Create suggestion with URL styling
*/
urlSuggestion(url: string, title?: string): FormattedSuggestion {
const displayTitle = title || formatUrl(url);
return {
content: url,
description: `<url>${escapeXml(displayTitle)}</url>`,
};
}
/**
* Create suggestion with dimmed secondary text
*/
dimmedSuggestion(content: string, primary: string, secondary: string): FormattedSuggestion {
return {
content,
description: `${escapeXml(primary)} <dim>${escapeXml(secondary)}</dim>`,
};
}
/**
* Create suggestion with match highlighting
*/
matchingSuggestion(content: string, text: string, query: string): FormattedSuggestion {
return {
content,
description: highlightMatch(text, query),
};
}
}
Pattern 7: Default Suggestion Management
The default suggestion appears in the address bar itself (not in the dropdown) and provides context-aware hints.
Default Suggestion Service
// src/services/DefaultSuggestionService.ts
export interface DefaultSuggestion {
description: string;
}
export class DefaultSuggestionService {
private currentSuggestion: DefaultSuggestion = {
description: 'Type to search...',
};
/**
* Set the default suggestion displayed in the omnibox
*/
setDefaultSuggestion(suggestion: DefaultSuggestion): void {
this.currentSuggestion = suggestion;
chrome.omnibox.setDefaultSuggestion(suggestion);
}
/**
* Update based on current input
*/
updateForInput(text: string): void {
if (!text) {
this.setDefaultSuggestion({
description: 'Type to search or select a command...',
});
return;
}
const { command } = this.parseCommand(text);
switch (command) {
case 'search':
this.setDefaultSuggestion({
description: 'Press Enter to search for: ' + this.escape(text.replace(/^search\s*/, '')),
});
break;
case 'open':
this.setDefaultSuggestion({
description: 'Press Enter to open bookmark: ' + this.escape(text.replace(/^open\s*/, '')),
});
break;
case 'settings':
this.setDefaultSuggestion({
description: 'Press Enter to open settings',
});
break;
default:
this.setDefaultSuggestion({
description: 'Press Enter to execute',
});
}
}
/**
* Reset to default state
*/
reset(): void {
this.setDefaultSuggestion({
description: 'Type to search or select a command...',
});
}
private parseCommand(input: string): { command: string; args: string } {
const parts = input.trim().split(/\s+/);
return {
command: parts[0]?.toLowerCase() ?? '',
args: parts.slice(1).join(' '),
};
}
private escape(text: string): string {
return text.replace(/</g, '<').replace(/>/g, '>');
}
}
Integration
// src/omnibox/defaultSuggestion.ts
import { DefaultSuggestionService } from '../services/DefaultSuggestionService';
const defaultService = new DefaultSuggestionService();
// Update default suggestion on input
chrome.omnibox.onInputChanged.addListener((text, suggest) => {
defaultService.updateForInput(text);
// ... also provide suggestions
});
// Reset when user cancels
chrome.omnibox.onInputCancelled.addListener(() => {
defaultService.reset();
});
// Reset when input starts fresh
chrome.omnibox.onInputStarted.addListener(() => {
defaultService.reset();
});
Context-Aware Examples
// Context-specific default suggestions
const contextDefaults: Record<string, DefaultSuggestion> = {
empty: { description: 'Type to search bookmarks and history...' },
search: { description: 'Press Enter to search' },
command: { description: 'Select a command or press Enter' },
error: { description: '<dim>No results found. Try different keywords.</dim>' },
};
function getDefaultForContext(input: string, hasResults: boolean): DefaultSuggestion {
if (!input) return contextDefaults.empty;
if (!hasResults) return contextDefaults.error;
const cmd = input.split(/\s/)[0].toLowerCase();
if (cmd === 'search') return contextDefaults.search;
return contextDefaults.command;
}
Pattern 8: Omnibox to Tab Navigation
Handle different ways to open results based on user preference and context.
Tab Navigation Service
// src/services/TabNavigationService.ts
export type TabDisposition = 'currentTab' | 'newForegroundTab' | 'newBackgroundTab';
export interface NavigationOptions {
url: string;
disposition?: TabDisposition;
active?: boolean; // For new tabs: should be active
pinned?: boolean; // Pin the new tab
openerTabId?: number; // Associate with opener tab
}
export class TabNavigationService {
/**
* Navigate to URL with specified disposition
*/
async navigate(options: NavigationOptions): Promise<chrome.tabs.Tab> {
const disposition = options.disposition ?? 'currentTab';
switch (disposition) {
case 'currentTab':
return this.openInCurrentTab(options.url);
case 'newForegroundTab':
return this.openInNewForegroundTab(options.url, options.pinned);
case 'newBackgroundTab':
return this.openInNewBackgroundTab(options.url, options.pinned);
default:
return this.openInCurrentTab(options.url);
}
}
/**
* Open URL in the current tab
*/
async openInCurrentTab(url: string): Promise<chrome.tabs.Tab> {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab.id) {
return chrome.tabs.update(tab.id, { url, active: true });
}
return chrome.tabs.create({ url, active: true });
}
/**
* Open URL in a new foreground tab
*/
async openInNewForegroundTab(url: string, pinned: boolean = false): Promise<chrome.tabs.Tab> {
return chrome.tabs.create({ url, active: true, pinned });
}
/**
* Open URL in a new background tab
*/
async openInNewBackgroundTab(url: string, pinned: boolean = false): Promise<chrome.tabs.Tab> {
return chrome.tabs.create({ url, active: false, pinned });
}
/**
* Open extension page
*/
async openExtensionPage(page: string): Promise<chrome.tabs.Tab> {
const url = chrome.runtime.getURL(page);
return this.navigate({ url, disposition: 'currentTab' });
}
/**
* Open with keyboard modifier consideration
*/
async openWithModifiers(
url: string,
disposition: chrome.omnibox.OnInputEnteredDisposition
): Promise<chrome.tabs.Tab> {
const tabDisposition = this.mapDisposition(disposition);
return this.navigate({ url, disposition: tabDisposition });
}
/**
* Map omnibox disposition to our type
*/
private mapDisposition(
disposition: chrome.omnibox.OnInputEnteredDisposition
): TabDisposition {
switch (disposition) {
case 'currentTab':
return 'currentTab';
case 'newForegroundTab':
return 'newForegroundTab';
case 'newBackgroundTab':
return 'newBackgroundTab';
default:
return 'currentTab';
}
}
}
Complete Integration
// src/omnibox/navigation.ts
import { TabNavigationService, NavigationOptions } from '../services/TabNavigationService';
import { messenger } from '@theluckystrike/webext-messaging';
const navService = new TabNavigationService();
interface SearchResult {
url: string;
title: string;
type: 'bookmark' | 'history' | 'page';
}
// Main navigation handler
async function handleSelection(
result: SearchResult,
disposition: chrome.omnibox.OnInputEnteredDisposition
): Promise<void> {
// Handle special result types
if (result.type === 'bookmark') {
await navService.openWithModifiers(result.url, disposition);
return;
}
// Handle regular navigation
const options: NavigationOptions = {
url: result.url,
disposition: mapDisposition(disposition),
};
await navService.navigate(options);
}
// Handle the omnibox enter event
chrome.omnibox.onInputEntered.addListener((text, disposition) => {
const result = parseSelectedSuggestion(text);
if (result) {
handleSelection(result, disposition);
}
});
function mapDisposition(
disposition: chrome.omnibox.OnInputEnteredDisposition
): 'currentTab' | 'newForegroundTab' | 'newBackgroundTab' {
switch (disposition) {
case 'currentTab':
return 'currentTab';
case 'newForegroundTab':
return 'newForegroundTab';
case 'newBackgroundTab':
return 'newBackgroundTab';
default:
return 'currentTab';
}
}
function parseSelectedSuggestion(text: string): SearchResult | null {
// Parse the content back to result
try {
return JSON.parse(text);
} catch {
return { url: text, title: text, type: 'page' };
}
}
Deep Linking to Extension Pages
// Deep linking utilities
export function createDeepLink(path: string, params?: Record<string, string>): string {
const url = new URL(chrome.runtime.getURL(path));
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
}
return url.toString();
}
// Example usage in omnibox
router.register({
name: 'settings',
description: 'Open settings',
usage: 'settings',
handler: () => {
const url = createDeepLink('pages/settings.html', { tab: 'general' });
navService.navigate({ url, disposition: 'currentTab' });
},
});
router.register({
name: 'help',
description: 'View help',
usage: 'help [topic]',
handler: (args) => {
const url = createDeepLink('pages/help.html', { topic: args || 'index' });
navService.navigate({ url, disposition: 'currentTab' });
},
});
Summary Table
| Pattern | Use Case | Key APIs |
|---|---|---|
| Basic Setup | Simple keyword-triggered search | onInputStarted, onInputChanged, onInputEntered |
| Typed Suggestions | Rich suggestions with XML formatting | SuggestResult, <match>, <dim>, <url> |
| Async with Debounce | Search APIs without rate limiting | setTimeout, loading placeholders |
| Command Router | CLI-like subcommands | Command pattern, parsing |
| History | Recent searches | chrome.storage.session |
| Rich Formatting | Beautiful suggestions | XML tags, truncation |
| Default Suggestion | Context-aware hints | setDefaultSuggestion |
| Tab Navigation | Open results appropriately | disposition, tab creation |
Key Best Practices
- Always escape XML in user-provided content to prevent rendering issues
- Limit suggestions to 5-6 results for optimal UX
- Debounce async operations to avoid excessive API calls
- Use
setDefaultSuggestionfor contextual hints - Handle all dispositions properly (currentTab, newForegroundTab, newBackgroundTab)
- Store recent searches in session storage for privacy
- Provide meaningful defaults when no input is given -e —
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.