═══════════════════════════════════════════════════════════════════════
@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)
72 lines
1.9 KiB
TypeScript
72 lines
1.9 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|