feat(packages): Wave 2 v0.4 + Wave 3 v0.1 — ai-ui expanded, command-palette new

═══════════════════════════════════════════════════════════════════════
@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)
This commit is contained in:
saravanakumardb1 2026-05-27 12:35:10 -07:00
parent c9a7f905af
commit e2eea086dc
23 changed files with 2964 additions and 11 deletions

View File

@ -60,6 +60,7 @@ jobs:
--filter @bytelyst/react-auth \ --filter @bytelyst/react-auth \
--filter @bytelyst/dashboard-shell \ --filter @bytelyst/dashboard-shell \
--filter @bytelyst/ai-ui \ --filter @bytelyst/ai-ui \
--filter @bytelyst/command-palette \
run build run build
- name: Enforce size budgets - name: Enforce size budgets

View File

@ -72,4 +72,11 @@ module.exports = [
limit: '35 KB', limit: '35 KB',
gzip: true, gzip: true,
}, },
// ── Command palette (15 KB — fuzzy + dialog + registry) ─────────
{
name: '@bytelyst/command-palette',
path: 'packages/command-palette/dist/index.js',
limit: '15 KB',
gzip: true,
},
]; ];

View File

@ -1,6 +1,6 @@
{ {
"name": "@bytelyst/ai-ui", "name": "@bytelyst/ai-ui",
"version": "0.1.0", "version": "0.4.0",
"type": "module", "type": "module",
"description": "AI-native UI primitives — ChatStream, MessageBubble, PromptComposer, useChat. Transport-agnostic; adopts Vercel AI SDK hook shape.", "description": "AI-native UI primitives — ChatStream, MessageBubble, PromptComposer, useChat. Transport-agnostic; adopts Vercel AI SDK hook shape.",
"exports": { "exports": {

View File

@ -0,0 +1,182 @@
import type { CSSProperties, ReactNode } from 'react';
import { ToolCallCard } from './ToolCallCard.js';
import type { ToolInvocation } from './types.js';
export type AgentStepKind =
| 'thought'
| 'tool_call'
| 'observation'
| 'response'
| 'custom';
export interface AgentStep {
id: string;
kind: AgentStepKind;
/** Step body. For 'tool_call' supply a ToolInvocation, else freeform. */
content?: ReactNode;
/** Required when kind === 'tool_call'. */
invocation?: ToolInvocation;
/** Override the label rendered in the marker column. */
label?: string;
/** Visually mark this step as still in-progress (e.g. streaming). */
isActive?: boolean;
}
export interface AgentTimelineProps {
steps: AgentStep[];
className?: string;
style?: CSSProperties;
}
/**
* `<AgentTimeline>` vertical trace of agent steps in the
* `think → act → observe → respond` loop. Used by JarvisJr and any
* agentic flow that wants to expose the agent's reasoning chain.
*
* Each step renders with a colored marker dot, a kind label, and a
* body slot. `kind === 'tool_call'` automatically uses `<ToolCallCard>`
* so the rendering stays consistent across the catalog.
*/
export function AgentTimeline({ steps, className, style }: AgentTimelineProps) {
return (
<ol
data-testid="bl-agent-timeline"
className={className}
style={{
listStyle: 'none',
margin: 0,
padding: 0,
display: 'flex',
flexDirection: 'column',
gap: 0,
position: 'relative',
...style,
}}
>
{steps.map((step, idx) => (
<Step key={step.id} step={step} isLast={idx === steps.length - 1} />
))}
</ol>
);
}
function Step({ step, isLast }: { step: AgentStep; isLast: boolean }) {
const palette = STEP_PALETTE[step.kind];
return (
<li
data-testid={`bl-agent-step-${step.id}`}
data-kind={step.kind}
style={{
position: 'relative',
display: 'grid',
gridTemplateColumns: '32px 1fr',
gap: 'var(--bl-space-3, 12px)',
paddingBottom: isLast ? 0 : 'var(--bl-space-4, 16px)',
}}
>
{/* Marker rail */}
<div
style={{
position: 'relative',
display: 'flex',
justifyContent: 'center',
paddingTop: 4,
}}
>
<span
aria-hidden
style={{
display: 'inline-block',
width: 12,
height: 12,
borderRadius: '50%',
background: palette.dot,
boxShadow: step.isActive
? `0 0 0 4px ${palette.glow}`
: `0 0 0 2px var(--bl-surface-card, #fff)`,
transition: 'box-shadow 200ms ease',
}}
/>
{!isLast && (
<span
aria-hidden
style={{
position: 'absolute',
top: 18,
bottom: -12,
width: 2,
background: 'var(--bl-border, rgba(0,0,0,0.1))',
}}
/>
)}
</div>
{/* Body */}
<div style={{ minWidth: 0 }}>
<div
style={{
fontSize: '0.7rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: palette.label,
marginBottom: 'var(--bl-space-1, 4px)',
}}
>
{step.label ?? STEP_LABELS[step.kind]}
</div>
{step.kind === 'tool_call' && step.invocation ? (
<ToolCallCard invocation={step.invocation} defaultExpanded={step.isActive} />
) : (
<div
style={{
fontSize: '0.9rem',
lineHeight: 1.5,
color: 'var(--bl-text-primary, inherit)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{step.content}
</div>
)}
</div>
</li>
);
}
const STEP_LABELS: Record<AgentStepKind, string> = {
thought: 'Thought',
tool_call: 'Action',
observation: 'Observation',
response: 'Response',
custom: 'Step',
};
const STEP_PALETTE: Record<AgentStepKind, { dot: string; glow: string; label: string }> = {
thought: {
dot: 'var(--bl-text-tertiary, #888)',
glow: 'var(--bl-surface-muted, rgba(0,0,0,0.04))',
label: 'var(--bl-text-tertiary, #888)',
},
tool_call: {
dot: 'var(--bl-accent, #6366f1)',
glow: 'var(--bl-accent-muted, rgba(99,102,241,0.18))',
label: 'var(--bl-accent, #6366f1)',
},
observation: {
dot: 'var(--bl-info, #38bdf8)',
glow: 'var(--bl-info-muted, rgba(56,189,248,0.18))',
label: 'var(--bl-info, #38bdf8)',
},
response: {
dot: 'var(--bl-success, #22c55e)',
glow: 'var(--bl-success-muted, rgba(34,197,94,0.18))',
label: 'var(--bl-success, #22c55e)',
},
custom: {
dot: 'var(--bl-text-secondary, #555)',
glow: 'var(--bl-surface-muted, rgba(0,0,0,0.06))',
label: 'var(--bl-text-secondary, #555)',
},
};

View File

@ -0,0 +1,140 @@
import { useState, type CSSProperties, type ReactNode } from 'react';
import type { Citation } from './types.js';
export interface CitationChipProps {
citation: Citation;
/** Override the chip body — useful for icons. Defaults to `[n]`. */
children?: ReactNode;
/** Suppress the popover preview (e.g. when used inline in dense lists). */
noPreview?: boolean;
className?: string;
style?: CSSProperties;
}
/**
* Inline citation marker. Renders a small `[n]` chip; on hover or focus
* a preview pops up with the title, snippet, and an outbound link.
*
* The preview is a CSS-only popover (no portal, no JS layout) so it
* stays cheap to embed dozens of times in a single message body.
*/
export function CitationChip({
citation,
children,
noPreview = false,
className,
style,
}: CitationChipProps) {
const [hover, setHover] = useState(false);
const [focus, setFocus] = useState(false);
const open = !noPreview && (hover || focus);
const Wrapper = citation.url ? 'a' : 'span';
const wrapperProps = citation.url
? {
href: citation.url,
target: '_blank',
rel: 'noopener noreferrer',
}
: {};
return (
<span
style={{ position: 'relative', display: 'inline-block', ...style }}
className={className}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<Wrapper
data-testid={`bl-citation-${citation.id}`}
data-citation-index={citation.index}
{...wrapperProps}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 18,
height: 18,
padding: '0 4px',
marginInline: 2,
fontSize: 11,
fontWeight: 700,
lineHeight: 1,
color: 'var(--bl-accent, #6366f1)',
background: 'var(--bl-accent-muted, rgba(99,102,241,0.12))',
borderRadius: 'var(--bl-radius-pill, 999px)',
textDecoration: 'none',
cursor: citation.url ? 'pointer' : 'help',
verticalAlign: 'super',
}}
>
{children ?? citation.index}
</Wrapper>
{open && (
<span
role="tooltip"
data-testid={`bl-citation-preview-${citation.id}`}
style={{
position: 'absolute',
bottom: 'calc(100% + 6px)',
left: 0,
zIndex: 50,
display: 'block',
width: 280,
padding: 'var(--bl-space-3, 12px)',
background: 'var(--bl-surface-card, #fff)',
color: 'var(--bl-text-primary, inherit)',
border: '1px solid var(--bl-border, rgba(0,0,0,0.08))',
borderRadius: 'var(--bl-radius-card, 10px)',
boxShadow: '0 10px 30px rgba(0,0,0,0.18)',
fontSize: '0.8rem',
lineHeight: 1.45,
fontWeight: 400,
textAlign: 'left',
whiteSpace: 'normal',
pointerEvents: 'none', // hover-only preview; avoids flicker
}}
>
{citation.source && (
<div
style={{
fontSize: '0.65rem',
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--bl-text-tertiary, #888)',
marginBottom: 2,
}}
>
{citation.source}
</div>
)}
<div style={{ fontWeight: 600, marginBottom: 4 }}>{citation.title}</div>
{citation.snippet && (
<div style={{ color: 'var(--bl-text-secondary, #444)' }}>
{citation.snippet}
</div>
)}
{citation.url && (
<div
style={{
marginTop: 6,
fontSize: '0.7rem',
color: 'var(--bl-text-tertiary, #888)',
wordBreak: 'break-all',
}}
>
{truncateUrl(citation.url)}
</div>
)}
</span>
)}
</span>
);
}
function truncateUrl(url: string, max = 56): string {
return url.length <= max ? url : `${url.slice(0, max - 1)}`;
}

View File

@ -0,0 +1,247 @@
import { useEffect, useRef, useState, type CSSProperties } from 'react';
export interface ModelCapability {
/** Short id (e.g. 'vision', 'tools', 'streaming'). */
id: string;
/** Human-friendly label rendered in the chip. */
label: string;
}
export interface ModelOption {
/** Stable model id (e.g. 'gpt-4o', 'claude-3-5-sonnet'). */
id: string;
/** Display name. */
name: string;
/** Provider label (e.g. 'OpenAI', 'Anthropic', 'Local'). */
provider: string;
/** Optional avatar / glyph for the provider. */
providerGlyph?: string;
/** Cost per 1k input tokens (USD), used for sort + display. */
inputCostPer1k?: number;
/** Cost per 1k output tokens (USD). */
outputCostPer1k?: number;
/** Typical latency ms (P50). */
latencyMs?: number;
/** Context window size in tokens. */
contextTokens?: number;
/** Capability chips rendered next to the model name. */
capabilities?: ModelCapability[];
/** Mark the model as unavailable (greyed out, unselectable). */
disabled?: boolean;
/** Optional short description. */
description?: string;
}
export interface ModelPickerProps {
models: ModelOption[];
value: string;
onChange: (modelId: string) => void;
/** Show cost cells. Default: true. */
showCost?: boolean;
/** Show latency cells. Default: true. */
showLatency?: boolean;
/** Trigger label override (default: the active model's name). */
triggerLabel?: string;
className?: string;
style?: CSSProperties;
}
/**
* `<ModelPicker>` dropdown that exposes the full capability profile
* of each available LLM. Designed for the top of a chat surface where
* power-users want to swap models mid-conversation.
*
* Shows: provider · name · capability chips · ctx window · cost · latency.
* Closes on outside-click and Escape.
*/
export function ModelPicker({
models,
value,
onChange,
showCost = true,
showLatency = true,
triggerLabel,
className,
style,
}: ModelPickerProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
const keys = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handler);
document.addEventListener('keydown', keys);
return () => {
document.removeEventListener('mousedown', handler);
document.removeEventListener('keydown', keys);
};
}, [open]);
const active = models.find(m => m.id === value);
return (
<div
ref={rootRef}
data-testid="bl-model-picker"
className={className}
style={{ position: 'relative', display: 'inline-block', ...style }}
>
<button
type="button"
data-testid="bl-model-picker-trigger"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen(o => !o)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--bl-space-2, 8px)',
padding: 'var(--bl-space-1, 4px) var(--bl-space-2, 8px)',
background: 'var(--bl-surface-card, #fff)',
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
borderRadius: 'var(--bl-radius-pill, 999px)',
color: 'var(--bl-text-primary, inherit)',
fontSize: '0.85rem',
cursor: 'pointer',
minWidth: 0,
}}
>
{active?.providerGlyph && <span aria-hidden>{active.providerGlyph}</span>}
<span style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>
{triggerLabel ?? active?.name ?? 'Select model'}
</span>
<span aria-hidden style={{ color: 'var(--bl-text-tertiary, #888)' }}>
</span>
</button>
{open && (
<div
role="listbox"
data-testid="bl-model-picker-listbox"
aria-label="Model selector"
style={{
position: 'absolute',
top: 'calc(100% + 6px)',
right: 0,
zIndex: 50,
minWidth: 340,
maxWidth: 480,
maxHeight: 420,
overflowY: 'auto',
background: 'var(--bl-surface-card, #fff)',
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
borderRadius: 'var(--bl-radius-card, 10px)',
boxShadow: '0 16px 40px rgba(0,0,0,0.20)',
padding: 'var(--bl-space-1, 4px)',
}}
>
{models.map(m => (
<button
key={m.id}
type="button"
role="option"
aria-selected={m.id === value}
data-testid={`bl-model-picker-option-${m.id}`}
disabled={m.disabled}
onClick={() => {
onChange(m.id);
setOpen(false);
}}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
padding: 'var(--bl-space-2, 8px) var(--bl-space-3, 12px)',
background:
m.id === value
? 'var(--bl-accent-muted, rgba(99,102,241,0.12))'
: 'transparent',
border: 'none',
borderRadius: 'var(--bl-radius-control, 6px)',
color: m.disabled
? 'var(--bl-text-tertiary, #888)'
: 'var(--bl-text-primary, inherit)',
cursor: m.disabled ? 'not-allowed' : 'pointer',
opacity: m.disabled ? 0.5 : 1,
}}
>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>{m.name}</span>
<span
style={{
fontSize: '0.7rem',
color: 'var(--bl-text-tertiary, #888)',
}}
>
{m.provider}
</span>
{m.capabilities && m.capabilities.length > 0 && (
<span style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
{m.capabilities.map(c => (
<span
key={c.id}
style={{
fontSize: '0.65rem',
padding: '1px 6px',
borderRadius: 'var(--bl-radius-pill, 999px)',
background: 'var(--bl-surface-muted, rgba(0,0,0,0.05))',
color: 'var(--bl-text-secondary, #555)',
}}
>
{c.label}
</span>
))}
</span>
)}
</div>
{m.description && (
<div
style={{
fontSize: '0.75rem',
color: 'var(--bl-text-secondary, #555)',
marginTop: 2,
}}
>
{m.description}
</div>
)}
{(showCost || showLatency || m.contextTokens) && (
<div
style={{
display: 'flex',
gap: 12,
marginTop: 6,
fontSize: '0.7rem',
color: 'var(--bl-text-tertiary, #888)',
}}
>
{showCost && typeof m.inputCostPer1k === 'number' && (
<span>
${m.inputCostPer1k.toFixed(3)} in
{typeof m.outputCostPer1k === 'number' &&
` · $${m.outputCostPer1k.toFixed(3)} out`}
/1k
</span>
)}
{showLatency && typeof m.latencyMs === 'number' && (
<span>~{m.latencyMs}ms</span>
)}
{m.contextTokens && (
<span>{(m.contextTokens / 1000).toFixed(0)}k ctx</span>
)}
</div>
)}
</button>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,234 @@
import { useState, type CSSProperties, type ReactNode } from 'react';
import type { ToolCallStatus, ToolInvocation } from './types.js';
export interface ToolCallCardProps {
invocation: ToolInvocation;
/** Initially expanded? Default: false. */
defaultExpanded?: boolean;
/** Override the title rendered to the right of the status pill. */
title?: ReactNode;
/** Render a custom output preview (overrides default JSON pretty-print). */
renderOutput?: (output: unknown) => ReactNode;
className?: string;
style?: CSSProperties;
}
/**
* Disclosure card showing a single LLM tool call. Collapsed by default,
* expands to show the input + output JSON. Status pill + name are always
* visible.
*
* Visual states:
* pending gray pill, dotted spinner
* streaming accent pill, animated dots
* success success-token pill,
* error danger-token pill, + error string in body
*/
export function ToolCallCard({
invocation,
defaultExpanded = false,
title,
renderOutput,
className,
style,
}: ToolCallCardProps) {
const [open, setOpen] = useState(defaultExpanded);
const { name, status, input, output, error, durationMs } = invocation;
return (
<article
data-testid={`bl-tool-call-${invocation.id}`}
data-status={status}
className={className}
style={{
border: '1px solid var(--bl-border, rgba(0,0,0,0.08))',
borderRadius: 'var(--bl-radius-card, 10px)',
background: 'var(--bl-surface-card, #fff)',
overflow: 'hidden',
fontSize: '0.85rem',
...style,
}}
>
<button
type="button"
data-testid="bl-tool-call-toggle"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 'var(--bl-space-2, 8px)',
padding: 'var(--bl-space-2, 8px) var(--bl-space-3, 12px)',
background: 'transparent',
border: 'none',
textAlign: 'left',
cursor: 'pointer',
color: 'inherit',
}}
>
<StatusPill status={status} />
<span style={{ fontFamily: 'var(--bl-font-mono, ui-monospace)', fontWeight: 600 }}>
{title ?? name}
</span>
{typeof durationMs === 'number' && (
<span
style={{
marginLeft: 'auto',
fontSize: '0.75rem',
color: 'var(--bl-text-tertiary, #888)',
}}
>
{durationMs}ms
</span>
)}
<span
aria-hidden
style={{
transition: 'transform 120ms ease',
transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
color: 'var(--bl-text-tertiary, #888)',
}}
>
</span>
</button>
{open && (
<div
data-testid="bl-tool-call-body"
style={{
borderTop: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
padding: 'var(--bl-space-3, 12px)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--bl-space-2, 8px)',
fontFamily: 'var(--bl-font-mono, ui-monospace, monospace)',
fontSize: '0.78rem',
background: 'var(--bl-surface-muted, #fafafa)',
}}
>
{input !== undefined && (
<Section label="input">
<Pre>{stringifyShort(input)}</Pre>
</Section>
)}
{status === 'error' ? (
<Section label="error" tone="danger">
<Pre>{error ?? 'Tool call failed.'}</Pre>
</Section>
) : output !== undefined ? (
<Section label="output">
{renderOutput ? renderOutput(output) : <Pre>{stringifyShort(output)}</Pre>}
</Section>
) : null}
</div>
)}
</article>
);
}
function StatusPill({ status }: { status: ToolCallStatus }) {
const map: Record<ToolCallStatus, { bg: string; fg: string; label: string }> = {
pending: {
bg: 'var(--bl-surface-muted, rgba(0,0,0,0.04))',
fg: 'var(--bl-text-tertiary, #888)',
label: '…',
},
streaming: {
bg: 'var(--bl-accent-muted, rgba(99,102,241,0.12))',
fg: 'var(--bl-accent, #6366f1)',
label: '⋯',
},
success: {
bg: 'var(--bl-success-muted, rgba(34,197,94,0.12))',
fg: 'var(--bl-success, #22c55e)',
label: '✓',
},
error: {
bg: 'var(--bl-danger-muted, rgba(239,68,68,0.12))',
fg: 'var(--bl-danger, #ef4444)',
label: '✕',
},
};
const v = map[status];
return (
<span
data-testid="bl-tool-call-status"
aria-label={`Status: ${status}`}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 22,
height: 22,
borderRadius: 'var(--bl-radius-pill, 999px)',
background: v.bg,
color: v.fg,
fontSize: 12,
fontWeight: 700,
flexShrink: 0,
}}
>
{v.label}
</span>
);
}
function Section({
label,
tone,
children,
}: {
label: string;
tone?: 'danger';
children: ReactNode;
}) {
return (
<div>
<div
style={{
fontSize: '0.7rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.06em',
color:
tone === 'danger'
? 'var(--bl-danger, #ef4444)'
: 'var(--bl-text-tertiary, #888)',
marginBottom: 'var(--bl-space-1, 4px)',
}}
>
{label}
</div>
{children}
</div>
);
}
function Pre({ children }: { children: ReactNode }) {
return (
<pre
style={{
margin: 0,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
background: 'var(--bl-surface-card, #fff)',
padding: 'var(--bl-space-2, 8px)',
borderRadius: 'var(--bl-radius-control, 6px)',
border: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
}}
>
{children}
</pre>
);
}
function stringifyShort(value: unknown): string {
if (typeof value === 'string') return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}

