Command Palette in WebShell

· 3 min read · 530 Words · -Views -Comments

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:

  1. Click Connections
  2. Focus the search input
  3. Select a connection config
  4. 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:

  1. Use a hotkey to open the palette
  2. Use the input + arrow keys to choose a connection
  3. 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:

  1. uid uniquely identifies a command for things like history ordering.
  2. match follows Alfred’s idea: matching only on title can be limiting. For example, a “search” keyword might match Google. With match, you can add extra keywords to improve recall quality.
  3. action defines the Enter behavior. As the palette grows, Enter alone may not be enough. Add modifier+Enter combos via actionMap to expand functionality. Think of action as the enter entry in actionMap.
  4. hotkey shows the command’s shortcut.
  5. 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:

  1. 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.
  2. 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.

Some terminal products offer similar features:

  • xterminal: single-level only
  • warp: supports multi-level (e.g., sessions listing)
Authors
Developer, digital product enthusiast, tinkerer, sharer, open source lover