From e2eea086dc19d49d469da6cf2fcbafaf616fceee Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Wed, 27 May 2026 12:35:10 -0700 Subject: [PATCH] =?UTF-8?q?feat(packages):=20Wave=202=20v0.4=20+=20Wave=20?= =?UTF-8?q?3=20v0.1=20=E2=80=94=20ai-ui=20expanded,=20command-palette=20ne?= =?UTF-8?q?w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ═══════════════════════════════════════════════════════════════════════ @bytelyst/ai-ui bump 0.1.0 → 0.4.0 ═══════════════════════════════════════════════════════════════════════ Folds three more roadmap milestones into the flagship package. 0.2: — disclosure card; status pill, JSON preview — 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: — vertical think→act→observe→respond trace; embeds ToolCallCard for kind='tool_call' steps — model dropdown with capability chips, cost, latency, context window, disabled gating 0.4: — 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: — wrap your app once — 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: ' suggestion that products can wire to 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) --- .gitea/workflows/size-limit.yml | 1 + .size-limit.cjs | 7 + packages/ai-ui/package.json | 2 +- packages/ai-ui/src/AgentTimeline.tsx | 182 ++++++ packages/ai-ui/src/CitationChip.tsx | 140 +++++ packages/ai-ui/src/ModelPicker.tsx | 247 ++++++++ packages/ai-ui/src/ToolCallCard.tsx | 234 +++++++ packages/ai-ui/src/ToolPalette.tsx | 227 +++++++ packages/ai-ui/src/__tests__/v02-v04.test.tsx | 357 +++++++++++ packages/ai-ui/src/index.ts | 48 +- packages/ai-ui/src/types.ts | 34 ++ packages/ai-ui/src/useToolCalls.ts | 137 +++++ packages/command-palette/package.json | 36 ++ .../command-palette/src/CommandPalette.tsx | 578 ++++++++++++++++++ .../src/__tests__/command-palette.test.tsx | 356 +++++++++++ packages/command-palette/src/fuzzy.ts | 71 +++ packages/command-palette/src/index.ts | 38 ++ packages/command-palette/src/registry.tsx | 128 ++++ packages/command-palette/src/types.ts | 38 ++ .../command-palette/src/useCommandPalette.ts | 68 +++ packages/command-palette/tsconfig.json | 11 + packages/command-palette/vitest.config.ts | 8 + pnpm-lock.yaml | 27 + 23 files changed, 2964 insertions(+), 11 deletions(-) create mode 100644 packages/ai-ui/src/AgentTimeline.tsx create mode 100644 packages/ai-ui/src/CitationChip.tsx create mode 100644 packages/ai-ui/src/ModelPicker.tsx create mode 100644 packages/ai-ui/src/ToolCallCard.tsx create mode 100644 packages/ai-ui/src/ToolPalette.tsx create mode 100644 packages/ai-ui/src/__tests__/v02-v04.test.tsx create mode 100644 packages/ai-ui/src/useToolCalls.ts create mode 100644 packages/command-palette/package.json create mode 100644 packages/command-palette/src/CommandPalette.tsx create mode 100644 packages/command-palette/src/__tests__/command-palette.test.tsx create mode 100644 packages/command-palette/src/fuzzy.ts create mode 100644 packages/command-palette/src/index.ts create mode 100644 packages/command-palette/src/registry.tsx create mode 100644 packages/command-palette/src/types.ts create mode 100644 packages/command-palette/src/useCommandPalette.ts create mode 100644 packages/command-palette/tsconfig.json create mode 100644 packages/command-palette/vitest.config.ts diff --git a/.gitea/workflows/size-limit.yml b/.gitea/workflows/size-limit.yml index f692d0a6..4ed0c84e 100644 --- a/.gitea/workflows/size-limit.yml +++ b/.gitea/workflows/size-limit.yml @@ -60,6 +60,7 @@ jobs: --filter @bytelyst/react-auth \ --filter @bytelyst/dashboard-shell \ --filter @bytelyst/ai-ui \ + --filter @bytelyst/command-palette \ run build - name: Enforce size budgets diff --git a/.size-limit.cjs b/.size-limit.cjs index bd3705c0..1bc70f2c 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -72,4 +72,11 @@ module.exports = [ limit: '35 KB', gzip: true, }, + // ── Command palette (15 KB — fuzzy + dialog + registry) ───────── + { + name: '@bytelyst/command-palette', + path: 'packages/command-palette/dist/index.js', + limit: '15 KB', + gzip: true, + }, ]; diff --git a/packages/ai-ui/package.json b/packages/ai-ui/package.json index 60371c8b..f2a6d963 100644 --- a/packages/ai-ui/package.json +++ b/packages/ai-ui/package.json @@ -1,6 +1,6 @@ { "name": "@bytelyst/ai-ui", - "version": "0.1.0", + "version": "0.4.0", "type": "module", "description": "AI-native UI primitives — ChatStream, MessageBubble, PromptComposer, useChat. Transport-agnostic; adopts Vercel AI SDK hook shape.", "exports": { diff --git a/packages/ai-ui/src/AgentTimeline.tsx b/packages/ai-ui/src/AgentTimeline.tsx new file mode 100644 index 00000000..913d80a7 --- /dev/null +++ b/packages/ai-ui/src/AgentTimeline.tsx @@ -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; +} + +/** + * `` — 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 `` + * so the rendering stays consistent across the catalog. + */ +export function AgentTimeline({ steps, className, style }: AgentTimelineProps) { + return ( +
    + {steps.map((step, idx) => ( + + ))} +