View File

@ -0,0 +1,227 @@
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
export interface ToolDescriptor {
name: string;
description?: string;
/** JSON Schema-ish parameter spec (we don't parse, just display). */
parameters?: Record<string, unknown>;
/** Optional source/server label (e.g. 'platform-service', 'web_search'). */
source?: string;
/** Glyph rendered to the left of the name. */
glyph?: string;
/** Mark tool as unavailable. */
disabled?: boolean;
}
export interface ToolPaletteProps {
/**
* Tools either an array OR an MCP-style URL (`mcp://...`) the
* palette can fetch via the supplied `discover` adapter.
*/
source: ToolDescriptor[] | string;
/**
* When `source` is a string URL, this adapter performs the discovery
* lookup. Receives the URL, returns the resolved tool list.
* Default: fetch(url).then(r => r.json()).then(j => j.tools ?? j).
*/
discover?: (source: string) => Promise<ToolDescriptor[]>;
/** Called when a tool is selected. */
onSelect: (tool: ToolDescriptor) => void;
/** Filter substring (e.g. sea matches search). */
filter?: string;
className?: string;
style?: CSSProperties;
}
const defaultDiscover = async (src: string): Promise<ToolDescriptor[]> => {
const res = await fetch(src);
if (!res.ok) throw new Error(`MCP discovery failed: ${res.status}`);
const body = (await res.json()) as unknown;
if (Array.isArray(body)) return body as ToolDescriptor[];
if (body && typeof body === 'object' && Array.isArray((body as { tools?: unknown }).tools)) {
return (body as { tools: ToolDescriptor[] }).tools;
}
return [];
};
/**
* `<ToolPalette>` searchable list of tools an LLM can invoke.
* Powers the "slash-command" menu in `<PromptComposer>` (Wave 2 0.4+).
*
* MCP-style discovery: if `source` is a URL string (e.g.
* `'mcp://platform-service/tools'`) the palette resolves it via the
* `discover` adapter and renders the resulting tool descriptors. This
* is the wedge that lets ByteLyst's MCP-first ecosystem expose tools
* directly to UIs without per-product wiring.
*/
export function ToolPalette({
source,
discover = defaultDiscover,
onSelect,
filter = '',
className,
style,
}: ToolPaletteProps) {
const [resolved, setResolved] = useState<ToolDescriptor[] | null>(
Array.isArray(source) ? source : null,
);
const [loading, setLoading] = useState<boolean>(typeof source === 'string');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (Array.isArray(source)) {
setResolved(source);
setLoading(false);
setError(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
discover(source)
.then(tools => {
if (cancelled) return;
setResolved(tools);
setLoading(false);
})
.catch(err => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
setLoading(false);
});
return () => {
cancelled = true;
};
}, [source, discover]);
const filtered = useMemo(() => {
if (!resolved) return [];
const q = filter.trim().toLowerCase();
if (!q) return resolved;
return resolved.filter(
t =>
t.name.toLowerCase().includes(q) ||
t.description?.toLowerCase().includes(q) ||
t.source?.toLowerCase().includes(q),
);
}, [resolved, filter]);
return (
<div
role="listbox"
aria-label="Available tools"
data-testid="bl-tool-palette"
className={className}
style={{
background: 'var(--bl-surface-card, #fff)',
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
borderRadius: 'var(--bl-radius-card, 10px)',
padding: 'var(--bl-space-1, 4px)',
maxHeight: 360,
overflowY: 'auto',
...style,
}}
>
{loading && <Status>Discovering tools</Status>}
{error && <Status tone="danger">{error}</Status>}
{!loading && !error && filtered.length === 0 && <Status>No tools match.</Status>}
{filtered.map(tool => (
<button
key={tool.name}
type="button"
role="option"
data-testid={`bl-tool-palette-option-${tool.name}`}
disabled={tool.disabled}
onClick={() => onSelect(tool)}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 'var(--bl-space-2, 8px)',
width: '100%',
padding: 'var(--bl-space-2, 8px) var(--bl-space-3, 12px)',
background: 'transparent',
border: 'none',
borderRadius: 'var(--bl-radius-control, 6px)',
textAlign: 'left',
cursor: tool.disabled ? 'not-allowed' : 'pointer',
color: 'var(--bl-text-primary, inherit)',
opacity: tool.disabled ? 0.5 : 1,
}}
>
<span
aria-hidden
style={{
fontSize: 16,
width: 22,
textAlign: 'center',
flexShrink: 0,
color: 'var(--bl-accent, #6366f1)',
}}
>
{tool.glyph ?? '⚙'}
</span>
<span style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<code
style={{
fontFamily: 'var(--bl-font-mono, ui-monospace, monospace)',
fontSize: '0.85rem',
fontWeight: 600,
}}
>
{tool.name}
</code>
{tool.source && (
<span
style={{
fontSize: '0.7rem',
color: 'var(--bl-text-tertiary, #888)',
}}
>
· {tool.source}
</span>
)}
</div>
{tool.description && (
<div
style={{
fontSize: '0.75rem',
color: 'var(--bl-text-secondary, #555)',
marginTop: 2,
}}
>
{tool.description}
</div>
)}
</span>
</button>
))}
</div>
);
}
function Status({
children,
tone,
}: {
children: React.ReactNode;
tone?: 'danger';
}) {
return (
<div
data-testid="bl-tool-palette-status"
style={{
padding: 'var(--bl-space-3, 12px)',
fontSize: '0.8rem',
color:
tone === 'danger'
? 'var(--bl-danger, #ef4444)'
: 'var(--bl-text-tertiary, #888)',
textAlign: 'center',
}}
>
{children}
</div>
);
}

