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/react-auth \
|
||||||
--filter @bytelyst/dashboard-shell \
|
--filter @bytelyst/dashboard-shell \
|
||||||
--filter @bytelyst/ai-ui \
|
--filter @bytelyst/ai-ui \
|
||||||
|
--filter @bytelyst/command-palette \
|
||||||
run build
|
run build
|
||||||
|
|
||||||
- name: Enforce size budgets
|
- name: Enforce size budgets
|
||||||
|
|||||||
@ -72,4 +72,11 @@ module.exports = [
|
|||||||
limit: '35 KB',
|
limit: '35 KB',
|
||||||
gzip: true,
|
gzip: true,
|
||||||
},
|
},
|
||||||
|
// ── Command palette (15 KB — fuzzy + dialog + registry) ─────────
|
||||||
|
{
|
||||||
|
name: '@bytelyst/command-palette',
|
||||||
|
path: 'packages/command-palette/dist/index.js',
|
||||||
|
limit: '15 KB',
|
||||||
|
gzip: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@bytelyst/ai-ui",
|
"name": "@bytelyst/ai-ui",
|
||||||
"version": "0.1.0",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-native UI primitives — ChatStream, MessageBubble, PromptComposer, useChat. Transport-agnostic; adopts Vercel AI SDK hook shape.",
|
"description": "AI-native UI primitives — ChatStream, MessageBubble, PromptComposer, useChat. Transport-agnostic; adopts Vercel AI SDK hook shape.",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
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.
|
* @bytelyst/ai-ui — AI-native UI primitives.
|
||||||
*
|
*
|
||||||
* MVP exports (Wave 2 v0.1.0):
|
* Exports (Wave 2 — 0.4.0):
|
||||||
* - <ChatStream> — composed chat surface (the 80% path)
|
* Components
|
||||||
* - <MessageBubble> — single message atom
|
* 0.1: <ChatStream>, <MessageBubble>, <PromptComposer>
|
||||||
* - <PromptComposer> — multi-line input with submit-on-Enter
|
* 0.2: <ToolCallCard>, <CitationChip>
|
||||||
* - useChat() — Vercel AI SDK-shaped hook
|
* 0.3: <AgentTimeline>, <ModelPicker>
|
||||||
* - streamText() — low-level SSE/text/data stream consumer
|
* 0.4: <ToolPalette> (MCP-style discovery)
|
||||||
|
* Hooks
|
||||||
|
* 0.1: useChat()
|
||||||
|
* 0.2: useToolCalls()
|
||||||
|
* Utilities
|
||||||
|
* 0.1: streamText()
|
||||||
*
|
*
|
||||||
* Coming in 0.2.x (per ROADMAP §Wave 2):
|
* Coming in 0.5.x (per ROADMAP §Wave 2):
|
||||||
* - <ToolCallCard>, <CitationChip>, <AgentTimeline>
|
* - <Markdown>, <CodeDiff>, <ExplainThis>
|
||||||
* - <ModelPicker>, <ToolPalette> (MCP discovery)
|
* - usePromptHistory(), useTokenCount()
|
||||||
* - useToolCalls(), usePromptHistory(), useTokenCount()
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { ChatStream } from './ChatStream.js';
|
export { ChatStream } from './ChatStream.js';
|
||||||
@ -26,11 +30,35 @@ export type { PromptComposerHandle, PromptComposerProps } from './PromptComposer
|
|||||||
export { useChat } from './useChat.js';
|
export { useChat } from './useChat.js';
|
||||||
export { streamText } from './useStreamingText.js';
|
export { streamText } from './useStreamingText.js';
|
||||||
|
|
||||||
|
// ── 0.2 surfaces ───────────────────────────────────────────────────────────
|
||||||
|
export { ToolCallCard } from './ToolCallCard.js';
|
||||||
|
export type { ToolCallCardProps } from './ToolCallCard.js';
|
||||||
|
|
||||||
|
export { CitationChip } from './CitationChip.js';
|
||||||
|
export type { CitationChipProps } from './CitationChip.js';
|
||||||
|
|
||||||
|
export { useToolCalls } from './useToolCalls.js';
|
||||||
|
export type { UseToolCallsHelpers } from './useToolCalls.js';
|
||||||
|
|
||||||
|
// ── 0.3 surfaces ───────────────────────────────────────────────────────────
|
||||||
|
export { AgentTimeline } from './AgentTimeline.js';
|
||||||
|
export type { AgentStep, AgentStepKind, AgentTimelineProps } from './AgentTimeline.js';
|
||||||
|
|
||||||
|
export { ModelPicker } from './ModelPicker.js';
|
||||||
|
export type { ModelCapability, ModelOption, ModelPickerProps } from './ModelPicker.js';
|
||||||
|
|
||||||
|
// ── 0.4 surfaces ───────────────────────────────────────────────────────────
|
||||||
|
export { ToolPalette } from './ToolPalette.js';
|
||||||
|
export type { ToolDescriptor, ToolPaletteProps } from './ToolPalette.js';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ChatTransportOptions,
|
ChatTransportOptions,
|
||||||
|
Citation,
|
||||||
Message,
|
Message,
|
||||||
MessageRole,
|
MessageRole,
|
||||||
StreamProtocol,
|
StreamProtocol,
|
||||||
|
ToolCallStatus,
|
||||||
|
ToolInvocation,
|
||||||
UseChatHelpers,
|
UseChatHelpers,
|
||||||
UseChatOptions,
|
UseChatOptions,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|||||||
@ -11,6 +11,36 @@
|
|||||||
|
|
||||||
export type MessageRole = 'system' | 'user' | 'assistant' | 'tool';
|
export type MessageRole = 'system' | 'user' | 'assistant' | 'tool';
|
||||||
|
|
||||||
|
export type ToolCallStatus = 'pending' | 'streaming' | 'success' | 'error';
|
||||||
|
|
||||||
|
export interface ToolInvocation {
|
||||||
|
/** Stable id — typically the tool-call id emitted by the LLM. */
|
||||||
|
id: string;
|
||||||
|
/** Tool name (e.g. 'web_search', 'calculator'). */
|
||||||
|
name: string;
|
||||||
|
status: ToolCallStatus;
|
||||||
|
/** Tool input — already-parsed JSON (no wire framing). */
|
||||||
|
input?: unknown;
|
||||||
|
/** Tool output — already-parsed JSON, or a raw string. */
|
||||||
|
output?: unknown;
|
||||||
|
/** Error message when status === 'error'. */
|
||||||
|
error?: string;
|
||||||
|
/** Total wall-clock duration in ms once the call settles. */
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Citation {
|
||||||
|
/** Stable id used for deduplication and React keys. */
|
||||||
|
id: string;
|
||||||
|
/** 1-based index displayed in the chip. */
|
||||||
|
index: number;
|
||||||
|
title: string;
|
||||||
|
url?: string;
|
||||||
|
snippet?: string;
|
||||||
|
/** Optional source label (e.g. 'arxiv', 'wikipedia'). */
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
/** Stable id — used as React key and for deduplication. */
|
/** Stable id — used as React key and for deduplication. */
|
||||||
id: string;
|
id: string;
|
||||||
@ -21,6 +51,10 @@ export interface Message {
|
|||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
/** When true, indicates content is still streaming in (assistant only). */
|
/** When true, indicates content is still streaming in (assistant only). */
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
|
/** Tool calls associated with this assistant turn. Added in 0.2.0. */
|
||||||
|
toolInvocations?: ToolInvocation[];
|
||||||
|
/** Inline citations referenced by [^n] markers in `content`. Added in 0.2.0. */
|
||||||
|
citations?: Citation[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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
|
specifier: ^3.0.0
|
||||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
|
|
||||||
|
packages/command-palette:
|
||||||
|
devDependencies:
|
||||||
|
'@testing-library/react':
|
||||||
|
specifier: ^16.3.2
|
||||||
|
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
'@types/react':
|
||||||
|
specifier: ^19.2.14
|
||||||
|
version: 19.2.14
|
||||||
|
'@types/react-dom':
|
||||||
|
specifier: ^19.2.3
|
||||||
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
|
happy-dom:
|
||||||
|
specifier: ^18.0.1
|
||||||
|
version: 18.0.1
|
||||||
|
react:
|
||||||
|
specifier: ^19.2.4
|
||||||
|
version: 19.2.4
|
||||||
|
react-dom:
|
||||||
|
specifier: ^19.2.4
|
||||||
|
version: 19.2.4(react@19.2.4)
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.3
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^4.0.18
|
||||||
|
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
|
|
||||||
packages/config:
|
packages/config:
|
||||||
dependencies:
|
dependencies:
|
||||||
zod:
|
zod:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user