Skip to content
Star on GitHub
WorkspaceCommand palette
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Command palette

Jump anywhere or hand a task to the agent from one box. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add command-palette
esc
↑↓ move↵ run
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add command-palette

React

$npx designme add command-palette --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

A search box with a list of commands under it, grouped by heading, where a person can jump to a page, run an action, or pass what they typed to the agent. As soon as something is typed, the list narrows and a last row, "Ask the agent", sends the text to the agent instead. It gives an agent product one keyboard-first place to start anything.

  • When people need a single shortcut that reaches every page and action in your app.
  • When a typed request that matches no command should still reach the agent.
  • When you want to show keyboard shortcuts next to the commands they trigger.
  • Inside a modal opened by a key such as Cmd K, closed again with Escape.
03

Usage

Import it and drive it from your own state.

Import

import { CommandPalette } from '@/components/designme/command-palette/CommandPalette';

Example

The demo above as a React component, 41 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { CommandPalette, type CommandPaletteItem } from './CommandPalette';
import { useStory } from '../lib/useScript';

const COMMANDS: CommandPaletteItem[] = [
  { group: 'Agent', icon: 'spark', label: 'Audit this page for contrast', keys: ['⌘', 'J'], hint: 'WCAG AA', tone: 'purple' },
  { group: 'Agent', icon: 'wand', label: 'Tidy layer names in the selection', hint: '12 layers', tone: 'purple' },
  { group: 'Agent', icon: 'code', label: 'Export tokens as CSS variables', hint: 'tokens.css', tone: 'purple' },
  { group: 'Go to', icon: 'layers', label: 'Pricing v4', keys: ['G', 'P'], hint: 'Page', tone: 'blue' },
  { group: 'Go to', icon: 'figma', label: 'Checkout flow', hint: 'Figma file', tone: 'blue' },
  { group: 'Go to', icon: 'book', label: 'Memory', hint: 'Team notes', tone: 'blue' },
  { group: 'Actions', icon: 'users', label: 'Invite a teammate', hint: 'Acme Labs', tone: 'teal' },
  { group: 'Actions', icon: 'gear', label: 'Agent settings', keys: ['⌘', ','], tone: 'teal' },
  { group: 'Actions', icon: 'palette', label: 'Switch theme', hint: 'Light', tone: 'teal' }
];

/* ---------- Command palette: someone types "tok" and runs the token export, then finds the invite and runs it ---------- */
export default function CommandPaletteDemo() {
  const [query, setQuery] = useState('');
  const ref = useRef<HTMLDivElement>(null);
  // clicking, focusing or typing in it takes over, and what it had typed goes
  useStory(ref, async ({ wait, taken }) => {
    let typing = false;
    taken(() => { if (typing) setQuery(''); });
    const type = async (text: string) => { typing = true; let typed = ''; for (const ch of text) { typed += ch; setQuery(typed); await wait(180); } typing = false; };
    // Enter in the box runs the highlighted row, as a person pressing it would
    const enter = () => ref.current?.querySelector('input')?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
    await wait(1500);
    await type('tok'); await wait(1000); enter(); await wait(2000);
    setQuery(''); await wait(500);
    await type('invi'); await wait(1000); enter();
  });
  return (
    <div ref={ref} style={{ width: '100%' }}>
      <CommandPalette items={COMMANDS} query={query} onQueryChange={setQuery} />
    </div>
  );
}
04

Props

Everything CommandPalette takes.

PropTypeWhat it does
itemsrequiredCommandPaletteItem[]
querystringWhat is typed in the box. Leave it out to let the box hold its own
defaultQuerystring
onQueryChange(query: string) => void
onRun(item: CommandPaletteItem) => voidCalled when a command runs, by click or Enter
onAsk(query: string) => voidCalled with the typed text when the last row, "Ask the agent", runs
onClose() => voidCalled by Escape when the box is already empty
placeholderstring
statusstringThe quiet text at the end of the footer. Defaults to what ran last
autoFocusbooleanPuts the cursor in the box when it appears
classNamestring
05

Code

The full source, as the CLI installs it.

CommandPalette.tsxReact · 156 lines
'use client';
import { Fragment, useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect, useReducedMotion } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import '../designme.css';
import './command-palette.css';

export type CommandTone = 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'yellow' | 'pink' | 'teal';
const TONE: Record<CommandTone, string> = { blue: 'pb', purple: 'pl', green: 'pm', orange: 'pp', red: 'pr', yellow: 'py', pink: 'pk', teal: 'pt' };

/** One command in the palette */
export interface CommandPaletteItem {
  /** The group it is listed under, like "Go to" */
  group: string;
  icon: IconName;
  /** What it does, like "Invite a teammate". Typing matches it letter by letter */
  label: string;
  /** Its shortcut, one key per entry, like ['⌘', 'J'] */
  keys?: string[];
  /** A quiet word or two after the label, like "tokens.css". It isn't matched, and gives way first on a narrow palette */
  hint?: string;
  /** The hue of its icon tile, usually one per group. Leave it out for grey */
  tone?: CommandTone;
}

export interface CommandPaletteProps {
  items: CommandPaletteItem[];
  /** What is typed in the box. Leave it out to let the box hold its own */
  query?: string;
  defaultQuery?: string;
  onQueryChange?: (query: string) => void;
  /** Called when a command runs, by click or Enter */
  onRun?: (item: CommandPaletteItem) => void;
  /** Called with the typed text when the last row, "Ask the agent", runs */
  onAsk?: (query: string) => void;
  /** Called by Escape when the box is already empty */
  onClose?: () => void;
  placeholder?: string;
  /** The quiet text at the end of the footer. Defaults to what ran last */
  status?: string;
  /** Puts the cursor in the box when it appears */
  autoFocus?: boolean;
  className?: string;
}

// letters of the query, in order, anywhere in the label
const matches = (label: string, q: string) => {
  const l = label.toLowerCase(); let at = 0;
  for (const ch of q.toLowerCase()) { at = l.indexOf(ch, at); if (at < 0) return false; at++; }
  return true;
};
// every letter the query contains is marked, as on designme.dev
const marked = (label: string, q: string) => {
  if (!q) return label;
  const set = new Set(q.toLowerCase()), out: { t: string; m: boolean }[] = [];
  for (const ch of label) { const m = set.has(ch.toLowerCase()), last = out[out.length - 1]; if (last && !last.m && !m) last.t += ch; else out.push({ t: ch, m }); }
  return out.map((p, i) => p.m ? <mark key={i}>{p.t}</mark> : <Fragment key={i}>{p.t}</Fragment>);
};

/** Jump anywhere or hand a task to the agent from one box. Type to filter, arrows to move, Enter to run, Escape to clear.
    Each command has a tile in its hue and a quiet hint; a command that runs spins its tile for a beat. */