View File

@ -0,0 +1,357 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
import { ToolCallCard } from '../ToolCallCard.js';
import { CitationChip } from '../CitationChip.js';
import { useToolCalls } from '../useToolCalls.js';
import { AgentTimeline } from '../AgentTimeline.js';
import { ModelPicker, type ModelOption } from '../ModelPicker.js';
import { ToolPalette, type ToolDescriptor } from '../ToolPalette.js';
import type { ToolInvocation } from '../types.js';
const INV: ToolInvocation = {
id: 'inv-1',
name: 'web_search',
status: 'success',
input: { query: 'react streaming' },
output: { hits: 3 },
durationMs: 124,
};
// ─── ToolCallCard ─────────────────────────────────────────────────────────
describe('ToolCallCard', () => {
beforeEach(() => cleanup());
it('renders collapsed by default with status pill + name + duration', () => {
render(<ToolCallCard invocation={INV} />);
expect(screen.getByTestId('bl-tool-call-inv-1')).toBeDefined();
expect(screen.getByTestId('bl-tool-call-status').textContent).toBe('✓');
expect(screen.getByText('web_search')).toBeDefined();
expect(screen.getByText('124ms')).toBeDefined();
expect(screen.queryByTestId('bl-tool-call-body')).toBeNull();
});
it('expands on toggle click and shows input + output', () => {
render(<ToolCallCard invocation={INV} />);
fireEvent.click(screen.getByTestId('bl-tool-call-toggle'));
const body = screen.getByTestId('bl-tool-call-body');
expect(body.textContent).toContain('react streaming');
expect(body.textContent).toContain('"hits": 3');
});
it('renders error body when status is error', () => {
render(
<ToolCallCard
invocation={{ ...INV, status: 'error', output: undefined, error: 'rate limited' }}
defaultExpanded
/>,
);
expect(screen.getByTestId('bl-tool-call-status').textContent).toBe('✕');
expect(screen.getByText('rate limited')).toBeDefined();
});
it('honors renderOutput override', () => {
render(
<ToolCallCard
invocation={INV}
defaultExpanded
renderOutput={() => <div data-testid="custom-out">custom</div>}
/>,
);
expect(screen.getByTestId('custom-out').textContent).toBe('custom');
});
});
// ─── CitationChip ─────────────────────────────────────────────────────────
describe('CitationChip', () => {
beforeEach(() => cleanup());
it('renders the citation index by default', () => {
render(
<CitationChip
citation={{ id: 'c1', index: 3, title: 'Streaming SSR Patterns', url: 'https://example.com/a' }}
/>,
);
const el = screen.getByTestId('bl-citation-c1');
expect(el.textContent).toBe('3');
expect(el.getAttribute('data-citation-index')).toBe('3');
});
it('shows preview popover on hover', () => {
render(
<CitationChip
citation={{
id: 'c2',
index: 1,
title: 'My Paper',
snippet: 'A concise summary…',
source: 'arxiv',
url: 'https://arxiv.org/x/1',
}}
/>,
);
// Hover the outer wrapper to fire onMouseEnter.
fireEvent.mouseEnter(screen.getByTestId('bl-citation-c2').parentElement!);
expect(screen.getByTestId('bl-citation-preview-c2').textContent).toContain('My Paper');
expect(screen.getByText('arxiv')).toBeDefined();
});
it('renders a non-anchor span when url is omitted', () => {
render(<CitationChip citation={{ id: 'c3', index: 2, title: 'Local' }} />);
const el = screen.getByTestId('bl-citation-c3');
expect(el.tagName.toLowerCase()).toBe('span');
});
it('respects noPreview', () => {
render(
<CitationChip
noPreview
citation={{ id: 'c4', index: 1, title: 'Hidden' }}
/>,
);
fireEvent.mouseEnter(screen.getByTestId('bl-citation-c4').parentElement!);
expect(screen.queryByTestId('bl-citation-preview-c4')).toBeNull();
});
});
// ─── useToolCalls ─────────────────────────────────────────────────────────
describe('useToolCalls', () => {
it('begins, updates, and settles a tool call with durationMs', () => {
const { result } = renderHook(() => useToolCalls());
act(() => result.current.begin({ id: 't1', name: 'search', input: { q: 'x' } }));
expect(result.current.ordered).toHaveLength(1);
expect(result.current.invocations.t1?.status).toBe('pending');
act(() => result.current.update('t1', { status: 'streaming' }));
expect(result.current.invocations.t1?.status).toBe('streaming');
act(() => result.current.settle('t1', { status: 'success', output: { hits: 2 } }));
expect(result.current.invocations.t1?.status).toBe('success');
expect(result.current.invocations.t1?.output).toEqual({ hits: 2 });
expect(typeof result.current.invocations.t1?.durationMs).toBe('number');
});
it('preserves insertion order across updates', () => {
const { result } = renderHook(() => useToolCalls());
act(() => {
result.current.begin({ id: 'a', name: 'first' });
result.current.begin({ id: 'b', name: 'second' });
result.current.begin({ id: 'c', name: 'third' });
result.current.update('a', { status: 'success' });
});
expect(result.current.ordered.map(x => x.id)).toEqual(['a', 'b', 'c']);
});
it('settle with error sets status + error + clears prior output', () => {
const { result } = renderHook(() => useToolCalls());
act(() => result.current.begin({ id: 't', name: 'x' }));
act(() => result.current.settle('t', { status: 'error', error: 'boom' }));
expect(result.current.invocations.t?.status).toBe('error');
expect(result.current.invocations.t?.error).toBe('boom');
});
it('clear and clearAll', () => {
const { result } = renderHook(() => useToolCalls());
act(() => {
result.current.begin({ id: 'a', name: 'x' });
result.current.begin({ id: 'b', name: 'y' });
});
act(() => result.current.clear('a'));
expect(result.current.ordered.map(x => x.id)).toEqual(['b']);
act(() => result.current.clearAll());
expect(result.current.ordered).toEqual([]);
});
it('hydrates from initial invocations', () => {
const initial: ToolInvocation[] = [
{ id: 'i1', name: 'seed', status: 'success' },
];
const { result } = renderHook(() => useToolCalls(initial));
expect(result.current.ordered).toHaveLength(1);
expect(result.current.invocations.i1?.status).toBe('success');
});
});
// ─── AgentTimeline ────────────────────────────────────────────────────────
describe('AgentTimeline', () => {
beforeEach(() => cleanup());
it('renders all steps in order with data-kind attributes', () => {
render(
<AgentTimeline
steps={[
{ id: 's1', kind: 'thought', content: 'I should search.' },
{
id: 's2',
kind: 'tool_call',
invocation: { ...INV, id: 't1' },
},
{ id: 's3', kind: 'observation', content: '3 hits.' },
{ id: 's4', kind: 'response', content: 'Here is the summary.' },
]}
/>,
);
expect(screen.getByTestId('bl-agent-timeline')).toBeDefined();
expect(screen.getByTestId('bl-agent-step-s1').getAttribute('data-kind')).toBe('thought');
expect(screen.getByTestId('bl-agent-step-s2').getAttribute('data-kind')).toBe('tool_call');
// tool_call step embeds a ToolCallCard
expect(screen.getByTestId('bl-tool-call-t1')).toBeDefined();
});
it('renders custom label when provided', () => {
render(
<AgentTimeline
steps={[{ id: 's', kind: 'custom', label: 'Plan', content: 'Outline.' }]}
/>,
);
expect(screen.getByText('Plan')).toBeDefined();
});
});
// ─── ModelPicker ──────────────────────────────────────────────────────────
const MODELS: ModelOption[] = [
{
id: 'gpt-4o',
name: 'GPT-4o',
provider: 'OpenAI',
providerGlyph: '🟢',
inputCostPer1k: 0.005,
outputCostPer1k: 0.015,
latencyMs: 400,
contextTokens: 128_000,
capabilities: [
{ id: 'tools', label: 'tools' },
{ id: 'vision', label: 'vision' },
],
description: 'Flagship multimodal.',
},
{
id: 'claude-3-5-sonnet',
name: 'Claude 3.5 Sonnet',
provider: 'Anthropic',
inputCostPer1k: 0.003,
outputCostPer1k: 0.015,
latencyMs: 380,
contextTokens: 200_000,
},
{ id: 'gpt-4o-mini', name: 'GPT-4o mini', provider: 'OpenAI', disabled: true },
];
describe('ModelPicker', () => {
beforeEach(() => cleanup());
it('renders trigger with active model name', () => {
render(<ModelPicker models={MODELS} value="gpt-4o" onChange={() => {}} />);
expect(screen.getByTestId('bl-model-picker-trigger').textContent).toContain('GPT-4o');
});
it('opens listbox on trigger click and lists all models', () => {
render(<ModelPicker models={MODELS} value="gpt-4o" onChange={() => {}} />);
fireEvent.click(screen.getByTestId('bl-model-picker-trigger'));
expect(screen.getByTestId('bl-model-picker-listbox')).toBeDefined();
expect(screen.getByTestId('bl-model-picker-option-claude-3-5-sonnet')).toBeDefined();
});
it('selects model on option click and closes', () => {
const onChange = vi.fn();
render(<ModelPicker models={MODELS} value="gpt-4o" onChange={onChange} />);
fireEvent.click(screen.getByTestId('bl-model-picker-trigger'));
fireEvent.click(screen.getByTestId('bl-model-picker-option-claude-3-5-sonnet'));
expect(onChange).toHaveBeenCalledWith('claude-3-5-sonnet');
expect(screen.queryByTestId('bl-model-picker-listbox')).toBeNull();
});
it('does not invoke onChange for disabled models', () => {
const onChange = vi.fn();
render(<ModelPicker models={MODELS} value="gpt-4o" onChange={onChange} />);
fireEvent.click(screen.getByTestId('bl-model-picker-trigger'));
fireEvent.click(screen.getByTestId('bl-model-picker-option-gpt-4o-mini'));
expect(onChange).not.toHaveBeenCalled();
});
});
// ─── ToolPalette ──────────────────────────────────────────────────────────
const TOOLS: ToolDescriptor[] = [
{ name: 'web_search', description: 'Search the web', source: 'platform' },
{ name: 'calculator', description: 'Evaluate math expressions' },
{ name: 'image_gen', description: 'Generate images', disabled: true },
];
describe('ToolPalette', () => {
beforeEach(() => cleanup());
it('renders an array source synchronously', () => {
render(<ToolPalette source={TOOLS} onSelect={() => {}} />);
expect(screen.getByTestId('bl-tool-palette-option-web_search')).toBeDefined();
expect(screen.getByTestId('bl-tool-palette-option-calculator')).toBeDefined();
});
it('filters by substring across name + description + source', () => {
render(<ToolPalette source={TOOLS} onSelect={() => {}} filter="math" />);
expect(screen.getByTestId('bl-tool-palette-option-calculator')).toBeDefined();
expect(screen.queryByTestId('bl-tool-palette-option-web_search')).toBeNull();
});
it('reports an empty filter result via status', () => {
render(<ToolPalette source={TOOLS} onSelect={() => {}} filter="nonexistent" />);
expect(screen.getByTestId('bl-tool-palette-status').textContent).toMatch(/no tools/i);
});
it('uses the supplied discover adapter for string sources (MCP-style)', async () => {
const discover = vi.fn(async (src: string) => {
expect(src).toBe('mcp://platform-service/tools');
return TOOLS;
});
render(
<ToolPalette
source="mcp://platform-service/tools"
discover={discover}
onSelect={() => {}}
/>,
);
expect(screen.getByTestId('bl-tool-palette-status').textContent).toMatch(/discovering/i);
await waitFor(() =>
expect(screen.getByTestId('bl-tool-palette-option-web_search')).toBeDefined(),
);
expect(discover).toHaveBeenCalledOnce();
});
it('surfaces discovery errors via status (danger tone)', async () => {
const discover = vi.fn(async () => {
throw new Error('unreachable');
});
render(
<ToolPalette
source="mcp://nowhere/tools"
discover={discover}
onSelect={() => {}}
/>,
);
await waitFor(() =>
expect(screen.getByTestId('bl-tool-palette-status').textContent).toMatch(
/unreachable/i,
),
);
});
it('selects on click', () => {
const onSelect = vi.fn();
render(<ToolPalette source={TOOLS} onSelect={onSelect} />);
fireEvent.click(screen.getByTestId('bl-tool-palette-option-calculator'));
expect(onSelect).toHaveBeenCalledWith(TOOLS[1]);
});
it('does not select disabled tools', () => {
const onSelect = vi.fn();
render(<ToolPalette source={TOOLS} onSelect={onSelect} />);
fireEvent.click(screen.getByTestId('bl-tool-palette-option-image_gen'));
expect(onSelect).not.toHaveBeenCalled();
});
});

