═══════════════════════════════════════════════════════════════════════
@bytelyst/ai-ui bump 0.1.0 → 0.4.0
═══════════════════════════════════════════════════════════════════════
Folds three more roadmap milestones into the flagship package.
0.2: <ToolCallCard> — disclosure card; status pill, JSON preview
<CitationChip> — inline citation marker + hover preview
useToolCalls() — per-turn tool-invocation state machine
(begin/update/settle/clear); preserves insertion
order across updates; auto-computes durationMs
0.3: <AgentTimeline> — vertical think→act→observe→respond trace;
embeds ToolCallCard for kind='tool_call' steps
<ModelPicker> — model dropdown with capability chips, cost,
latency, context window, disabled gating
0.4: <ToolPalette> — searchable tool list with MCP-style discovery
(source can be ToolDescriptor[] OR an
'mcp://...' URL resolved via a discover
adapter; default adapter is fetch+JSON)
Types extended:
- ToolInvocation, ToolCallStatus, Citation added
- Message gains optional toolInvocations + citations
Tests: 53/53 (27 old + 26 new) · typecheck clean · 7.65 KB / 35 KB
═══════════════════════════════════════════════════════════════════════
NEW PACKAGE: @bytelyst/command-palette@0.1.0
═══════════════════════════════════════════════════════════════════════
Wave 3 deliverable — Cmd-K dialog with three modes and pluggable command
registration. Roadmap §Wave 3 of ROADMAP_2026.md.
What's exported:
<CommandRegistryProvider> — wrap your app once
<CommandPalette> — the dialog (Cmd-K / Ctrl-K)
useRegisterCommands() — contribute commands for component lifetime
useCommands() — read snapshot
useCommandRegistry() — imperative access
useCommandPalette() — open/close state + global hotkey
fuzzyScore / scoreCommand — exposed for tests + custom UIs
Three modes:
actions — invoke a registered run()
navigate — jump to href via onNavigate or window.location
ask-ai — host-supplied askAiPanel; default renders an 'Ask AI: <q>'
suggestion that products can wire to <ChatStream>
Keyboard:
↑ ↓ navigate selection
Enter activate
Tab cycle mode tabs (Shift+Tab reverses)
Esc close
Niceties:
- Fuzzy matcher (substring + subsequence with light scoring)
- localStorage-backed recents float to top of actions mode
- requires() gate hides commands wholesale (auth / feature-flag)
- aria-haspopup, role=dialog, role=listbox, role=option, aria-selected
- Backdrop click closes; Esc handler at document level
- Hotkey suppressed by Cmd-K / Ctrl-K default; configurable
Tests: 26/26 · typecheck clean · 3.91 KB / 15 KB
═══════════════════════════════════════════════════════════════════════
CI plumbing
═══════════════════════════════════════════════════════════════════════
- .size-limit.cjs gains @bytelyst/command-palette entry
- .gitea/workflows/size-limit.yml build filter expanded
- All 8 measured packages comfortably under budget
Refs:
learning_ai_uxui_web/docs/ROADMAP_2026.md §Wave 2 (0.2/0.3/0.4)
learning_ai_uxui_web/docs/ROADMAP_2026.md §Wave 3 (Command palette)
docs/ROADMAP_2026_DECISIONS.md §10 (Vercel AI SDK shape continues)
579 lines
17 KiB
TypeScript
579 lines
17 KiB
TypeScript
import {
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
type KeyboardEvent,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import { useCommands } from './registry.js';
|
|
import { scoreCommand } from './fuzzy.js';
|
|
import type { Command, CommandMode } from './types.js';
|
|
|
|
export interface CommandPaletteProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
/** Initial mode tab. Default: 'actions'. */
|
|
initialMode?: CommandMode;
|
|
/** Hide the mode tabs (only render the active mode). */
|
|
hideModeTabs?: boolean;
|
|
/** Render a custom Ask-AI panel (replaces the default suggestion). */
|
|
askAiPanel?: (query: string) => ReactNode;
|
|
/** Called when the user picks a navigate-mode command. */
|
|
onNavigate?: (href: string) => void;
|
|
/** Force-merge external commands without registering them. */
|
|
extraCommands?: Command[];
|
|
/** localStorage key for recents. Default: 'bl-cmdk-recents'. */
|
|
recentsKey?: string;
|
|
/** How many recents to remember. Default: 8. */
|
|
recentsLimit?: number;
|
|
/** ARIA label for the dialog. */
|
|
ariaLabel?: string;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/**
|
|
* `<CommandPalette>` — Cmd-K dialog with three modes:
|
|
* - **actions** — invoke a registered command (calls its `run`)
|
|
* - **navigate** — jump to a registered route (uses `onNavigate` or
|
|
* `window.location.assign` as fallback)
|
|
* - **ask-ai** — delegate the query to an AI panel (host-supplied)
|
|
*
|
|
* Commands are sourced from `useCommands()` (provider context) plus any
|
|
* `extraCommands` prop. Recents persist to `localStorage` under
|
|
* `recentsKey` and float to the top of the actions list when the query
|
|
* is empty.
|
|
*
|
|
* Keyboard:
|
|
* ↑ ↓ — move selection
|
|
* Enter — run selection
|
|
* Tab — cycle mode tabs
|
|
* Esc — close
|
|
*/
|
|
export function CommandPalette({
|
|
open,
|
|
onClose,
|
|
initialMode = 'actions',
|
|
hideModeTabs = false,
|
|
askAiPanel,
|
|
onNavigate,
|
|
extraCommands = [],
|
|
recentsKey = 'bl-cmdk-recents',
|
|
recentsLimit = 8,
|
|
ariaLabel = 'Command palette',
|
|
className,
|
|
style,
|
|
}: CommandPaletteProps) {
|
|
const registryCommands = useCommands();
|
|
const allCommands = useMemo(
|
|
() => [...registryCommands, ...extraCommands],
|
|
[registryCommands, extraCommands],
|
|
);
|
|
|
|
const [mode, setMode] = useState<CommandMode>(initialMode);
|
|
const [query, setQuery] = useState('');
|
|
const [selected, setSelected] = useState(0);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Reset state every time the palette opens.
|
|
useEffect(() => {
|
|
if (open) {
|
|
setQuery('');
|
|
setSelected(0);
|
|
setMode(initialMode);
|
|
// Defer focus until after the dialog mounts.
|
|
queueMicrotask(() => inputRef.current?.focus());
|
|
}
|
|
}, [open, initialMode]);
|
|
|
|
// Esc to close — handled at the document level so clicks outside the
|
|
// dialog don't need to bubble.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handler = (e: globalThis.KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
onClose();
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handler);
|
|
return () => document.removeEventListener('keydown', handler);
|
|
}, [open, onClose]);
|
|
|
|
// ── Filter + score ──────────────────────────────────────────────────────
|
|
const recents = useRecents(recentsKey, recentsLimit);
|
|
|
|
const filtered = useMemo(() => {
|
|
const bucketed = allCommands.filter(c => {
|
|
if (c.requires && !c.requires()) return false;
|
|
// ask-ai bucket is virtual — no commands live in it.
|
|
if (mode === 'navigate') return c.mode === 'navigate';
|
|
if (mode === 'actions') return c.mode !== 'navigate';
|
|
return false;
|
|
});
|
|
|
|
if (!query) {
|
|
// No query: float recents to the top in actions mode.
|
|
if (mode === 'actions' && recents.length) {
|
|
const recentSet = new Set(recents);
|
|
const recentItems = bucketed.filter(c => recentSet.has(c.id));
|
|
const rest = bucketed.filter(c => !recentSet.has(c.id));
|
|
return recentItems.concat(rest).map(command => ({ command, score: 0 }));
|
|
}
|
|
return bucketed.map(command => ({ command, score: 0 }));
|
|
}
|
|
|
|
return bucketed
|
|
.map(command => ({ command, score: scoreCommand(command, query) }))
|
|
.filter((row): row is { command: Command; score: number } => row.score !== null)
|
|
.sort((a, b) => b.score - a.score);
|
|
}, [allCommands, mode, query, recents]);
|
|
|
|
useEffect(() => {
|
|
if (selected >= filtered.length) setSelected(0);
|
|
}, [filtered.length, selected]);
|
|
|
|
// ── Activation ──────────────────────────────────────────────────────────
|
|
const activate = (cmd: Command) => {
|
|
if (cmd.enabled === false) return;
|
|
recents.push(cmd.id);
|
|
onClose();
|
|
if (cmd.run) {
|
|
void cmd.run();
|
|
return;
|
|
}
|
|
if (cmd.href) {
|
|
if (onNavigate) onNavigate(cmd.href);
|
|
else if (typeof window !== 'undefined') window.location.assign(cmd.href);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setSelected(s => Math.min(s + 1, Math.max(filtered.length - 1, 0)));
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setSelected(s => Math.max(s - 1, 0));
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const row = filtered[selected];
|
|
if (row) activate(row.command);
|
|
} else if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
setMode(m => nextMode(m, e.shiftKey ? -1 : 1));
|
|
}
|
|
};
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={ariaLabel}
|
|
data-testid="bl-cmdk"
|
|
className={className}
|
|
onKeyDown={handleKeyDown}
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
zIndex: 1000,
|
|
background: 'var(--bl-overlay-scrim, rgba(0,0,0,0.55))',
|
|
display: 'flex',
|
|
alignItems: 'flex-start',
|
|
justifyContent: 'center',
|
|
paddingTop: '12vh',
|
|
...style,
|
|
}}
|
|
onMouseDown={e => {
|
|
// Close on backdrop click — but never when the inner panel is clicked.
|
|
if (e.target === e.currentTarget) onClose();
|
|
}}
|
|
>
|
|
<div
|
|
data-testid="bl-cmdk-panel"
|
|
style={{
|
|
width: 'min(640px, 92vw)',
|
|
maxHeight: '70vh',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
background: 'var(--bl-surface-card, #fff)',
|
|
color: 'var(--bl-text-primary, inherit)',
|
|
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
|
|
borderRadius: 'var(--bl-radius-card, 12px)',
|
|
boxShadow: '0 20px 60px rgba(0,0,0,0.30)',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{/* Search input */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--bl-space-2, 8px)',
|
|
padding: 'var(--bl-space-3, 12px) var(--bl-space-4, 16px)',
|
|
borderBottom: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
|
|
}}
|
|
>
|
|
<span
|
|
aria-hidden
|
|
style={{ fontSize: 14, color: 'var(--bl-text-tertiary, #888)' }}
|
|
>
|
|
⌘
|
|
</span>
|
|
<input
|
|
ref={inputRef}
|
|
data-testid="bl-cmdk-input"
|
|
type="text"
|
|
value={query}
|
|
onChange={e => {
|
|
setQuery(e.target.value);
|
|
setSelected(0);
|
|
}}
|
|
placeholder={
|
|
mode === 'navigate'
|
|
? 'Jump to…'
|
|
: mode === 'ask-ai'
|
|
? 'Ask AI anything…'
|
|
: 'Type a command, or search…'
|
|
}
|
|
aria-label="Search commands"
|
|
style={{
|
|
flex: 1,
|
|
border: 'none',
|
|
outline: 'none',
|
|
background: 'transparent',
|
|
color: 'inherit',
|
|
fontSize: '1rem',
|
|
}}
|
|
/>
|
|
{!hideModeTabs && <ModeTabs mode={mode} onChange={setMode} />}
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div
|
|
data-testid="bl-cmdk-body"
|
|
style={{
|
|
flex: 1,
|
|
overflowY: 'auto',
|
|
padding: 'var(--bl-space-1, 4px)',
|
|
}}
|
|
>
|
|
{mode === 'ask-ai' ? (
|
|
askAiPanel ? (
|
|
askAiPanel(query)
|
|
) : (
|
|
<DefaultAskAiPanel query={query} />
|
|
)
|
|
) : filtered.length === 0 ? (
|
|
<EmptyState query={query} mode={mode} />
|
|
) : (
|
|
<ul
|
|
role="listbox"
|
|
aria-label={mode === 'navigate' ? 'Navigate targets' : 'Actions'}
|
|
style={{ listStyle: 'none', margin: 0, padding: 0 }}
|
|
>
|
|
{filtered.map((row, i) => (
|
|
<CommandRow
|
|
key={row.command.id}
|
|
command={row.command}
|
|
selected={i === selected}
|
|
onSelect={() => activate(row.command)}
|
|
onHover={() => setSelected(i)}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
<Footer mode={mode} hits={filtered.length} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Sub-components ──────────────────────────────────────────────────────
|
|
|
|
function ModeTabs({
|
|
mode,
|
|
onChange,
|
|
}: {
|
|
mode: CommandMode;
|
|
onChange: (m: CommandMode) => void;
|
|
}) {
|
|
const tabs: Array<{ id: CommandMode; label: string }> = [
|
|
{ id: 'actions', label: 'Actions' },
|
|
{ id: 'navigate', label: 'Navigate' },
|
|
{ id: 'ask-ai', label: 'Ask AI' },
|
|
];
|
|
return (
|
|
<div
|
|
role="tablist"
|
|
style={{
|
|
display: 'inline-flex',
|
|
gap: 2,
|
|
padding: 2,
|
|
background: 'var(--bl-surface-muted, rgba(0,0,0,0.04))',
|
|
borderRadius: 'var(--bl-radius-pill, 999px)',
|
|
}}
|
|
>
|
|
{tabs.map(t => {
|
|
const active = t.id === mode;
|
|
return (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={active}
|
|
data-testid={`bl-cmdk-tab-${t.id}`}
|
|
onClick={() => onChange(t.id)}
|
|
style={{
|
|
padding: '4px 10px',
|
|
fontSize: '0.75rem',
|
|
border: 'none',
|
|
borderRadius: 'var(--bl-radius-pill, 999px)',
|
|
background: active ? 'var(--bl-surface-card, #fff)' : 'transparent',
|
|
color: active
|
|
? 'var(--bl-text-primary, inherit)'
|
|
: 'var(--bl-text-tertiary, #888)',
|
|
fontWeight: active ? 600 : 500,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CommandRow({
|
|
command,
|
|
selected,
|
|
onSelect,
|
|
onHover,
|
|
}: {
|
|
command: Command;
|
|
selected: boolean;
|
|
onSelect: () => void;
|
|
onHover: () => void;
|
|
}) {
|
|
const disabled = command.enabled === false;
|
|
return (
|
|
<li
|
|
role="option"
|
|
aria-selected={selected}
|
|
aria-disabled={disabled}
|
|
data-testid={`bl-cmdk-item-${command.id}`}
|
|
onMouseMove={onHover}
|
|
onClick={() => {
|
|
if (!disabled) onSelect();
|
|
}}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--bl-space-3, 12px)',
|
|
padding: 'var(--bl-space-2, 8px) var(--bl-space-3, 12px)',
|
|
background: selected
|
|
? 'var(--bl-accent-muted, rgba(99,102,241,0.12))'
|
|
: 'transparent',
|
|
borderRadius: 'var(--bl-radius-control, 6px)',
|
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
opacity: disabled ? 0.5 : 1,
|
|
}}
|
|
>
|
|
{command.icon && (
|
|
<span
|
|
aria-hidden
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: 22,
|
|
color: 'var(--bl-accent, #6366f1)',
|
|
}}
|
|
>
|
|
{command.icon}
|
|
</span>
|
|
)}
|
|
<span style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: '0.9rem', fontWeight: 600 }}>{command.label}</div>
|
|
{command.description && (
|
|
<div
|
|
style={{
|
|
fontSize: '0.75rem',
|
|
color: 'var(--bl-text-tertiary, #888)',
|
|
marginTop: 1,
|
|
}}
|
|
>
|
|
{command.description}
|
|
</div>
|
|
)}
|
|
</span>
|
|
{command.shortcut && (
|
|
<span style={{ display: 'inline-flex', gap: 2 }}>
|
|
{command.shortcut.map(k => (
|
|
<kbd
|
|
key={k}
|
|
style={{
|
|
fontFamily: 'inherit',
|
|
fontSize: '0.7rem',
|
|
padding: '1px 5px',
|
|
background: 'var(--bl-surface-muted, rgba(0,0,0,0.06))',
|
|
color: 'var(--bl-text-secondary, #555)',
|
|
border: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.08))',
|
|
borderRadius: 4,
|
|
}}
|
|
>
|
|
{k}
|
|
</kbd>
|
|
))}
|
|
</span>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ query, mode }: { query: string; mode: CommandMode }) {
|
|
return (
|
|
<div
|
|
data-testid="bl-cmdk-empty"
|
|
style={{
|
|
padding: 'var(--bl-space-4, 16px)',
|
|
textAlign: 'center',
|
|
color: 'var(--bl-text-tertiary, #888)',
|
|
fontSize: '0.85rem',
|
|
}}
|
|
>
|
|
{query
|
|
? `No ${mode === 'navigate' ? 'navigation targets' : 'commands'} match "${query}".`
|
|
: `No ${mode === 'navigate' ? 'navigation targets' : 'commands'} registered.`}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DefaultAskAiPanel({ query }: { query: string }) {
|
|
return (
|
|
<div
|
|
data-testid="bl-cmdk-ask-ai"
|
|
style={{
|
|
padding: 'var(--bl-space-4, 16px)',
|
|
textAlign: 'center',
|
|
color: 'var(--bl-text-secondary, #444)',
|
|
}}
|
|
>
|
|
<div style={{ fontSize: 28, marginBottom: 8 }} aria-hidden>
|
|
✨
|
|
</div>
|
|
<div style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
|
{query ? `Ask AI: "${query}"` : 'Type a question to ask AI'}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: '0.75rem',
|
|
color: 'var(--bl-text-tertiary, #888)',
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
Provide an <code>askAiPanel</code> prop to wire this to{' '}
|
|
<code>@bytelyst/ai-ui</code>.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Footer({ mode, hits }: { mode: CommandMode; hits: number }) {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--bl-space-3, 12px)',
|
|
padding: 'var(--bl-space-2, 8px) var(--bl-space-3, 12px)',
|
|
borderTop: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
|
|
fontSize: '0.7rem',
|
|
color: 'var(--bl-text-tertiary, #888)',
|
|
}}
|
|
>
|
|
<span>
|
|
<kbd style={kbdStyle}>↑↓</kbd> navigate
|
|
</span>
|
|
<span>
|
|
<kbd style={kbdStyle}>↵</kbd> activate
|
|
</span>
|
|
<span>
|
|
<kbd style={kbdStyle}>Tab</kbd> switch mode
|
|
</span>
|
|
<span>
|
|
<kbd style={kbdStyle}>Esc</kbd> close
|
|
</span>
|
|
{mode !== 'ask-ai' && (
|
|
<span style={{ marginLeft: 'auto' }}>{hits} result{hits === 1 ? '' : 's'}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const kbdStyle: CSSProperties = {
|
|
fontFamily: 'inherit',
|
|
fontSize: '0.7rem',
|
|
padding: '0 4px',
|
|
background: 'var(--bl-surface-muted, rgba(0,0,0,0.06))',
|
|
border: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.08))',
|
|
borderRadius: 3,
|
|
};
|
|
|
|
function nextMode(current: CommandMode, direction: 1 | -1): CommandMode {
|
|
const order: CommandMode[] = ['actions', 'navigate', 'ask-ai'];
|
|
const idx = order.indexOf(current);
|
|
const next = (idx + direction + order.length) % order.length;
|
|
return order[next]!;
|
|
}
|
|
|
|
// ─── localStorage-backed recents ─────────────────────────────────────────
|
|
|
|
function useRecents(key: string, limit: number) {
|
|
const [recents, setRecents] = useState<string[]>(() => {
|
|
if (typeof window === 'undefined') return [];
|
|
try {
|
|
const raw = window.localStorage.getItem(key);
|
|
if (!raw) return [];
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parsed.slice(0, limit) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
|
|
return useMemo(
|
|
() => ({
|
|
get list() {
|
|
return recents;
|
|
},
|
|
// Iterable convenience.
|
|
[Symbol.iterator]: () => recents[Symbol.iterator](),
|
|
get length() {
|
|
return recents.length;
|
|
},
|
|
includes: (id: string) => recents.includes(id),
|
|
filter: (predicate: (id: string) => boolean) => recents.filter(predicate),
|
|
push: (id: string) => {
|
|
setRecents(prev => {
|
|
const next = [id, ...prev.filter(x => x !== id)].slice(0, limit);
|
|
try {
|
|
window.localStorage.setItem(key, JSON.stringify(next));
|
|
} catch {
|
|
/* quota / private mode — best-effort */
|
|
}
|
|
return next;
|
|
});
|
|
},
|
|
}),
|
|
[recents, key, limit],
|
|
);
|
|
}
|