export function CommandPalette({ items, query, defaultQuery = '', onQueryChange, onRun, onAsk, onClose, placeholder = 'Search or ask the agent', status, autoFocus, className }: CommandPaletteProps) {
  const [q, setQ] = useControllable(query, defaultQuery, onQueryChange);
  const v = q.trim();
  const shown = items.filter(it => matches(it.label, v));
  const rows = v ? [...shown, null] : shown; // null is the "Ask the agent" row
  // the highlight goes back to the first row whenever the list changes
  const [hi, setHi] = useState({ at: 0, for: v });
  const at = hi.for === v ? Math.min(hi.at, Math.max(0, rows.length - 1)) : 0;
  const [ran, setRan] = useState('');
  // the row that just ran spins its tile for a beat before the footer says what ran
  const [busy, setBusy] = useState<string>();
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  useEffect(() => () => clearTimeout(timer.current), []);
  // rows that arrive after the first render play a short rise, in a cascade
  const [ready, setReady] = useState(false);
  useEffect(() => { setReady(true); }, []);

  const list = useRef<HTMLDivElement>(null);
  const btns = useRef<(HTMLButtonElement | null)[]>([]);
  const id = useId(), reduce = useReducedMotion();
  const [top, setTop] = useState<{ for: string; px: number }>();
  useIsoLayoutEffect(() => {
    const b = btns.current[at], l = list.current; if (!b || !l) return;
    setTop({ for: v, px: b.offsetTop });
    if (b.offsetTop < l.scrollTop || b.offsetTop + 36 > l.scrollTop + l.clientHeight) l.scrollTo({ top: b.offsetTop - 40, behavior: reduce ? 'auto' : 'smooth' });
  }, [at, v, rows.length, reduce]);

  // while there is more list below, its last row fades out at the edge instead of being cut off by the footer
  const [more, setMore] = useState(false);
  const edge = () => { const l = list.current; if (l) setMore(l.scrollTop + l.clientHeight < l.scrollHeight - 2); };
  useIsoLayoutEffect(edge, [v, rows.length]);

  const move = (d: number) => { if (rows.length) setHi({ at: (at + d + rows.length) % rows.length, for: v }); };
  const run = (i: number) => {
    const row = rows[i], b = btns.current[i];
    if (row === undefined) return;
    if (row) onRun?.(row); else onAsk?.(v);
    const said = row ? 'Ran: ' + row.label : 'Sent to the agent', k = row ? row.group + row.label : '\u0000ask';
    setBusy(k); clearTimeout(timer.current);
    timer.current = setTimeout(() => { setBusy(b => b === k ? undefined : b); setRan(said); }, reduce ? 0 : 650);
    b?.animate?.([{ transform: 'scale(1)' }, { transform: 'scale(.98)' }, { transform: 'scale(1)' }], { duration: 260, easing: 'ease-out' });
  };
  const key = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'ArrowDown') { move(1); e.preventDefault(); }
    else if (e.key === 'ArrowUp') { move(-1); e.preventDefault(); }
    else if (e.key === 'Enter') run(at);
    else if (e.key === 'Escape') { if (q) setQ(''); else onClose?.(); }
  };

  let group = '';
  return (
    <div data-dm="" className={cx('dm-cpl dm-panel', className)}>
      <div className="dm-cpl-in">
        <Icon name="search" />
        {/* the box keeps the focus and points at the highlighted row, so the rows are not tab stops */}
        <input className="dm-cpl-q" placeholder={placeholder} aria-label="Command" spellCheck={false} autoFocus={autoFocus} value={q}
          role="combobox" aria-expanded aria-controls={`${id}-list`} aria-autocomplete="list" aria-activedescendant={rows.length ? `${id}-${at}` : undefined}
          onChange={e => setQ(e.target.value)} onKeyDown={key} />
        <span className="dm-kbd">esc</span>
      </div>
      <div className={cx('dm-cpl-list', more && 'dm-more')} role="listbox" id={`${id}-list`} aria-label="Commands" tabIndex={-1} ref={list} onScroll={edge}>
        {/* a new list starts the highlight afresh, so it jumps into place instead of sliding */}
        <span key={v} className="dm-cpl-hl" style={{ top: top?.for === v ? top.px : undefined }} />
        {rows.map((row, i) => {
          const head = row ? row.group : 'Ask', k = row ? row.group + row.label : '\u0000ask';
          const g = head !== group ? <div className="dm-cpl-g">{(group = head)}</div> : null;
          const tone = row?.tone ? TONE[row.tone] : undefined;
          return (
            <Fragment key={k}>
              {g}
              <button type="button" ref={el => { btns.current[i] = el; }} className={cx('dm-cpl-i', !row && 'dm-ask', i === at && 'dm-hl', ready && 'dm-in')}
                style={ready ? { animationDelay: `${Math.min(i, 6) * 28}ms` } : undefined}
                role="option" id={`${id}-${i}`} tabIndex={-1} aria-selected={i === at} onMouseEnter={() => setHi({ at: i, for: v })} onClick={() => run(i)}>
                <span className="dm-ic" style={tone ? { '--dm-tc': `var(--dm-${tone})`, '--dm-tb': `var(--dm-${tone}-bg)` } as CSSProperties : undefined}>
                  {busy === k ? <span className="dm-spin" aria-hidden="true" /> : <Icon name={row ? row.icon : 'spark'} size="xs" />}
                </span>
                <span className="dm-t">{row ? marked(row.label, v) : <>Ask the agent <span className="dm-q">“{v}”</span></>}</span>
                {row?.hint && <span className="dm-h">{row.hint}</span>}
                {row?.keys && <span className="dm-k">{row.keys.map((k, j) => <span key={j} className="dm-kbd">{k}</span>)}</span>}
                {!row && <span className="dm-kbd dm-ent" aria-hidden="true">↵</span>}
              </button>
            </Fragment>
          );
        })}
      </div>
      <div className="dm-cpl-f dm-muted">
        <span><span className="dm-kbd">↑</span><span className="dm-kbd">↓</span> move</span>
        <span><span className="dm-kbd">↵</span> run</span>
        <span className="dm-cpl-st">{(status ?? ran) && <span key={status ?? ran} className="dm-enter"><span className="dm-dot" aria-hidden="true" />{status ?? ran}</span>}</span>
      </div>
    </div>
  );
}
command-palette.cssReact · 42 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Command palette ---------- */
/* one quiet box: each command has a tile in its group's hue and a muted hint after its name; a grey highlight glides to the
   row the arrows are on, and a command that runs turns its tile into a spinner for a beat */