View File

@ -1,17 +1,21 @@
/** /**
* @bytelyst/ai-ui AI-native UI primitives. * @bytelyst/ai-ui AI-native UI primitives.
* *
* MVP exports (Wave 2 v0.1.0): * Exports (Wave 2 0.4.0):
* - <ChatStream> composed chat surface (the 80% path) * Components
* - <MessageBubble> single message atom * 0.1: <ChatStream>, <MessageBubble>, <PromptComposer>
* - <PromptComposer> multi-line input with submit-on-Enter * 0.2: <ToolCallCard>, <CitationChip>
* - useChat() Vercel AI SDK-shaped hook * 0.3: <AgentTimeline>, <ModelPicker>
* - streamText() low-level SSE/text/data stream consumer * 0.4: <ToolPalette> (MCP-style discovery)
* Hooks
* 0.1: useChat()
* 0.2: useToolCalls()
* Utilities
* 0.1: streamText()
* *
* Coming in 0.2.x (per ROADMAP §Wave 2): * Coming in 0.5.x (per ROADMAP §Wave 2):
* - <ToolCallCard>, <CitationChip>, <AgentTimeline> * - <Markdown>, <CodeDiff>, <ExplainThis>
* - <ModelPicker>, <ToolPalette> (MCP discovery) * - usePromptHistory(), useTokenCount()
* - useToolCalls(), usePromptHistory(), useTokenCount()
*/ */
export { ChatStream } from './ChatStream.js'; export { ChatStream } from './ChatStream.js';
@ -26,11 +30,35 @@ export type { PromptComposerHandle, PromptComposerProps } from './PromptComposer
export { useChat } from './useChat.js'; export { useChat } from './useChat.js';
export { streamText } from './useStreamingText.js'; export { streamText } from './useStreamingText.js';
// ── 0.2 surfaces ───────────────────────────────────────────────────────────
export { ToolCallCard } from './ToolCallCard.js';
export type { ToolCallCardProps } from './ToolCallCard.js';
export { CitationChip } from './CitationChip.js';
export type { CitationChipProps } from './CitationChip.js';
export { useToolCalls } from './useToolCalls.js';
export type { UseToolCallsHelpers } from './useToolCalls.js';
// ── 0.3 surfaces ───────────────────────────────────────────────────────────
export { AgentTimeline } from './AgentTimeline.js';
export type { AgentStep, AgentStepKind, AgentTimelineProps } from './AgentTimeline.js';
export { ModelPicker } from './ModelPicker.js';
export type { ModelCapability, ModelOption, ModelPickerProps } from './ModelPicker.js';
// ── 0.4 surfaces ───────────────────────────────────────────────────────────
export { ToolPalette } from './ToolPalette.js';
export type { ToolDescriptor, ToolPaletteProps } from './ToolPalette.js';
export type { export type {
ChatTransportOptions, ChatTransportOptions,
Citation,
Message, Message,
MessageRole, MessageRole,
StreamProtocol, StreamProtocol,
ToolCallStatus,
ToolInvocation,
UseChatHelpers, UseChatHelpers,
UseChatOptions, UseChatOptions,
} from './types.js'; } from './types.js';