+ ); +} + +function Step({ step, isLast }: { step: AgentStep; isLast: boolean }) { + const palette = STEP_PALETTE[step.kind]; + return ( +
  • + {/* Marker rail */} +
    + + {!isLast && ( + + )} +
    + + {/* Body */} +
    +
    + {step.label ?? STEP_LABELS[step.kind]} +
    + {step.kind === 'tool_call' && step.invocation ? ( + + ) : ( +
    + {step.content} +
    + )} +
    +
  • + ); +} + +const STEP_LABELS: Record = { + thought: 'Thought', + tool_call: 'Action', + observation: 'Observation', + response: 'Response', + custom: 'Step', +}; + +const STEP_PALETTE: Record = { + 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)', + }, +}; diff --git a/packages/ai-ui/src/CitationChip.tsx b/packages/ai-ui/src/CitationChip.tsx new file mode 100644 index 00000000..1ccff113 --- /dev/null +++ b/packages/ai-ui/src/CitationChip.tsx @@ -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 ( + setHover(true)} + onMouseLeave={() => setHover(false)} + > + 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} + + + {open && ( + + {citation.source && ( +
    + {citation.source} +
    + )} +
    {citation.title}
    + {citation.snippet && ( +
    + {citation.snippet} +
    + )} + {citation.url && ( +
    + {truncateUrl(citation.url)} +
    + )} +
    + )} +
    + ); +} + +function truncateUrl(url: string, max = 56): string { + return url.length <= max ? url : `${url.slice(0, max - 1)}…`; +} diff --git a/packages/ai-ui/src/ModelPicker.tsx b/packages/ai-ui/src/ModelPicker.tsx new file mode 100644 index 00000000..ed51d228 --- /dev/null +++ b/packages/ai-ui/src/ModelPicker.tsx @@ -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; +} + +/** + * `` — 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(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 ( +
    + + + {open && ( +
    + {models.map(m => ( + + ))} +
    + )} +
    + ); +} diff --git a/packages/ai-ui/src/ToolCallCard.tsx b/packages/ai-ui/src/ToolCallCard.tsx new file mode 100644 index 00000000..9e952b4d --- /dev/null +++ b/packages/ai-ui/src/ToolCallCard.tsx @@ -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 ( +
    + + + {open && ( +
    + {input !== undefined && ( +
    +
    {stringifyShort(input)}
    +
    + )} + {status === 'error' ? ( +
    +
    {error ?? 'Tool call failed.'}
    +
    + ) : output !== undefined ? ( +
    + {renderOutput ? renderOutput(output) :
    {stringifyShort(output)}
    } +
    + ) : null} +
    + )} +
    + ); +} + +function StatusPill({ status }: { status: ToolCallStatus }) { + const map: Record = { + 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 ( + + {v.label} + + ); +} + +function Section({ + label, + tone, + children, +}: { + label: string; + tone?: 'danger'; + children: ReactNode; +}) { + return ( +
    +
    + {label} +
    + {children} +
    + ); +} + +function Pre({ children }: { children: ReactNode }) { + return ( +
    +      {children}
    +    
    + ); +} + +function stringifyShort(value: unknown): string { + if (typeof value === 'string') return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} diff --git a/packages/ai-ui/src/ToolPalette.tsx b/packages/ai-ui/src/ToolPalette.tsx new file mode 100644 index 00000000..6442fe26 --- /dev/null +++ b/packages/ai-ui/src/ToolPalette.tsx @@ -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; + /** 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; + /** 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 => { + 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 []; +}; + +/** + * `` — searchable list of tools an LLM can invoke. + * Powers the "slash-command" menu in `` (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( + Array.isArray(source) ? source : null, + ); + const [loading, setLoading] = useState(typeof source === 'string'); + const [error, setError] = useState(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 ( +
    + {loading && Discovering tools…} + {error && {error}} + {!loading && !error && filtered.length === 0 && No tools match.} + + {filtered.map(tool => ( + + ))} +
    + ); +} + +function Status({ + children, + tone, +}: { + children: React.ReactNode; + tone?: 'danger'; +}) { + return ( +
    + {children} +
    + ); +} diff --git a/packages/ai-ui/src/__tests__/v02-v04.test.tsx b/packages/ai-ui/src/__tests__/v02-v04.test.tsx new file mode 100644 index 00000000..f3795a72 --- /dev/null +++ b/packages/ai-ui/src/__tests__/v02-v04.test.tsx @@ -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(); + 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(); + 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( + , + ); + expect(screen.getByTestId('bl-tool-call-status').textContent).toBe('✕'); + expect(screen.getByText('rate limited')).toBeDefined(); + }); + + it('honors renderOutput override', () => { + render( +
    custom
    } + />, + ); + expect(screen.getByTestId('custom-out').textContent).toBe('custom'); + }); +}); + +// ─── CitationChip ───────────────────────────────────────────────────────── + +describe('CitationChip', () => { + beforeEach(() => cleanup()); + + it('renders the citation index by default', () => { + render( + , + ); + 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( + , + ); + // 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(); + const el = screen.getByTestId('bl-citation-c3'); + expect(el.tagName.toLowerCase()).toBe('span'); + }); + + it('respects noPreview', () => { + render( + , + ); + 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( + , + ); + 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( + , + ); + 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( {}} />); + expect(screen.getByTestId('bl-model-picker-trigger').textContent).toContain('GPT-4o'); + }); + + it('opens listbox on trigger click and lists all models', () => { + render( {}} />); + 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(); + 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(); + 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( {}} />); + 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( {}} 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( {}} 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( + {}} + />, + ); + 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( + {}} + />, + ); + await waitFor(() => + expect(screen.getByTestId('bl-tool-palette-status').textContent).toMatch( + /unreachable/i, + ), + ); + }); + + it('selects on click', () => { + const onSelect = vi.fn(); + render(); + 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(); + fireEvent.click(screen.getByTestId('bl-tool-palette-option-image_gen')); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ai-ui/src/index.ts b/packages/ai-ui/src/index.ts index 407a465a..4d02fa0f 100644 --- a/packages/ai-ui/src/index.ts +++ b/packages/ai-ui/src/index.ts @@ -1,17 +1,21 @@ /** * @bytelyst/ai-ui — AI-native UI primitives. * - * MVP exports (Wave 2 v0.1.0): - * - — composed chat surface (the 80% path) - * - — single message atom - * - — multi-line input with submit-on-Enter - * - useChat() — Vercel AI SDK-shaped hook - * - streamText() — low-level SSE/text/data stream consumer + * Exports (Wave 2 — 0.4.0): + * Components + * 0.1: , , + * 0.2: , + * 0.3: , + * 0.4: (MCP-style discovery) + * Hooks + * 0.1: useChat() + * 0.2: useToolCalls() + * Utilities + * 0.1: streamText() * - * Coming in 0.2.x (per ROADMAP §Wave 2): - * - , , - * - , (MCP discovery) - * - useToolCalls(), usePromptHistory(), useTokenCount() + * Coming in 0.5.x (per ROADMAP §Wave 2): + * - , , + * - usePromptHistory(), useTokenCount() */ export { ChatStream } from './ChatStream.js'; @@ -26,11 +30,35 @@ export type { PromptComposerHandle, PromptComposerProps } from './PromptComposer export { useChat } from './useChat.js'; export { streamText } from './useStreamingText.js'; +// ── 0.2 surfaces ─────────────────────────────────────────────────────────── +export { ToolCallCard } from './ToolCallCard.js'; +export type { ToolCallCardProps } from './ToolCallCard.js'; + +export { CitationChip } from './CitationChip.js'; +export type { CitationChipProps } from './CitationChip.js'; + +export { useToolCalls } from './useToolCalls.js'; +export type { UseToolCallsHelpers } from './useToolCalls.js'; + +// ── 0.3 surfaces ─────────────────────────────────────────────────────────── +export { AgentTimeline } from './AgentTimeline.js'; +export type { AgentStep, AgentStepKind, AgentTimelineProps } from './AgentTimeline.js'; + +export { ModelPicker } from './ModelPicker.js'; +export type { ModelCapability, ModelOption, ModelPickerProps } from './ModelPicker.js'; + +// ── 0.4 surfaces ─────────────────────────────────────────────────────────── +export { ToolPalette } from './ToolPalette.js'; +export type { ToolDescriptor, ToolPaletteProps } from './ToolPalette.js'; + export type { ChatTransportOptions, + Citation, Message, MessageRole, StreamProtocol, + ToolCallStatus, + ToolInvocation, UseChatHelpers, UseChatOptions, } from './types.js'; diff --git a/packages/ai-ui/src/types.ts b/packages/ai-ui/src/types.ts index 7b08c903..988b33ad 100644 --- a/packages/ai-ui/src/types.ts +++ b/packages/ai-ui/src/types.ts @@ -11,6 +11,36 @@ export type MessageRole = 'system' | 'user' | 'assistant' | 'tool'; +export type ToolCallStatus = 'pending' | 'streaming' | 'success' | 'error'; + +export interface ToolInvocation { + /** Stable id — typically the tool-call id emitted by the LLM. */ + id: string; + /** Tool name (e.g. 'web_search', 'calculator'). */ + name: string; + status: ToolCallStatus; + /** Tool input — already-parsed JSON (no wire framing). */ + input?: unknown; + /** Tool output — already-parsed JSON, or a raw string. */ + output?: unknown; + /** Error message when status === 'error'. */ + error?: string; + /** Total wall-clock duration in ms once the call settles. */ + durationMs?: number; +} + +export interface Citation { + /** Stable id used for deduplication and React keys. */ + id: string; + /** 1-based index displayed in the chip. */ + index: number; + title: string; + url?: string; + snippet?: string; + /** Optional source label (e.g. 'arxiv', 'wikipedia'). */ + source?: string; +} + export interface Message { /** Stable id — used as React key and for deduplication. */ id: string; @@ -21,6 +51,10 @@ export interface Message { createdAt?: Date; /** When true, indicates content is still streaming in (assistant only). */ isStreaming?: boolean; + /** Tool calls associated with this assistant turn. Added in 0.2.0. */ + toolInvocations?: ToolInvocation[]; + /** Inline citations referenced by [^n] markers in `content`. Added in 0.2.0. */ + citations?: Citation[]; } /** diff --git a/packages/ai-ui/src/useToolCalls.ts b/packages/ai-ui/src/useToolCalls.ts new file mode 100644 index 00000000..1331074f --- /dev/null +++ b/packages/ai-ui/src/useToolCalls.ts @@ -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; + /** Ordered list of invocations (insertion order). */ + ordered: ToolInvocation[]; + /** Begin a new tool call. Idempotent — repeated calls update in place. */ + begin: ( + init: Pick & Partial>, + ) => void; + /** Merge a partial update into an existing invocation. */ + update: (id: string, patch: Partial) => 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 (`` with MCP discovery) will layer auto-wiring on top + * of this hook. + */ +export function useToolCalls(initial: ToolInvocation[] = []): UseToolCallsHelpers { + const [invocations, setInvocations] = useState>( + () => Object.fromEntries(initial.map(t => [t.id, t])), + ); + const [order, setOrder] = useState(() => initial.map(t => t.id)); + + const begin = useCallback(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((id, patch) => { + setInvocations(prev => { + const existing = prev[id]; + if (!existing) return prev; + return { ...prev, [id]: { ...existing, ...patch } }; + }); + }, []); + + const settle = useCallback((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(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 }; +} diff --git a/packages/command-palette/package.json b/packages/command-palette/package.json new file mode 100644 index 00000000..fca86fbe --- /dev/null +++ b/packages/command-palette/package.json @@ -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" + } +} diff --git a/packages/command-palette/src/CommandPalette.tsx b/packages/command-palette/src/CommandPalette.tsx new file mode 100644 index 00000000..292ba65b --- /dev/null +++ b/packages/command-palette/src/CommandPalette.tsx @@ -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; +} + +/** + * `` — 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(initialMode); + const [query, setQuery] = useState(''); + const [selected, setSelected] = useState(0); + const inputRef = useRef(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) => { + 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 ( +
    { + // Close on backdrop click — but never when the inner panel is clicked. + if (e.target === e.currentTarget) onClose(); + }} + > +
    + {/* Search input */} +
    + + ⌘ + + { + 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 && } +
    + + {/* Body */} +
    + {mode === 'ask-ai' ? ( + askAiPanel ? ( + askAiPanel(query) + ) : ( + + ) + ) : filtered.length === 0 ? ( + + ) : ( +
      + {filtered.map((row, i) => ( + activate(row.command)} + onHover={() => setSelected(i)} + /> + ))} +
    + )} +
    + +
    +
    +
    + ); +} + +// ─── 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 ( +
    + {tabs.map(t => { + const active = t.id === mode; + return ( + + ); + })} +
    + ); +} + +function CommandRow({ + command, + selected, + onSelect, + onHover, +}: { + command: Command; + selected: boolean; + onSelect: () => void; + onHover: () => void; +}) { + const disabled = command.enabled === false; + return ( +
  • { + 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 && ( + + {command.icon} + + )} + +
    {command.label}
    + {command.description && ( +
    + {command.description} +
    + )} +
    + {command.shortcut && ( + + {command.shortcut.map(k => ( + + {k} + + ))} + + )} +
  • + ); +} + +function EmptyState({ query, mode }: { query: string; mode: CommandMode }) { + return ( +
    + {query + ? `No ${mode === 'navigate' ? 'navigation targets' : 'commands'} match "${query}".` + : `No ${mode === 'navigate' ? 'navigation targets' : 'commands'} registered.`} +
    + ); +} + +function DefaultAskAiPanel({ query }: { query: string }) { + return ( +
    +
    + ✨ +
    +
    + {query ? `Ask AI: "${query}"` : 'Type a question to ask AI'} +
    +
    + Provide an askAiPanel prop to wire this to{' '} + @bytelyst/ai-ui. +
    +
    + ); +} + +function Footer({ mode, hits }: { mode: CommandMode; hits: number }) { + return ( +
    + + ↑↓ navigate + + + activate + + + Tab switch mode + + + Esc close + + {mode !== 'ask-ai' && ( + {hits} result{hits === 1 ? '' : 's'} + )} +
    + ); +} + +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(() => { + 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], + ); +} diff --git a/packages/command-palette/src/__tests__/command-palette.test.tsx b/packages/command-palette/src/__tests__/command-palette.test.tsx new file mode 100644 index 00000000..84e1a5bf --- /dev/null +++ b/packages/command-palette/src/__tests__/command-palette.test.tsx @@ -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 }) => ( + {children} + ); + +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).mockReset(); + (seed[3].run as ReturnType).mockReset(); + }); + + it('does not render when open=false', () => { + render( + + {}} /> + , + ); + expect(screen.queryByTestId('bl-cmdk')).toBeNull(); + }); + + it('renders with default actions tab + input focused', () => { + render( + + {}} /> + , + ); + 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( + + {}} /> + , + ); + expect(screen.queryByTestId('bl-cmdk-item-gated')).toBeNull(); + }); + + it('Tab cycles modes; navigate tab shows only navigate-mode commands', () => { + render( + + {}} /> + , + ); + 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( + + {}} /> + , + ); + 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( + + + , + ); + fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Enter' }); + expect((seed[0].run as ReturnType)).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalled(); + }); + + it('navigate-mode Enter calls onNavigate with href', () => { + const onNavigate = vi.fn(); + const onClose = vi.fn(); + render( + + + , + ); + 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( + + {}} /> + , + ); + 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( + + + , + ); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('Ask-AI tab renders default panel; custom askAiPanel takes precedence', () => { + const { rerender } = render( + + {}} initialMode="ask-ai" /> + , + ); + expect(screen.getByTestId('bl-cmdk-ask-ai')).toBeDefined(); + + rerender( + + {}} + initialMode="ask-ai" + askAiPanel={q =>
    {q || 'idle'}
    } + /> +
    , + ); + expect(screen.getByTestId('custom-ai').textContent).toBe('idle'); + }); + + it('disabled commands are not activatable by click', () => { + render( + + {}} /> + , + ); + const row = screen.getByTestId('bl-cmdk-item-archive'); + expect(row.getAttribute('aria-disabled')).toBe('true'); + fireEvent.click(row); + expect((seed[3].run as ReturnType)).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 = {}; + 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( + + {}} /> + , + ); + 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); + }); +}); diff --git a/packages/command-palette/src/fuzzy.ts b/packages/command-palette/src/fuzzy.ts new file mode 100644 index 00000000..1f16c1d6 --- /dev/null +++ b/packages/command-palette/src/fuzzy.ts @@ -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; +} diff --git a/packages/command-palette/src/index.ts b/packages/command-palette/src/index.ts new file mode 100644 index 00000000..a7937645 --- /dev/null +++ b/packages/command-palette/src/index.ts @@ -0,0 +1,38 @@ +/** + * @bytelyst/command-palette — Cmd-K palette for ByteLyst products. + * + * Exports (Wave 3 — 0.1.0): + * Components + * — wrap your app once + * — 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'; diff --git a/packages/command-palette/src/registry.tsx b/packages/command-palette/src/registry.tsx new file mode 100644 index 00000000..37efa456 --- /dev/null +++ b/packages/command-palette/src/registry.tsx @@ -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; + register: (commands: Command[]) => () => void; + unregister: (ids: string[]) => void; + clear: () => void; +} + +const RegistryContext = createContext(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 ``) 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>(() => { + const m = new Map(); + for (const c of initial) m.set(c.id, c); + return m; + }); + + const register = useCallback(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(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( + () => ({ commands, register, unregister, clear }), + [commands, register, unregister, clear], + ); + + return {children}; +} + +function useRegistry(): RegistryState { + const ctx = useContext(RegistryContext); + if (!ctx) { + throw new Error( + '@bytelyst/command-palette: useRegisterCommands / useCommands must be used within ', + ); + } + 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(); +} diff --git a/packages/command-palette/src/types.ts b/packages/command-palette/src/types.ts new file mode 100644 index 00000000..ae9b6637 --- /dev/null +++ b/packages/command-palette/src/types.ts @@ -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; + /** 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; +} diff --git a/packages/command-palette/src/useCommandPalette.ts b/packages/command-palette/src/useCommandPalette.ts new file mode 100644 index 00000000..f739287a --- /dev/null +++ b/packages/command-palette/src/useCommandPalette.ts @@ -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 `` and (optionally) + * binds a global hotkey. Drop it next to your app shell: + * + * ```tsx + * const cmdk = useCommandPalette(); + * return ( + * <> + * + * + * + * ); + * ``` + * + * 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 }; +} diff --git a/packages/command-palette/tsconfig.json b/packages/command-palette/tsconfig.json new file mode 100644 index 00000000..4447784f --- /dev/null +++ b/packages/command-palette/tsconfig.json @@ -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"] +} diff --git a/packages/command-palette/vitest.config.ts b/packages/command-palette/vitest.config.ts new file mode 100644 index 00000000..cf326865 --- /dev/null +++ b/packages/command-palette/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'happy-dom', + pool: 'forks', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f437b3b6..2a2fee91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -459,6 +459,33 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/command-palette: + devDependencies: + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + happy-dom: + specifier: ^18.0.1 + version: 18.0.1 + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/config: dependencies: zod: