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:
parent
c9a7f905af
commit
e2eea086dc
@ -60,6 +60,7 @@ jobs:
|
||||
--filter @bytelyst/react-auth \
|
||||
--filter @bytelyst/dashboard-shell \
|
||||
--filter @bytelyst/ai-ui \
|
||||
--filter @bytelyst/command-palette \
|
||||
run build
|
||||
|
||||
- name: Enforce size budgets
|
||||
|
||||
@ -72,4 +72,11 @@ module.exports = [
|
||||
limit: '35 KB',
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bytelyst/ai-ui",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "AI-native UI primitives — ChatStream, MessageBubble, PromptComposer, useChat. Transport-agnostic; adopts Vercel AI SDK hook shape.",
|
||||
"exports": {
|
||||
|
||||
182
packages/ai-ui/src/AgentTimeline.tsx
Normal file
182
packages/ai-ui/src/AgentTimeline.tsx
Normal 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)',
|
||||
},
|
||||
};
|
||||
140
packages/ai-ui/src/CitationChip.tsx
Normal file
140
packages/ai-ui/src/CitationChip.tsx
Normal 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)}…`;
|
||||
}
|
||||
247
packages/ai-ui/src/ModelPicker.tsx
Normal file
247
packages/ai-ui/src/ModelPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
234
packages/ai-ui/src/ToolCallCard.tsx
Normal file
234
packages/ai-ui/src/ToolCallCard.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
227
packages/ai-ui/src/ToolPalette.tsx
Normal file
227
packages/ai-ui/src/ToolPalette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
357
packages/ai-ui/src/__tests__/v02-v04.test.tsx
Normal file
357
packages/ai-ui/src/__tests__/v02-v04.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -1,17 +1,21 @@
|
||||
/**
|
||||
* @bytelyst/ai-ui — AI-native UI primitives.
|
||||
*
|
||||
* MVP exports (Wave 2 v0.1.0):
|
||||
* - <ChatStream> — composed chat surface (the 80% path)
|
||||
* - <MessageBubble> — single message atom
|
||||
* - <PromptComposer> — multi-line input with submit-on-Enter
|
||||
* - useChat() — Vercel AI SDK-shaped hook
|
||||
* - streamText() — low-level SSE/text/data stream consumer
|
||||
* Exports (Wave 2 — 0.4.0):
|
||||
* Components
|
||||
* 0.1: <ChatStream>, <MessageBubble>, <PromptComposer>
|
||||
* 0.2: <ToolCallCard>, <CitationChip>
|
||||
* 0.3: <AgentTimeline>, <ModelPicker>
|
||||
* 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):
|
||||
* - <ToolCallCard>, <CitationChip>, <AgentTimeline>
|
||||
* - <ModelPicker>, <ToolPalette> (MCP discovery)
|
||||
* - useToolCalls(), usePromptHistory(), useTokenCount()
|
||||
* Coming in 0.5.x (per ROADMAP §Wave 2):
|
||||
* - <Markdown>, <CodeDiff>, <ExplainThis>
|
||||
* - usePromptHistory(), useTokenCount()
|
||||
*/
|
||||
|
||||
export { ChatStream } from './ChatStream.js';
|
||||
@ -26,11 +30,35 @@ export type { PromptComposerHandle, PromptComposerProps } from './PromptComposer
|
||||
export { useChat } from './useChat.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 {
|
||||
ChatTransportOptions,
|
||||
Citation,
|
||||
Message,
|
||||
MessageRole,
|
||||
StreamProtocol,
|
||||
ToolCallStatus,
|
||||
ToolInvocation,
|
||||
UseChatHelpers,
|
||||
UseChatOptions,
|
||||
} from './types.js';
|
||||
|
||||
@ -11,6 +11,36 @@
|
||||
|
||||
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 {
|
||||
/** Stable id — used as React key and for deduplication. */
|
||||
id: string;
|
||||
@ -21,6 +51,10 @@ export interface Message {
|
||||
createdAt?: Date;
|
||||
/** When true, indicates content is still streaming in (assistant only). */
|
||||
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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
137
packages/ai-ui/src/useToolCalls.ts
Normal file
137
packages/ai-ui/src/useToolCalls.ts
Normal 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 };
|
||||
}
|
||||
36
packages/command-palette/package.json
Normal file
36
packages/command-palette/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
578
packages/command-palette/src/CommandPalette.tsx
Normal file
578
packages/command-palette/src/CommandPalette.tsx
Normal 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],
|
||||
);
|
||||
}
|
||||
356
packages/command-palette/src/__tests__/command-palette.test.tsx
Normal file
356
packages/command-palette/src/__tests__/command-palette.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
71
packages/command-palette/src/fuzzy.ts
Normal file
71
packages/command-palette/src/fuzzy.ts
Normal 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;
|
||||
}
|
||||
38
packages/command-palette/src/index.ts
Normal file
38
packages/command-palette/src/index.ts
Normal 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';
|
||||
128
packages/command-palette/src/registry.tsx
Normal file
128
packages/command-palette/src/registry.tsx
Normal 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();
|
||||
}
|
||||
38
packages/command-palette/src/types.ts
Normal file
38
packages/command-palette/src/types.ts
Normal 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;
|
||||
}
|
||||
68
packages/command-palette/src/useCommandPalette.ts
Normal file
68
packages/command-palette/src/useCommandPalette.ts
Normal 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 };
|
||||
}
|
||||
11
packages/command-palette/tsconfig.json
Normal file
11
packages/command-palette/tsconfig.json
Normal 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"]
|
||||
}
|
||||
8
packages/command-palette/vitest.config.ts
Normal file
8
packages/command-palette/vitest.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
pool: 'forks',
|
||||
},
|
||||
});
|
||||
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
@ -459,6 +459,33 @@ importers:
|
||||
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)
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
zod:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user