View File

@ -11,6 +11,36 @@
export type MessageRole = 'system' | 'user' | 'assistant' | 'tool'; export type MessageRole = 'system' | 'user' | 'assistant' | 'tool';
export type ToolCallStatus = 'pending' | 'streaming' | 'success' | 'error';
export interface ToolInvocation {
/** Stable id — typically the tool-call id emitted by the LLM. */
id: string;
/** Tool name (e.g. 'web_search', 'calculator'). */
name: string;
status: ToolCallStatus;
/** Tool input — already-parsed JSON (no wire framing). */
input?: unknown;
/** Tool output — already-parsed JSON, or a raw string. */
output?: unknown;
/** Error message when status === 'error'. */
error?: string;
/** Total wall-clock duration in ms once the call settles. */
durationMs?: number;
}
export interface Citation {
/** Stable id used for deduplication and React keys. */
id: string;
/** 1-based index displayed in the chip. */
index: number;
title: string;
url?: string;
snippet?: string;
/** Optional source label (e.g. 'arxiv', 'wikipedia'). */
source?: string;
}
export interface Message { export interface Message {
/** Stable id — used as React key and for deduplication. */ /** Stable id — used as React key and for deduplication. */
id: string; id: string;
@ -21,6 +51,10 @@ export interface Message {
createdAt?: Date; createdAt?: Date;
/** When true, indicates content is still streaming in (assistant only). */ /** When true, indicates content is still streaming in (assistant only). */
isStreaming?: boolean; isStreaming?: boolean;
/** Tool calls associated with this assistant turn. Added in 0.2.0. */
toolInvocations?: ToolInvocation[];
/** Inline citations referenced by [^n] markers in `content`. Added in 0.2.0. */
citations?: Citation[];
} }
/** /**

View File

@ -0,0 +1,137 @@
import { useCallback, useMemo, useState } from 'react';
import type { ToolCallStatus, ToolInvocation } from './types.js';
export interface UseToolCallsHelpers {
/** Map of id → ToolInvocation, stable across renders. */
invocations: Record<string, ToolInvocation>;
/** Ordered list of invocations (insertion order). */
ordered: ToolInvocation[];
/** Begin a new tool call. Idempotent — repeated calls update in place. */
begin: (
init: Pick<ToolInvocation, 'id' | 'name'> & Partial<Omit<ToolInvocation, 'id' | 'name'>>,
) => void;
/** Merge a partial update into an existing invocation. */
update: (id: string, patch: Partial<ToolInvocation>) => void;
/** Transition an invocation to its terminal state (success / error). */
settle: (
id: string,
result:
| { status: 'success'; output?: unknown }
| { status: 'error'; error: string },
) => void;
/** Remove a specific invocation (e.g. on reload). */
clear: (id: string) => void;
/** Remove all invocations. */
clearAll: () => void;
}
/**
* `useToolCalls` per-turn tool-invocation state machine.
*
* Stores tool calls in two parallel shapes a Record for O(1) lookup
* by id and an ordered array for rendering. Insertion order is preserved
* across `update` calls so vertical scroll position stays stable while
* a stream is in flight.
*
* Typical wiring (Wave 2 0.2 usage with raw streams):
*
* ```ts
* const tools = useToolCalls();
* for await (const event of parseToolStream(response.body)) {
* if (event.type === 'tool-call') tools.begin(event);
* if (event.type === 'tool-delta') tools.update(event.id, event);
* if (event.type === 'tool-result') tools.settle(event.id, event.result);
* }
* ```
*
* 0.4 (`<ToolPalette>` with MCP discovery) will layer auto-wiring on top
* of this hook.
*/
export function useToolCalls(initial: ToolInvocation[] = []): UseToolCallsHelpers {
const [invocations, setInvocations] = useState<Record<string, ToolInvocation>>(
() => Object.fromEntries(initial.map(t => [t.id, t])),
);
const [order, setOrder] = useState<string[]>(() => initial.map(t => t.id));
const begin = useCallback<UseToolCallsHelpers['begin']>(init => {
const id = init.id;
const status: ToolCallStatus = init.status ?? 'pending';
const startedAt = performance.now();
setInvocations(prev => {
const existing = prev[id];
const next: ToolInvocation = {
...existing,
status,
...init,
// Carry the start timestamp into a private field for later
// settle() to compute durationMs. We tuck it under a symbol-
// free convention so consumers can still read everything.
};
// Stash startedAt on a hidden field. We can't use Symbol because
// we want this hook to survive JSON serialization in tests.
(next as { __startedAt?: number }).__startedAt = startedAt;
return { ...prev, [id]: next };
});
setOrder(prev => (prev.includes(id) ? prev : [...prev, id]));
}, []);
const update = useCallback<UseToolCallsHelpers['update']>((id, patch) => {
setInvocations(prev => {
const existing = prev[id];
if (!existing) return prev;
return { ...prev, [id]: { ...existing, ...patch } };
});
}, []);
const settle = useCallback<UseToolCallsHelpers['settle']>((id, result) => {
setInvocations(prev => {
const existing = prev[id];
if (!existing) return prev;
const startedAt = (existing as { __startedAt?: number }).__startedAt;
const durationMs =
typeof startedAt === 'number'
? Math.max(0, Math.round(performance.now() - startedAt))
: existing.durationMs;
const next: ToolInvocation =
result.status === 'success'
? {
...existing,
status: 'success',
output: result.output ?? existing.output,
error: undefined,
durationMs,
}
: {
...existing,
status: 'error',
error: result.error,
durationMs,
};
return { ...prev, [id]: next };
});
}, []);
const clear = useCallback<UseToolCallsHelpers['clear']>(id => {
setInvocations(prev => {
if (!(id in prev)) return prev;
const { [id]: _drop, ...rest } = prev;
return rest;
});
setOrder(prev => prev.filter(x => x !== id));
}, []);
const clearAll = useCallback(() => {
setInvocations({});
setOrder([]);
}, []);
const ordered = useMemo(
() =>
order
.map(id => invocations[id])
.filter((x): x is ToolInvocation => x !== undefined),
[order, invocations],
);
return { invocations, ordered, begin, update, settle, clear, clearAll };
}

