Command Palette in WebShell
The WebShell command palette feature recently launched. Here are the design notes and considerations.
Problem Statement
Why build this?
As WebShell’s features grow, user “action paths” get longer. Example: logging into a machine without a command palette might require:
- Click Connections
- Focus the search input
- Select a connection config
- Click Login
This path is long. The command palette offers an interaction similar to Alfred/Raycast on macOS: keep your hands on the keyboard to execute actions quickly.
With a command palette:
- Use a hotkey to open the palette
- Use the input + arrow keys to choose a connection
- Press Enter to log in
The number of steps may be similar, but you never leave the keyboard, improving flow.
Design
At its core, the palette displays a list of actions. Let’s define a CommandAction
. Each menu item is a CommandAction.
CommandAction
{
uid: string;
icon?: React.ReactNode;
title: string;
match?: string[];
render?: (item?: CommandAction) => React.ReactNode;
hotkey?: GlobalAction;
action: ({ setQuery }: { setQuery: (q: string) => void }) => void;
actionMap?: {
[comboKey: string]: ({ close, shaking }) => void
};
windowDontCloseOnAction?: boolean;
preCheck?: () => React.ReactNode;
variables?: {
[index: string]: any;
},
};
Some design notes:
uid
uniquely identifies a command for things like history ordering.match
follows Alfred’s idea: matching only ontitle
can be limiting. For example, a “search” keyword might match Google. Withmatch
, you can add extra keywords to improve recall quality.action
defines the Enter behavior. As the palette grows, Enter alone may not be enough. Add modifier+Enter combos viaactionMap
to expand functionality. Think ofaction
as theenter
entry inactionMap
.hotkey
shows the command’s shortcut.variables
holds contextual variables when the command is highlighted/executed.
CommandAction List
Where does the list come from? Some are static (declared in code), others are generated dynamically via JS, e.g., fetched from the backend. The palette renders whatever list it receives.
Search/Filter
For the full list, filtering is straightforward:
- Fuzzy-match against
title
. If the UI is Chinese, supporting pinyin search improves UX.- Keep the full list in memory and filter on the query.
- For
match
, use exact matches. If it were fuzzy too, recall might be too broad and reduce quality.
Multi-level CommandAction Lists
As the palette grows, one level isn’t enough. For example, you might want to pick a specific SSH profile within the palette and press Enter to connect. Hence, multi-level support.
How to link levels?
Method 1
Maintain a ScriptCommandsMap
: key = trigger keyword, value = a function that generates the list. The trigger keyword is not the user’s filter text; it’s the activation keyword. The top level could be empty; a second-level list might use a keyword like connect
. When the user types connect
, the palette resolves the function and renders the generated list.
Method 2
Add a type
to CommandAction. If it’s a subcommand, pressing Enter shouldn’t close the palette; instead, the action
returns a list to display next.
I currently use Method 1, but may switch to Method 2 to avoid a large central map and let each command handle its own logic, improving maintainability.
Related Products
Some terminal products offer similar features:
- xterminal: single-level only
- warp: supports multi-level (e.g., sessions listing)