.dm-cpl{overflow:hidden;display:flex;flex-direction:column;box-shadow:var(--dm-shadow-pop)}
.dm-cpl-in{display:flex;align-items:center;gap:10px;padding:var(--dm-s-3) 14px;border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);color:var(--dm-muted)}
.dm-cpl-q{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:14px;color:var(--dm-fg);caret-color:var(--dm-accent-fg,var(--dm-fg))}
.dm-cpl-q::placeholder{color:var(--dm-muted)}
/* the line under the search box steps up while it has the focus; a row that takes the focus rings on its own */
.dm-cpl-list{padding:6px;height:236px;overflow-y:auto;position:relative}
/* while there is more list below, its last row fades out at the edge instead of being cut off by the footer */
.dm-cpl-list.dm-more{-webkit-mask-image:linear-gradient(to bottom,#000 calc(100% - 32px),transparent);mask-image:linear-gradient(to bottom,#000 calc(100% - 32px),transparent)}
.dm-cpl-g{font-size:11.5px;color:var(--dm-muted);padding:var(--dm-s-2) var(--dm-s-2) var(--dm-s-1)}
.dm-cpl-i{display:flex;align-items:center;gap:10px;width:100%;height:36px;padding:0 var(--dm-s-2);border-radius:10px;font-size:13.5px;text-align:left;color:var(--dm-fg);position:relative;z-index:1}
/* rows arrive in a short cascade when the list narrows */
.dm-cpl-i.dm-in{animation:dm-cplin .32s var(--dm-ease) backwards}
@keyframes dm-cplin{from{opacity:0;transform:translateY(4px)}}
.dm-cpl-i .dm-ic{width:24px;height:24px;border-radius:7px;display:grid;place-items:center;flex:none;color:var(--dm-tc,var(--dm-fg2));background:var(--dm-tb,var(--dm-chip));transition:background .2s,color .2s,transform .2s var(--dm-ease)}
.dm-cpl-i.dm-hl .dm-ic{transform:scale(1.06)}
.dm-cpl-i .dm-ic .dm-spin{width:11px;height:11px}
/* a label too long for a narrow palette ends in an ellipsis on its one line, so it never spills out of its row; the hint
   gives way first */
.dm-cpl-i .dm-t{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dm-cpl-i .dm-h{flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--dm-muted)}
.dm-cpl-i.dm-hl .dm-h{color:var(--dm-fg2)}
.dm-cpl-i .dm-k{margin-left:auto;display:flex;gap:3px;flex:none}
.dm-cpl-i mark{background:transparent;color:var(--dm-accent-fg,var(--dm-accent));font-weight:500}
/* the row that hands the text to the agent: an accent tile, the words in quotes, and Enter shown while it is highlighted */
.dm-cpl-i.dm-ask .dm-ic{background:var(--dm-accent);color:var(--dm-on-accent)}
.dm-cpl-i.dm-ask .dm-q{color:var(--dm-fg2)}
.dm-cpl-i .dm-ent{margin-left:auto;opacity:0;transition:opacity .2s}
.dm-cpl-i.dm-hl .dm-ent{opacity:1}
.dm-cpl-hl{position:absolute;left:6px;right:6px;height:36px;border-radius:10px;background:var(--dm-row);transition:top .25s var(--dm-ease);z-index:0}
.dm-cpl-f{display:flex;gap:14px;align-items:center;padding:var(--dm-s-2) 14px;border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:11.5px}
.dm-cpl-f>span{flex:none;white-space:nowrap}
.dm-cpl-f .dm-kbd{margin-right:3px}
.dm-cpl-f>.dm-cpl-st{flex:0 1 auto;min-width:0;margin-left:auto;color:var(--dm-fg2);overflow:hidden;text-overflow:ellipsis}
.dm-cpl-st .dm-dot{color:var(--dm-ok);margin-right:6px;vertical-align:1px}
.dm-cpl-empty{padding:30px 10px;text-align:center;font-size:13px;color:var(--dm-muted)}
:root:not([data-style="soft"]) .dm-cpl-i.dm-hl .dm-ic:not(:where([data-style="soft"],[data-style="soft"] *)){transform:none}
:root:not([data-style="soft"]) .dm-cpl-i.dm-in:not(:where([data-style="soft"],[data-style="soft"] *)){animation:dm-bpin .3s var(--dm-ease) backwards}
command-palette.htmlHTML · 10 lines
<div class="card" data-c="command-palette">
  <div class="stage-in">
    <div class="cpl panel">
      <div class="cpl-in"><svg class="i s"><use href="#i-search"/></svg><input class="cpl-q" placeholder="Search or ask the agent" aria-label="Command" spellcheck="false"><span class="kbd">esc</span></div>
      <div class="cpl-list" role="listbox"></div>
      <div class="cpl-f muted"><span><span class="kbd">↑</span><span class="kbd">↓</span> move</span><span><span class="kbd">↵</span> run</span><span class="cpl-st"></span></div>
    </div>
  </div>
</div>
command-palette.cssHTML · 41 lines
/* ---------- Command palette ---------- */
/* one quiet box: each command has a tile in its group's hue and a muted hint after its name; a grey highlight glides to the
   row the arrows are on, and a command that runs turns its tile into a spinner for a beat */
.cpl{overflow:hidden;display:flex;flex-direction:column;box-shadow:var(--shadow-pop)}
.cpl-in{display:flex;align-items:center;gap:10px;padding:var(--s-3) 14px;border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent);color:var(--muted)}
.cpl-q{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:14px;color:var(--fg);caret-color:var(--accent-fg,var(--fg))}
.cpl-q::placeholder{color:var(--muted)}
/* the line under the search box steps up while it has the focus; a row that takes the focus rings on its own */
.cpl-list{padding:6px;height:236px;overflow-y:auto;position:relative}
/* while there is more list below, its last row fades out at the edge instead of being cut off by the footer */
.cpl-list.more{-webkit-mask-image:linear-gradient(to bottom,#000 calc(100% - 32px),transparent);mask-image:linear-gradient(to bottom,#000 calc(100% - 32px),transparent)}
.cpl-g{font-size:11.5px;color:var(--muted);padding:var(--s-2) var(--s-2) var(--s-1)}
.cpl-i{display:flex;align-items:center;gap:10px;width:100%;height:36px;padding:0 var(--s-2);border-radius:10px;font-size:13.5px;text-align:left;color:var(--fg);position:relative;z-index:1}
/* rows arrive in a short cascade when the list narrows */
.cpl-i.in{animation:cplin .32s var(--ease) backwards}
@keyframes cplin{from{opacity:0;transform:translateY(4px)}}
.cpl-i .ic{width:24px;height:24px;border-radius:7px;display:grid;place-items:center;flex:none;color:var(--tc,var(--fg2));background:var(--tb,var(--chip));transition:background .2s,color .2s,transform .2s var(--ease)}
.cpl-i.hl .ic{transform:scale(1.06)}
.cpl-i .ic .spin{width:11px;height:11px}
/* a label too long for a narrow palette ends in an ellipsis on its one line, so it never spills out of its row; the hint
   gives way first */
.cpl-i .t{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cpl-i .h{flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--muted)}
.cpl-i.hl .h{color:var(--fg2)}
.cpl-i .k{margin-left:auto;display:flex;gap:3px;flex:none}
.cpl-i mark{background:transparent;color:var(--accent-fg,var(--accent));font-weight:500}
/* the row that hands the text to the agent: an accent tile, the words in quotes, and Enter shown while it is highlighted */
.cpl-i.ask .ic{background:var(--accent);color:var(--on-accent)}
.cpl-i.ask .q{color:var(--fg2)}
.cpl-i .ent{margin-left:auto;opacity:0;transition:opacity .2s}
.cpl-i.hl .ent{opacity:1}
.cpl-hl{position:absolute;left:6px;right:6px;height:36px;border-radius:10px;background:var(--row);transition:top .25s var(--ease);z-index:0}
.cpl-f{display:flex;gap:14px;align-items:center;padding:var(--s-2) 14px;border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent);font-size:11.5px}
.cpl-f>span{flex:none;white-space:nowrap}
.cpl-f .kbd{margin-right:3px}
.cpl-f>.cpl-st{flex:0 1 auto;min-width:0;margin-left:auto;color:var(--fg2);overflow:hidden;text-overflow:ellipsis}
.cpl-st .dot{color:var(--ok);margin-right:6px;vertical-align:1px}
.cpl-empty{padding:30px 10px;text-align:center;font-size:13px;color:var(--muted)}
:root:not([data-style="soft"]) .cpl-i.hl .ic:not(:where([data-style="soft"],[data-style="soft"] *)){transform:none}
:root:not([data-style="soft"]) .cpl-i.in:not(:where([data-style="soft"],[data-style="soft"] *)){animation:bpin .3s var(--ease) backwards}
command-palette.jsHTML · 59 lines
DM.def('command-palette', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{search}', I('search'));
  // [group, icon, label, keys, hint]; each group's tiles take its hue
  const C = [
    ['Agent','spark','Audit this page for contrast',['⌘','J'],'WCAG AA'],
    ['Agent','wand','Tidy layer names in the selection',null,'12 layers'],
    ['Agent','code','Export tokens as CSS variables',null,'tokens.css'],
    ['Go to','layers','Pricing v4',['G','P'],'Page'],
    ['Go to','figma','Checkout flow',null,'Figma file'],
    ['Go to','book','Memory',null,'Team notes'],
    ['Actions','users','Invite a teammate',null,'Acme Labs'],
    ['Actions','gear','Agent settings',['⌘',',']],
    ['Actions','palette','Switch theme',null,'Light']
  ];
  const TONE = { Agent:'pl', 'Go to':'pb', Actions:'pt' };
  const q = $('.cpl-q', r), list = $('.cpl-list', r), st = $('.cpl-st', r);
  list.id = 'cpl-l-' + Math.random().toString(36).slice(2, 7); list.setAttribute('aria-label', 'Commands'); list.tabIndex = -1;
  q.setAttribute('role', 'combobox'); q.setAttribute('aria-expanded', 'true'); q.setAttribute('aria-controls', list.id); q.setAttribute('aria-autocomplete', 'list');
  let items = [], hi = 0, last = C.map(x => x[2]).join('|');
  const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  function paint(){
    const v = q.value.trim(), re = v ? new RegExp('(' + v.split('').map(esc).join(').*?(') + ')', 'i') : null;
    const m = C.filter(x => !re || re.test(x[2]));
    // the rows play a short cascade only when the list changes, not on every letter that keeps it as it is
    const key = m.map(x => x[2]).join('|') + (v ? '|ask' : ''), fresh = key !== last; last = key;
    let h = '<span class="cpl-hl"></span>', g = '', n = 0;
    const cls = () => fresh ? ` in" style="animation-delay:${Math.min(n++, 6) * 28}ms` : '';
    m.forEach(x => { if (x[0] !== g){ g = x[0]; h += `<div class="cpl-g">${g}</div>`; } const label = v ? x[2].replace(new RegExp(v.split('').map(esc).join('|'), 'gi'), s => `<mark>${s}</mark>`) : x[2], t = TONE[x[0]];
      h += `<button class="cpl-i${cls()}" role="option" data-t="${x[2]}"><span class="ic" style="--tc:var(--${t});--tb:var(--${t}-bg)">${I(x[1],'xs')}</span><span class="t">${label}</span>${x[4] ? `<span class="h">${x[4]}</span>` : ''}${x[3] ? `<span class="k">${x[3].map(k => `<span class="kbd">${k}</span>`).join('')}</span>` : ''}</button>`; });
    if (v) h += `<div class="cpl-g">Ask</div><button class="cpl-i ask${cls()}" data-t="${v.replace(/"/g,'&quot;')}" data-ask="1"><span class="ic">${I('spark','xs')}</span><span class="t">Ask the agent <span class="q">“${v.replace(/</g,'&lt;')}”</span></span><span class="kbd ent">↵</span></button>`;
    list.innerHTML = h; items = $$('.cpl-i', list); hi = 0;
    // the search box keeps the focus and points at the highlighted row (aria-activedescendant), so the rows are not tab stops
    items.forEach((b, i) => { b.id = list.id + '-' + i; b.tabIndex = -1; b.setAttribute('role', 'option'); b.onmouseenter = () => move(i - hi); b.onclick = () => run(b); });
    if (!items.length) q.removeAttribute('aria-activedescendant'); move(0); edge();
  }
  function move(d){ if (!items.length) return; hi = (hi + d + items.length) % items.length; items.forEach((b, i) => { b.classList.toggle('hl', i === hi); b.setAttribute('aria-selected', i === hi); }); const b = items[hi], hl = $('.cpl-hl', list); q.setAttribute('aria-activedescendant', b.id); hl.style.top = b.offsetTop + 'px'; if (b.offsetTop < list.scrollTop || b.offsetTop + 36 > list.scrollTop + list.clientHeight) list.scrollTo({ top:b.offsetTop - 40, behavior:REDUCE ? 'auto' : 'smooth' }); }
  const edge = () => list.classList.toggle('more', list.scrollTop + list.clientHeight < list.scrollHeight - 2);
  c.on(list, 'scroll', edge, { passive:true });
  // a command that runs turns its tile into a spinner for a beat, then the footer says what ran
  function run(b){
    b.animate([{ transform:'scale(1)' },{ transform:'scale(.98)' },{ transform:'scale(1)' }], { duration:260, easing:'ease-out' });
    const ic = $('.ic', b), was = ic.innerHTML; ic.innerHTML = '<span class="spin"></span>';
    setTimeout(() => { if (ic.isConnected){ ic.innerHTML = was; enter(ic.firstElementChild); } swap(st, `<span class="dot"></span>${b.dataset.ask ? 'Sent to the agent' : 'Ran: ' + b.dataset.t}`, true); }, REDUCE ? 0 : 650);
  }
  q.oninput = paint;
  q.onkeydown = e => { if (e.key === 'ArrowDown'){ move(1); e.preventDefault(); } else if (e.key === 'ArrowUp'){ move(-1); e.preventDefault(); } else if (e.key === 'Enter' && items[hi]) run(items[hi]); else if (e.key === 'Escape'){ q.value = ''; paint(); } };
  paint();
  // the preview's story: someone types "tok" and runs the token export, then looks for "invi" and runs the invite; clicking,
  // focusing or typing in it takes over, and what it had typed goes
  c.story(async s => {
    await s.beat(3);
    await s.type(q, 'tok', { n:.36, each:paint });
    await s.beat(2); run(items[hi]); await s.beat(4);
    await s.type(q, '', { n:.36, each:paint, clear:true }); await s.beat(1);
    await s.type(q, 'invi', { n:.36, each:paint });
    await s.beat(2); run(items[hi]); await s.beat(4);
  });
});
06

Questions

What people ask about it.

How do I send a free-text request to the agent?
Pass onAsk. When the box has text, the last row reads "Ask the agent" with the typed text in quotes, and running it calls onAsk with the trimmed query.
How does the filter match commands?
Each letter of the query must appear in the label in the same order, though not side by side. So "tok" matches "Export tokens as CSS variables".
How do I close the palette with Escape?
Pass onClose. The first Escape clears any text, and the next Escape, on an empty box, calls onClose so you can hide the palette.
Can I focus the input when the palette opens?
Yes. Set autoFocus and the cursor goes into the box when it appears.
07

More in Workspace

Navigation, search, settings, models and automations around the agent.

Automation canvasA trigger, a few agent steps, and a run you can watch flow through.
Canvas toolbarA floating toolbar for a workflow canvas: run, pick a tool, comment, fit and zoom.
Memory panelWhat the agent remembers about your team, editable and removable.
Model compareRun one prompt on three models side by side and keep the best.
Agent settingsHow much the agent may do alone, with a save bar for changes.
Tools and connectionsThe tools the agent can reach, and how much it may do with each.
Knowledge sourcesThe files, sites and folders the agent knows, and how far each is indexed.
Scheduled runsTasks the agent runs on a schedule, with their last result and a switch.
All 11 in Workspace →