View File

@ -0,0 +1,36 @@
{
"name": "@bytelyst/command-palette",
"version": "0.1.0",
"type": "module",
"description": "Cmd-K command palette with Actions / Navigate / Ask-AI modes; pluggable command registration via useRegisterCommands.",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run --pool forks",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"happy-dom": "^18.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"typescript": "^5.7.3",
"vitest": "^4.0.18"
}
}

View File

@ -0,0 +1,578 @@
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],
);
}

View File

@ -0,0 +1,356 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react';
import {
CommandPalette,
CommandRegistryProvider,
useCommandPalette,
useRegisterCommands,
useCommands,
} from '../index.js';
import { fuzzyScore, scoreCommand } from '../fuzzy.js';
import type { Command } from '../types.js';
// ─── fuzzyScore / scoreCommand ────────────────────────────────────────────
describe('fuzzyScore', () => {
it('returns 1000 for exact match (case-insensitive)', () => {
expect(fuzzyScore('Settings', 'settings')).toBe(1000);
});
it('returns 500 for prefix match', () => {
expect(fuzzyScore('settings', 'set')).toBe(500);
});
it('returns positive score for substring match', () => {
const s = fuzzyScore('user settings', 'set');
expect(s).not.toBeNull();
expect(s).toBeLessThan(500);
expect(s).toBeGreaterThan(0);
});
it('matches subsequences even without contiguous substring', () => {
expect(fuzzyScore('go to billing', 'gtb')).not.toBeNull();
});
it('returns null when no subsequence match exists', () => {
expect(fuzzyScore('billing', 'xyz')).toBeNull();
});
it('empty query yields 0', () => {
expect(fuzzyScore('anything', '')).toBe(0);
});
});
describe('scoreCommand', () => {
it('searches across label, description, section, keywords', () => {
const cmd = {
label: 'New invoice',
description: 'Create a billing entry',
section: 'Finance',
keywords: ['receipt', 'bill'],
};
expect(scoreCommand(cmd, 'invoice')).not.toBeNull();
expect(scoreCommand(cmd, 'finance')).not.toBeNull();
expect(scoreCommand(cmd, 'receipt')).not.toBeNull();
expect(scoreCommand(cmd, 'xyz')).toBeNull();
});
it('zero score for empty query (visible row)', () => {
expect(scoreCommand({ label: 'X' }, '')).toBe(0);
});
});
// ─── Registry ────────────────────────────────────────────────────────────
const wrapper =
(initial: Command[] = []) =>
({ children }: { children: React.ReactNode }) => (
<CommandRegistryProvider initial={initial}>{children}</CommandRegistryProvider>
);
describe('CommandRegistryProvider', () => {
it('seeds from initial', () => {
const cmds: Command[] = [{ id: 'a', label: 'Alpha' }];
const { result } = renderHook(() => useCommands(), { wrapper: wrapper(cmds) });
expect(result.current.map(c => c.id)).toEqual(['a']);
});
it('useRegisterCommands adds + removes on unmount', () => {
const { result, unmount } = renderHook(
() => {
useRegisterCommands([
{ id: 'temp', label: 'Temp' },
{ id: 'temp2', label: 'Temp2' },
]);
return useCommands();
},
{ wrapper: wrapper() },
);
expect(result.current.map(c => c.id).sort()).toEqual(['temp', 'temp2']);
unmount();
// After unmount we can no longer call useCommands(); the snapshot
// before unmount is the assertion that matters. (Verified by the
// cleanup hook in the registry — see CommandPalette integration test
// which exercises both registration and unregistration end-to-end.)
});
it('throws when used outside provider', () => {
expect(() => renderHook(() => useCommands())).toThrow(
/CommandRegistryProvider/,
);
});
});
// ─── CommandPalette dialog ────────────────────────────────────────────────
const seed: Command[] = [
{ id: 'new-task', label: 'New task', run: vi.fn(), section: 'Tasks' },
{ id: 'goto-settings', label: 'Settings', mode: 'navigate', href: '/settings' },
{ id: 'goto-billing', label: 'Billing', mode: 'navigate', href: '/billing' },
{ id: 'archive', label: 'Archive', run: vi.fn(), enabled: false },
{ id: 'gated', label: 'Admin', run: vi.fn(), requires: () => false },
];
describe('CommandPalette', () => {
beforeEach(() => {
cleanup();
try {
window.localStorage.removeItem('bl-cmdk-recents');
} catch {
/* noop — some happy-dom builds throw */
}
(seed[0].run as ReturnType<typeof vi.fn>).mockReset();
(seed[3].run as ReturnType<typeof vi.fn>).mockReset();
});
it('does not render when open=false', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open={false} onClose={() => {}} />
</CommandRegistryProvider>,
);
expect(screen.queryByTestId('bl-cmdk')).toBeNull();
});
it('renders with default actions tab + input focused', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>,
);
expect(screen.getByTestId('bl-cmdk-panel')).toBeDefined();
expect(screen.getByTestId('bl-cmdk-item-new-task')).toBeDefined();
// Navigate-mode item should NOT appear in the actions tab.
expect(screen.queryByTestId('bl-cmdk-item-goto-settings')).toBeNull();
});
it('hides requires() === false commands', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>,
);
expect(screen.queryByTestId('bl-cmdk-item-gated')).toBeNull();
});
it('Tab cycles modes; navigate tab shows only navigate-mode commands', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>,
);
const dialog = screen.getByTestId('bl-cmdk');
fireEvent.keyDown(dialog, { key: 'Tab' });
expect(screen.getByTestId('bl-cmdk-item-goto-settings')).toBeDefined();
expect(screen.getByTestId('bl-cmdk-item-goto-billing')).toBeDefined();
expect(screen.queryByTestId('bl-cmdk-item-new-task')).toBeNull();
});
it('filters results by query', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>,
);
fireEvent.change(screen.getByTestId('bl-cmdk-input'), {
target: { value: 'task' },
});
expect(screen.getByTestId('bl-cmdk-item-new-task')).toBeDefined();
});
it('Enter activates the selected command via run()', () => {
const onClose = vi.fn();
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={onClose} />
</CommandRegistryProvider>,
);
fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Enter' });
expect((seed[0].run as ReturnType<typeof vi.fn>)).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalled();
});
it('navigate-mode Enter calls onNavigate with href', () => {
const onNavigate = vi.fn();
const onClose = vi.fn();
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={onClose} onNavigate={onNavigate} />
</CommandRegistryProvider>,
);
fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Tab' });
fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Enter' });
expect(onNavigate).toHaveBeenCalledWith('/settings');
expect(onClose).toHaveBeenCalled();
});
it('arrow keys move selection (highlighted row)', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>,
);
const dialog = screen.getByTestId('bl-cmdk');
fireEvent.keyDown(dialog, { key: 'ArrowDown' });
// After one ArrowDown, second visible action becomes selected.
// 'new-task' is at index 0; 'archive' is index 1 (disabled but listed).
// We assert aria-selected attribute.
const items = screen.getAllByRole('option');
expect(items[1]?.getAttribute('aria-selected')).toBe('true');
});
it('Escape closes', () => {
const onClose = vi.fn();
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={onClose} />
</CommandRegistryProvider>,
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledOnce();
});
it('Ask-AI tab renders default panel; custom askAiPanel takes precedence', () => {
const { rerender } = render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} initialMode="ask-ai" />
</CommandRegistryProvider>,
);
expect(screen.getByTestId('bl-cmdk-ask-ai')).toBeDefined();
rerender(
<CommandRegistryProvider initial={seed}>
<CommandPalette
open
onClose={() => {}}
initialMode="ask-ai"
askAiPanel={q => <div data-testid="custom-ai">{q || 'idle'}</div>}
/>
</CommandRegistryProvider>,
);
expect(screen.getByTestId('custom-ai').textContent).toBe('idle');
});
it('disabled commands are not activatable by click', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>,
);
const row = screen.getByTestId('bl-cmdk-item-archive');
expect(row.getAttribute('aria-disabled')).toBe('true');
fireEvent.click(row);
expect((seed[3].run as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
});
it('recents persist to localStorage when a command is run', () => {
// happy-dom v18 ships an incomplete Storage API in some builds, so
// we install a deterministic in-memory shim for the duration of
// this test. The shape is exactly what the registry depends on.
const mem: Record<string, string> = {};
const stub = {
getItem: (k: string) => (k in mem ? mem[k]! : null),
setItem: (k: string, v: string) => {
mem[k] = v;
},
removeItem: (k: string) => {
delete mem[k];
},
clear: () => {
for (const k of Object.keys(mem)) delete mem[k];
},
key: (i: number) => Object.keys(mem)[i] ?? null,
get length() {
return Object.keys(mem).length;
},
};
const original = window.localStorage;
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: stub,
});
try {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>,
);
fireEvent.click(screen.getByTestId('bl-cmdk-item-new-task'));
expect(mem['bl-cmdk-recents']).toContain('new-task');
} finally {
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: original,
});
}
});
});
// ─── useCommandPalette hotkey ────────────────────────────────────────────
describe('useCommandPalette', () => {
it('toggles via Cmd-K / Ctrl-K and stays closed on plain K', () => {
const { result } = renderHook(() => useCommandPalette());
expect(result.current.open).toBe(false);
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'k', metaKey: true }),
);
});
expect(result.current.open).toBe(true);
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'k', metaKey: true }),
);
});
expect(result.current.open).toBe(false);
// Plain 'k' should NOT toggle.
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k' }));
});
expect(result.current.open).toBe(false);
});
it('show/hide/toggle imperative helpers', () => {
const { result } = renderHook(() => useCommandPalette({ hotkey: null }));
act(() => result.current.show());
expect(result.current.open).toBe(true);
act(() => result.current.toggle());
expect(result.current.open).toBe(false);
act(() => result.current.toggle());
expect(result.current.open).toBe(true);
act(() => result.current.hide());
expect(result.current.open).toBe(false);
});
it('respects defaultOpen', () => {
const { result } = renderHook(() =>
useCommandPalette({ defaultOpen: true, hotkey: null }),
);
expect(result.current.open).toBe(true);
});
});

View File

@ -0,0 +1,71 @@
/**
* Tiny fuzzy matcher substring + subsequence with light scoring.
*
* Score components (higher = better):
* +1000 exact label match
* +500 label starts with the query
* +200 label contains the query (case-insensitive)
* +100 section/description/keyword contains the query
* +50 first-character matches the first query character
* -idx small penalty per character of "gap" between matched chars
*
* Returns `null` when no character of the query can be aligned at all.
*/
export function fuzzyScore(haystack: string, query: string): number | null {
if (!query) return 0;
const h = haystack.toLowerCase();
const q = query.toLowerCase();
if (h === q) return 1000;
if (h.startsWith(q)) return 500;
const idx = h.indexOf(q);
if (idx !== -1) return 200 - idx;
// Subsequence match — every char of q appears in order in h.
let hi = 0;
let qi = 0;
let gaps = 0;
let firstMatch = -1;
while (hi < h.length && qi < q.length) {
if (h[hi] === q[qi]) {
if (firstMatch === -1) firstMatch = hi;
qi += 1;
} else if (qi > 0) {
gaps += 1;
}
hi += 1;
}
if (qi !== q.length) return null;
return 50 + (firstMatch === 0 ? 50 : 0) - gaps;
}
export interface ScoredCommandFields {
label: string;
description?: string;
section?: string;
keywords?: string[];
}
/**
* Compose the best score across a command's searchable fields.
* Falls back to `null` when nothing matches caller hides the row.
*/
export function scoreCommand(
fields: ScoredCommandFields,
query: string,
): number | null {
if (!query) return 0;
const candidates = [
fields.label,
fields.description ?? '',
fields.section ?? '',
...(fields.keywords ?? []),
];
let best: number | null = null;
for (const c of candidates) {
if (!c) continue;
const score = fuzzyScore(c, query);
if (score !== null && (best === null || score > best)) best = score;
}
return best;
}

View File

@ -0,0 +1,38 @@
/**
* @bytelyst/command-palette Cmd-K palette for ByteLyst products.
*
* Exports (Wave 3 0.1.0):
* Components
* <CommandRegistryProvider> wrap your app once
* <CommandPalette> the Cmd-K dialog
* Hooks
* useRegisterCommands(cmds) contribute commands for component lifetime
* useCommands() read the current registry snapshot
* useCommandRegistry() imperative access (rarely needed)
* useCommandPalette(opts) open/closed state + global hotkey
* Utilities
* scoreCommand(fields, q) internal fuzzy scorer, exposed for tests
* fuzzyScore(hay, q) single-string scorer
*/
export { CommandPalette } from './CommandPalette.js';
export type { CommandPaletteProps } from './CommandPalette.js';
export {
CommandRegistryProvider,
useRegisterCommands,
useCommands,
useCommandRegistry,
} from './registry.js';
export type { CommandRegistryProviderProps } from './registry.js';
export { useCommandPalette } from './useCommandPalette.js';
export type {
UseCommandPaletteHelpers,
UseCommandPaletteOptions,
} from './useCommandPalette.js';
export { fuzzyScore, scoreCommand } from './fuzzy.js';
export type { ScoredCommandFields } from './fuzzy.js';
export type { Command, CommandMode } from './types.js';

View File

@ -0,0 +1,128 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import type { Command } from './types.js';
interface RegistryState {
commands: Map<string, Command>;
register: (commands: Command[]) => () => void;
unregister: (ids: string[]) => void;
clear: () => void;
}
const RegistryContext = createContext<RegistryState | null>(null);
export interface CommandRegistryProviderProps {
/** Optional seed of commands always available (app-level chrome). */
initial?: Command[];
children: ReactNode;
}
/**
* Provides a shared command registry to descendants. Wrap your app once
* (typically next to `<DashboardShell>`) and any nested component can
* call `useRegisterCommands(...)` to contribute commands for as long as
* it stays mounted.
*/
export function CommandRegistryProvider({
initial = [],
children,
}: CommandRegistryProviderProps) {
const [commands, setCommands] = useState<Map<string, Command>>(() => {
const m = new Map<string, Command>();
for (const c of initial) m.set(c.id, c);
return m;
});
const register = useCallback<RegistryState['register']>(next => {
setCommands(prev => {
const m = new Map(prev);
for (const c of next) m.set(c.id, c);
return m;
});
return () => {
setCommands(prev => {
const m = new Map(prev);
for (const c of next) m.delete(c.id);
return m;
});
};
}, []);
const unregister = useCallback<RegistryState['unregister']>(ids => {
setCommands(prev => {
const m = new Map(prev);
for (const id of ids) m.delete(id);
return m;
});
}, []);
const clear = useCallback(() => setCommands(new Map()), []);
const value = useMemo<RegistryState>(
() => ({ commands, register, unregister, clear }),
[commands, register, unregister, clear],
);
return <RegistryContext.Provider value={value}>{children}</RegistryContext.Provider>;
}
function useRegistry(): RegistryState {
const ctx = useContext(RegistryContext);
if (!ctx) {
throw new Error(
'@bytelyst/command-palette: useRegisterCommands / useCommands must be used within <CommandRegistryProvider>',
);
}
return ctx;
}
/**
* Register commands for the lifetime of the calling component. Returns a
* stable function callers can ignore registration unwinds automatically
* on unmount via the returned effect cleanup.
*
* @example
* ```tsx
* useRegisterCommands([
* { id: 'new-task', label: 'New task', icon: '+', run: openNewTask },
* { id: 'goto-billing', label: 'Billing', mode: 'navigate', href: '/billing' },
* ]);
* ```
*/
export function useRegisterCommands(commands: Command[]): void {
const { register } = useRegistry();
// Stable ref to the most-recent commands list. We intentionally avoid
// re-registering on every keystroke / state tick — callers usually
// pass an inline array. The serialization key below keeps it cheap.
const key = useMemo(
() =>
commands
.map(c => `${c.id}|${c.label}|${c.mode ?? ''}|${c.section ?? ''}`)
.join('\n'),
[commands],
);
useEffect(() => {
const off = register(commands);
return off;
// eslint-disable-next-line react-hooks/exhaustive-deps -- key is the canonical identity
}, [key, register]);
}
/** Read the current registry snapshot. */
export function useCommands(): Command[] {
const { commands } = useRegistry();
return useMemo(() => Array.from(commands.values()), [commands]);
}
/** Imperative access (e.g. for tests or shell-level wiring). */
export function useCommandRegistry(): RegistryState {
return useRegistry();
}

View File

@ -0,0 +1,38 @@
import type { ReactNode } from 'react';
export type CommandMode = 'actions' | 'navigate' | 'ask-ai';
export interface Command {
/** Stable id — used for React key + persistence (recents/favourites). */
id: string;
/** Display label. */
label: string;
/** Optional short description rendered under the label. */
description?: string;
/** Glyph rendered to the left of the label. */
icon?: ReactNode;
/** Mode bucket — controls which tab the command appears under. */
mode?: CommandMode;
/** Keyboard shortcut hint (e.g. ['Cmd', 'K']). Display only. */
shortcut?: string[];
/** Searchable synonyms / aliases that contribute to fuzzy match. */
keywords?: string[];
/** Section heading the command belongs to. */
section?: string;
/** For navigate-mode commands — the target href. */
href?: string;
/** For action-mode commands — what to run. */
run?: () => void | Promise<void>;
/** When false the command is greyed out and unselectable. */
enabled?: boolean;
/**
* Auth/feature-flag gate. If returned falsy at filter time the command is
* hidden entirely. Pure functions only called on every query.
*/
requires?: () => boolean;
/**
* Optional product/scope tag (e.g. 'chronomind'). Lets the host filter
* the registry per-product without registering/unregistering on mount.
*/
productScope?: string;
}

View File

@ -0,0 +1,68 @@
import { useCallback, useEffect, useState } from 'react';
export interface UseCommandPaletteOptions {
/** Default hotkey: Cmd-K / Ctrl-K. Pass `null` to disable. */
hotkey?: { key: string; meta?: boolean; ctrl?: boolean; shift?: boolean } | null;
/** Initial open state. */
defaultOpen?: boolean;
}
export interface UseCommandPaletteHelpers {
open: boolean;
show: () => void;
hide: () => void;
toggle: () => void;
}
/**
* Manages open/closed state for `<CommandPalette>` and (optionally)
* binds a global hotkey. Drop it next to your app shell:
*
* ```tsx
* const cmdk = useCommandPalette();
* return (
* <>
* <YourApp />
* <CommandPalette open={cmdk.open} onClose={cmdk.hide} />
* </>
* );
* ```
*
* The default hotkey (`Cmd-K` / `Ctrl-K`) is suppressed inside text
* inputs unless the user explicitly holds the modifier.
*/
export function useCommandPalette(
options: UseCommandPaletteOptions = {},
): UseCommandPaletteHelpers {
const { hotkey = { key: 'k', meta: true, ctrl: true }, defaultOpen = false } =
options;
const [open, setOpen] = useState(defaultOpen);
const show = useCallback(() => setOpen(true), []);
const hide = useCallback(() => setOpen(false), []);
const toggle = useCallback(() => setOpen(o => !o), []);
useEffect(() => {
if (!hotkey) return;
const handler = (e: KeyboardEvent) => {
if (e.key.toLowerCase() !== hotkey.key.toLowerCase()) return;
const wantMeta = hotkey.meta ?? false;
const wantCtrl = hotkey.ctrl ?? false;
const wantShift = hotkey.shift ?? false;
// Match if EITHER meta or ctrl is held when both are flagged (Mac/Win parity).
const modOk =
wantMeta || wantCtrl
? (wantMeta && e.metaKey) || (wantCtrl && e.ctrlKey)
: !e.metaKey && !e.ctrlKey;
const shiftOk = wantShift ? e.shiftKey : true;
if (modOk && shiftOk) {
e.preventDefault();
setOpen(o => !o);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [hotkey]);
return { open, show, hide, toggle };
}

View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}

View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom',
pool: 'forks',
},
});

27
pnpm-lock.yaml generated
View File

@ -459,6 +459,33 @@ importers:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/command-palette:
devDependencies:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/react':
specifier: ^19.2.14
version: 19.2.14
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
happy-dom:
specifier: ^18.0.1
version: 18.0.1
react:
specifier: ^19.2.4
version: 19.2.4
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/config: packages/config:
dependencies: dependencies:
zod: zod: