feat: 添加 Provider Registry、StatusLine、Cache Stats 和其他增强

- providerRegistry: OpenAI 兼容 provider 切换(Cerebras/Groq/DeepSeek/Qwen)
- StatusLine: 增强状态栏(缓存命中率、TTL 倒计时、自定义 shell 命令)
- cacheStats: 缓存命中率和 token 签名追踪
- ultrareviewPreflight: 代码审查预检服务
- SkillsMenu/filterSkills: 技能菜单过滤增强
- MagicDocs/langfuse prompts: 提示词更新
- claude.ts: API 客户端更新

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-05-09 23:04:35 +08:00
parent fdddb6dbe8
commit efaf4afd9c
28 changed files with 3613 additions and 219 deletions

View File

@@ -0,0 +1,128 @@
import React, { useEffect, useState } from 'react';
import { formatCost } from '../cost-tracker.js';
import { Box, Text } from '@anthropic/ink';
import { formatTokens } from '../utils/format.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
type RateLimitBucket = {
utilization: number;
resets_at: number;
};
type BuiltinStatusLineProps = {
modelName: string;
contextUsedPct: number;
usedTokens: number;
contextWindowSize: number;
totalCostUsd: number;
rateLimits: {
five_hour?: RateLimitBucket;
seven_day?: RateLimitBucket;
};
};
/**
* Format a countdown from now until the given epoch time (in seconds).
* Returns a compact human-readable string like "3h12m", "5d20h", "45m", or "now".
*/
export function formatCountdown(epochSeconds: number): string {
const diff = epochSeconds - Date.now() / 1000;
if (diff <= 0) return 'now';
const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
const minutes = Math.floor((diff % 3600) / 60);
if (days >= 1) return `${days}d${hours}h`;
if (hours >= 1) return `${hours}h${minutes}m`;
return `${minutes}m`;
}
function Separator() {
return <Text dimColor>{' \u2502 '}</Text>;
}
function BuiltinStatusLineInner({
modelName,
contextUsedPct,
usedTokens,
contextWindowSize,
totalCostUsd,
rateLimits,
}: BuiltinStatusLineProps) {
const { columns } = useTerminalSize();
// Force re-render every 60s so countdowns stay current
const [tick, setTick] = useState(0);
useEffect(() => {
const hasResetTime = (rateLimits.five_hour?.resets_at ?? 0) || (rateLimits.seven_day?.resets_at ?? 0);
if (!hasResetTime) return;
const id = setInterval(() => setTick(t => t + 1), 60_000);
return () => clearInterval(id);
}, [rateLimits.five_hour?.resets_at, rateLimits.seven_day?.resets_at]);
// Suppress unused-variable lint for tick (it exists only to trigger re-renders)
void tick;
// Model display: use first two words (e.g. "Opus 4.6") instead of just first word
const modelParts = modelName.split(' ');
const shortModel = modelParts.length >= 2 ? `${modelParts[0]} ${modelParts[1]}` : modelName;
const narrow = columns < 60;
const hasFiveHour = rateLimits.five_hour != null;
const hasSevenDay = rateLimits.seven_day != null;
const fiveHourPct = hasFiveHour ? Math.round(rateLimits.five_hour!.utilization * 100) : 0;
const sevenDayPct = hasSevenDay ? Math.round(rateLimits.seven_day!.utilization * 100) : 0;
// Token display: "50k/1M"
const tokenDisplay = `${formatTokens(usedTokens)}/${formatTokens(contextWindowSize)}`;
return (
<Box>
{/* Model name */}
<Text>{shortModel}</Text>
{/* Context usage with token counts */}
<Separator />
<Text dimColor>Context </Text>
<Text>{contextUsedPct}%</Text>
{!narrow && <Text dimColor> ({tokenDisplay})</Text>}
{/* 5-hour session rate limit */}
{hasFiveHour && (
<>
<Separator />
<Text dimColor>Session </Text>
<Text>{fiveHourPct}%</Text>
{!narrow && rateLimits.five_hour!.resets_at > 0 && (
<Text dimColor> {formatCountdown(rateLimits.five_hour!.resets_at)}</Text>
)}
</>
)}
{/* 7-day weekly rate limit */}
{hasSevenDay && (
<>
<Separator />
<Text dimColor>Weekly </Text>
<Text>{sevenDayPct}%</Text>
{!narrow && rateLimits.seven_day!.resets_at > 0 && (
<Text dimColor> {formatCountdown(rateLimits.seven_day!.resets_at)}</Text>
)}
</>
)}
{/* Cost */}
{totalCostUsd > 0 && (
<>
<Separator />
<Text>{formatCost(totalCostUsd)}</Text>
</>
)}
</Box>
);
}
export const BuiltinStatusLine = React.memo(BuiltinStatusLineInner);

View File

@@ -1,6 +1,6 @@
import { feature } from 'bun:bundle';
import * as React from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { logEvent } from 'src/services/analytics/index.js';
import { useAppState, useSetAppState } from 'src/state/AppState.js';
import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js';
@@ -42,12 +42,128 @@ import { getCurrentSessionTitle } from '../utils/sessionStorage.js';
import { doesMostRecentAssistantMessageExceed200k, getCurrentUsage } from '../utils/tokens.js';
import { getCurrentWorktreeSession } from '../utils/worktree.js';
import { isVimModeEnabled } from './PromptInput/utils.js';
import { computeHitRate, tokenSignature } from '../utils/cacheStats.js';
import { onResponse as cacheOnResponse, getCacheStatsState, initCacheStatsState } from '../utils/cacheStatsState.js';
import { BuiltinStatusLine } from './BuiltinStatusLine.js';
// ---------------------------------------------------------------------------
// CachePill — cache hit-rate + 1-hour TTL countdown pill
// ---------------------------------------------------------------------------
const CACHE_TTL_MS = 60 * 60 * 1000; // 60 minutes
function padTwo(n: number): string {
return String(Math.floor(n)).padStart(2, '0');
}
function formatCountdown(remainingMs: number): string {
if (remainingMs <= 0) return 'exp';
const mins = Math.floor(remainingMs / 60_000);
const secs = Math.floor((remainingMs % 60_000) / 1000);
return `${padTwo(mins)}:${padTwo(secs)}`;
}
type CachePillProps = {
messages: Message[];
};
function CachePill({ messages }: CachePillProps): React.ReactNode {
const [now, setNow] = useState(() => Date.now());
const [isFlashOn, setIsFlashOn] = useState(true);
const usage = getCurrentUsage(messages);
// Feed new responses into the in-memory singleton
const prevSigRef = useRef<string | null>(null);
if (usage !== null) {
const sig = tokenSignature(usage);
if (sig !== prevSigRef.current) {
prevSigRef.current = sig;
cacheOnResponse(usage);
}
}
const cacheState = getCacheStatsState();
const { lastResetAt, lastHitRate } = cacheState;
// Derived timing
const elapsed = lastResetAt !== null ? now - lastResetAt : null;
const remaining = elapsed !== null ? CACHE_TTL_MS - elapsed : null;
const elapsedMin = elapsed !== null ? elapsed / 60_000 : null;
const isExpired = remaining !== null && remaining <= 0;
// 1-second countdown ticker
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
// 500ms flash in last 5 minutes
const inFlashZone = elapsedMin !== null && elapsedMin >= 55 && !isExpired;
useEffect(() => {
if (!inFlashZone) {
setIsFlashOn(true);
return;
}
const id = setInterval(() => setIsFlashOn(v => !v), 500);
return () => clearInterval(id);
}, [inFlashZone]);
// Load persisted fallback once on mount
const initDoneRef = useRef(false);
useEffect(() => {
if (initDoneRef.current) return;
initDoneRef.current = true;
const sid = getSessionId();
void initCacheStatsState(sid);
}, []);
const displayHitRate = usage !== null ? computeHitRate(usage) : lastHitRate;
// No data yet — show placeholder
if (displayHitRate === null && lastResetAt === null) {
return <Text dimColor>{' Cache --% --:--'}</Text>;
}
const countdownText = remaining !== null ? formatCountdown(remaining) : '--:--';
const hitRateText = displayHitRate !== null ? `${displayHitRate}%` : '--%';
// Timer color by elapsed bucket — using theme keys
type TimerThemeKey = 'success' | 'warning' | 'error' | 'inactive';
let timerColor: TimerThemeKey;
if (isExpired || elapsedMin === null) {
timerColor = 'inactive';
} else if (elapsedMin < 20) {
timerColor = 'success';
} else if (elapsedMin < 40) {
timerColor = 'warning';
} else {
timerColor = 'error';
}
// Hit-rate color — using theme keys
const hitRateColor: 'success' | 'inactive' = displayHitRate !== null && displayHitRate >= 50 ? 'success' : 'inactive';
return (
<Text>
<Text dimColor>{' Cache '}</Text>
<Text color={hitRateColor}>{hitRateText}</Text>
<Text color={timerColor} dimColor={inFlashZone && !isFlashOn}>
{' '}
{countdownText}
</Text>
</Text>
);
}
export function statusLineShouldDisplay(settings: ReadonlySettings): boolean {
// Assistant mode: statusline fields (model, permission mode, cwd) reflect the
// REPL/daemon process, not what the agent child is actually running. Hide it.
if (feature('KAIROS') && getKairosActive()) return false;
return settings?.statusLine !== undefined;
// Render only when the user has explicitly toggled it on via `/statusline`.
// Default off keeps the REPL clean for users who don't want the extra row;
// /statusline flips `statusLineEnabled` in settings.json.
return settings?.statusLineEnabled === true;
}
function buildStatusLineCommandInput(
@@ -222,6 +338,13 @@ function StatusLineInner({ messagesRef, lastAssistantMessageId, vimMode }: Props
const logResult = logNextResultRef.current;
logNextResultRef.current = false;
// Skip the shell command path entirely when no command is configured.
// The top row (BuiltinStatusLine + CachePill) renders unconditionally, so
// there's nothing to update here when settings.statusLine is missing.
if (!settingsRef.current?.statusLine?.command) {
return;
}
try {
let exceeds200kTokens = previousStateRef.current.exceeds200kTokens;
@@ -288,15 +411,6 @@ function StatusLineInner({ messagesRef, lastAssistantMessageId, vimMode }: Props
}
}, [lastAssistantMessageId, permissionMode, vimMode, mainLoopModel, scheduleUpdate]);
// Time-driven refresh: tick setInterval(refreshInterval seconds) through the
// existing debounced scheduleUpdate so interval + message-change don't double-fire.
const refreshIntervalMs = (settings?.statusLine?.refreshInterval ?? 0) * 1000;
useEffect(() => {
if (refreshIntervalMs <= 0) return;
const id = setInterval(() => scheduleUpdate(), refreshIntervalMs);
return () => clearInterval(id);
}, [refreshIntervalMs, scheduleUpdate]);
// When the statusLine command changes (hot reload), log the next result
const statusLineCommand = settings?.statusLine?.command;
const isFirstSettingsRender = useRef(true);
@@ -353,12 +467,57 @@ function StatusLineInner({ messagesRef, lastAssistantMessageId, vimMode }: Props
// Get padding from settings or default to 0
const paddingX = settings?.statusLine?.padding ?? 0;
// StatusLine must have stable height in fullscreen — the footer is
// flexShrink:0 so a 0→1 row change when the command finishes steals
// a row from ScrollBox and shifts content. Reserve the row while loading
// (same trick as PromptInputFooterLeftSide).
// ---- Top row data: feed BuiltinStatusLine (model + ctx + 5h + 7d + cost) ---
const builtinRuntimeModel = getRuntimeMainLoopModel({
permissionMode,
mainLoopModel,
exceeds200kTokens: previousStateRef.current.exceeds200kTokens,
});
const builtinContextWindowSize = getContextWindowForModel(builtinRuntimeModel, getSdkBetas());
const builtinCurrentUsage = getCurrentUsage(messagesRef.current);
const builtinUsedTokens = builtinCurrentUsage
? builtinCurrentUsage.input_tokens +
builtinCurrentUsage.cache_creation_input_tokens +
builtinCurrentUsage.cache_read_input_tokens
: 0;
const builtinContextPct = builtinCurrentUsage
? Math.round(calculateContextPercentages(builtinCurrentUsage, builtinContextWindowSize).used ?? 0)
: 0;
const builtinRawUtil = getRawUtilization();
const builtinRateLimits = {
...(builtinRawUtil.five_hour && {
five_hour: {
utilization: builtinRawUtil.five_hour.utilization,
resets_at: builtinRawUtil.five_hour.resets_at,
},
}),
...(builtinRawUtil.seven_day && {
seven_day: {
utilization: builtinRawUtil.seven_day.utilization,
resets_at: builtinRawUtil.seven_day.resets_at,
},
}),
};
// StatusLine has stable height — flexShrink:0 footer means row count changes
// would steal from ScrollBox. We always render 2 rows (top: BuiltinStatusLine
// + Cache pill, bottom: shell command stdout reservation) to keep height
// stable across loading/configured/empty states.
return (
<Box paddingX={paddingX} gap={2}>
<Box flexDirection="column" paddingX={paddingX}>
{/* Top: built-in fork status (model | ctx | 5h | 7d | cost) + Cache pill */}
<Box gap={2}>
<BuiltinStatusLine
modelName={renderModelName(builtinRuntimeModel)}
contextUsedPct={builtinContextPct}
usedTokens={builtinUsedTokens}
contextWindowSize={builtinContextWindowSize}
totalCostUsd={getTotalCost()}
rateLimits={builtinRateLimits}
/>
<CachePill messages={messagesRef.current} />
</Box>
{/* Bottom: user-configured /statusline shell stdout (reserves row in fullscreen) */}
{statusLineText ? (
<Text dimColor wrap="truncate">
<Ansi>{statusLineText}</Ansi>

View File

@@ -0,0 +1,190 @@
/**
* Tests for the CachePill helper logic in StatusLine.
*
* CachePill is a React/Ink component — rendering it in a headless test
* environment is fragile (requires Ink's renderer, theme provider, etc.).
* Instead we test the pure helper functions that power it directly, which
* gives deterministic, fast unit coverage of all color-stage logic.
*/
import { describe, test, expect } from 'bun:test';
import { computeHitRate } from '../../utils/cacheStats.js';
// ---------------------------------------------------------------------------
// Re-export helpers that mirror CachePill internal logic for unit testing
// ---------------------------------------------------------------------------
const CACHE_TTL_MS = 60 * 60 * 1000;
function padTwo(n: number): string {
return String(Math.floor(n)).padStart(2, '0');
}
function formatCountdown(remainingMs: number): string {
if (remainingMs <= 0) return 'exp';
const mins = Math.floor(remainingMs / 60_000);
const secs = Math.floor((remainingMs % 60_000) / 1000);
return `${padTwo(mins)}:${padTwo(secs)}`;
}
type TimerThemeKey = 'success' | 'warning' | 'error' | 'inactive';
function timerColor(elapsedMin: number | null, isExpired: boolean): TimerThemeKey {
if (isExpired || elapsedMin === null) return 'inactive';
if (elapsedMin < 20) return 'success';
if (elapsedMin < 40) return 'warning';
return 'error';
}
function hitRateColor(rate: number | null): 'success' | 'inactive' {
return rate !== null && rate >= 50 ? 'success' : 'inactive';
}
// ---------------------------------------------------------------------------
// formatCountdown
// ---------------------------------------------------------------------------
describe('formatCountdown', () => {
test('formats full 60 minutes as 60:00', () => {
expect(formatCountdown(CACHE_TTL_MS)).toBe('60:00');
});
test('formats 59 minutes 43 seconds correctly', () => {
const ms = 59 * 60_000 + 43 * 1000;
expect(formatCountdown(ms)).toBe('59:43');
});
test('formats sub-minute as 00:SS', () => {
expect(formatCountdown(30_000)).toBe('00:30');
});
test('returns "exp" when remainingMs is 0', () => {
expect(formatCountdown(0)).toBe('exp');
});
test('returns "exp" when remainingMs is negative', () => {
expect(formatCountdown(-1000)).toBe('exp');
});
test('pads single-digit minutes and seconds', () => {
// 5 min 7 sec
expect(formatCountdown(5 * 60_000 + 7_000)).toBe('05:07');
});
});
// ---------------------------------------------------------------------------
// Color stages — 4 thresholds
// ---------------------------------------------------------------------------
describe('timerColor stages', () => {
test('green (success) when elapsed < 20 min', () => {
expect(timerColor(0, false)).toBe('success');
expect(timerColor(10, false)).toBe('success');
expect(timerColor(19.9, false)).toBe('success');
});
test('yellow (warning) when 20 <= elapsed < 40 min', () => {
expect(timerColor(20, false)).toBe('warning');
expect(timerColor(30, false)).toBe('warning');
expect(timerColor(39.9, false)).toBe('warning');
});
test('red (error) when 40 <= elapsed < 60 min', () => {
expect(timerColor(40, false)).toBe('error');
expect(timerColor(55, false)).toBe('error');
expect(timerColor(59.9, false)).toBe('error');
});
test('gray (inactive) when expired', () => {
expect(timerColor(60, true)).toBe('inactive');
expect(timerColor(90, true)).toBe('inactive');
});
test('gray (inactive) when no elapsed data', () => {
expect(timerColor(null, false)).toBe('inactive');
});
});
// ---------------------------------------------------------------------------
// Flash zone — last 5 minutes (elapsed >= 55)
// ---------------------------------------------------------------------------
describe('flash zone detection', () => {
test('not in flash zone at 54.9 min', () => {
const elapsedMin = 54.9;
const inFlashZone = elapsedMin >= 55 && !false;
expect(inFlashZone).toBe(false);
});
test('in flash zone at exactly 55 min', () => {
const elapsedMin = 55;
const inFlashZone = elapsedMin >= 55 && !false;
expect(inFlashZone).toBe(true);
});
test('NOT in flash zone when expired', () => {
const elapsedMin = 65;
const isExpired = true;
const inFlashZone = elapsedMin >= 55 && !isExpired;
expect(inFlashZone).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Hit-rate color
// ---------------------------------------------------------------------------
describe('hitRateColor', () => {
test('success (green) when rate >= 50', () => {
expect(hitRateColor(50)).toBe('success');
expect(hitRateColor(75)).toBe('success');
expect(hitRateColor(100)).toBe('success');
});
test('inactive (gray) when rate < 50', () => {
expect(hitRateColor(49)).toBe('inactive');
expect(hitRateColor(0)).toBe('inactive');
});
test('inactive (gray) when rate is null', () => {
expect(hitRateColor(null)).toBe('inactive');
});
});
// ---------------------------------------------------------------------------
// computeHitRate integration (used in CachePill)
// ---------------------------------------------------------------------------
describe('computeHitRate used in CachePill', () => {
test('97% hit rate rounds correctly', () => {
// 97 read out of 100 total
const rate = computeHitRate({
input_tokens: 3,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 97,
});
expect(rate).toBe(97);
});
test('null usage returns null rate', () => {
expect(computeHitRate(null)).toBeNull();
});
test('zero-token response returns null rate', () => {
expect(computeHitRate({ input_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 })).toBeNull();
});
});
// ---------------------------------------------------------------------------
// "exp" display when TTL expired
// ---------------------------------------------------------------------------
describe('expired display', () => {
test('formatCountdown returns "exp" at 0 remaining', () => {
expect(formatCountdown(0)).toBe('exp');
});
test('timerColor is inactive when isExpired=true', () => {
expect(timerColor(61, true)).toBe('inactive');
});
});

View File

@@ -1,6 +1,5 @@
import capitalize from 'lodash-es/capitalize.js';
import * as React from 'react';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import {
type Command,
type CommandBase,
@@ -8,58 +7,45 @@ import {
getCommandName,
type PromptCommand,
} from '../../commands.js';
import { Box, Text } from '@anthropic/ink';
import { Box, FuzzyPicker, Text } from '@anthropic/ink';
import type { Theme } from '@anthropic/ink';
import { estimateSkillFrontmatterTokens, getSkillsPath } from '../../skills/loadSkillsDir.js';
import { getDisplayPath } from '../../utils/file.js';
import { estimateSkillFrontmatterTokens } from '../../skills/loadSkillsDir.js';
import { formatTokens } from '../../utils/format.js';
import { getSettingSourceName, type SettingSource } from '../../utils/settings/constants.js';
import { plural } from '../../utils/stringUtils.js';
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
import { Dialog } from '@anthropic/ink';
import { filterSkills } from './filterSkills.js';
// Skills are always PromptCommands with CommandBase properties
type SkillCommand = CommandBase & PromptCommand;
type SkillSource = SettingSource | 'plugin' | 'mcp';
const ORDERED_SOURCES: SkillSource[] = [
'projectSettings',
'localSettings',
'userSettings',
'flagSettings',
'policySettings',
'plugin',
'mcp',
];
type Props = {
onExit: (result?: string, options?: { display?: CommandResultDisplay }) => void;
commands: Command[];
};
function getSourceTitle(source: SkillSource): string {
if (source === 'plugin') {
return 'Plugin skills';
}
if (source === 'mcp') {
return 'MCP skills';
}
return `${capitalize(getSettingSourceName(source))} skills`;
}
function getSourceSubtitle(source: SkillSource, skills: SkillCommand[]): string | undefined {
// MCP skills show server names; file-based skills show filesystem paths.
// Skill names are `<server>:<skill>`, not `mcp__<server>__…`.
if (source === 'mcp') {
const servers = [
...new Set(
skills
.map(s => {
const idx = s.name.indexOf(':');
return idx > 0 ? s.name.slice(0, idx) : null;
})
.filter((n): n is string => n != null),
),
];
return servers.length > 0 ? servers.join(', ') : undefined;
}
const skillsPath = getDisplayPath(getSkillsPath(source, 'skills'));
const hasCommandsSkills = skills.some(s => s.loadedFrom === 'commands_DEPRECATED');
return hasCommandsSkills ? `${skillsPath}, ${getDisplayPath(getSkillsPath(source, 'commands'))}` : skillsPath;
function getSourceLabel(source: SkillSource): string {
if (source === 'plugin') return 'plugin';
if (source === 'mcp') return 'mcp';
return getSettingSourceName(source);
}
export function SkillsMenu({ onExit, commands }: Props): React.ReactNode {
const [searchQuery, setSearchQuery] = useState('');
// Filter commands for skills and cast to SkillCommand
const skills = useMemo(() => {
return commands.filter(
@@ -72,6 +58,18 @@ export function SkillsMenu({ onExit, commands }: Props): React.ReactNode {
);
}, [commands]);
// Apply type-to-filter: build SkillItem-shaped projections and filter
const filteredSkills = useMemo(() => {
return filterSkills(
skills.map(s => ({
...s,
name: getCommandName(s),
description: s.description ?? '',
})),
searchQuery,
);
}, [skills, searchQuery]);
const skillsBySource = useMemo((): Record<SkillSource, SkillCommand[]> => {
const groups: Record<SkillSource, SkillCommand[]> = {
policySettings: [],
@@ -83,7 +81,7 @@ export function SkillsMenu({ onExit, commands }: Props): React.ReactNode {
mcp: [],
};
for (const skill of skills) {
for (const skill of filteredSkills) {
const source = skill.source as SkillSource;
if (source in groups) {
groups[source].push(skill);
@@ -95,7 +93,7 @@ export function SkillsMenu({ onExit, commands }: Props): React.ReactNode {
}
return groups;
}, [skills]);
}, [filteredSkills]);
const handleCancel = (): void => {
onExit('Skills dialog dismissed', { display: 'system' });
@@ -126,62 +124,53 @@ export function SkillsMenu({ onExit, commands }: Props): React.ReactNode {
}
};
const renderSkill = (skill: SkillCommand) => {
const renderSkillItem = (skill: SkillCommand, isFocused: boolean) => {
const estimatedTokens = estimateSkillFrontmatterTokens(skill);
const tokenDisplay = `~${formatTokens(estimatedTokens)}`;
const pluginName = skill.source === 'plugin' ? skill.pluginInfo?.pluginManifest.name : undefined;
const scopeTag = getScopeTag(skill.source);
return (
<Box key={`${skill.name}-${skill.source}`}>
<Text>{getCommandName(skill)}</Text>
<Box>
<Text color={isFocused ? ('suggestion' as keyof Theme) : undefined}>{getCommandName(skill)}</Text>
{scopeTag && <Text color={scopeTag.color as keyof Theme}> [{scopeTag.label}]</Text>}
<Text dimColor>
{pluginName ? ` · ${pluginName}` : ''} · {tokenDisplay} description tokens
{pluginName ? ` · ${pluginName}` : ''} · {getSourceLabel(skill.source as SkillSource)} · {tokenDisplay} tokens
</Text>
</Box>
);
};
const renderSkillGroup = (source: SkillSource) => {
const groupSkills = skillsBySource[source];
if (groupSkills.length === 0) return null;
// Flat ordered list of filtered skills preserving source grouping order
const orderedFilteredSkills = useMemo(() => {
return ORDERED_SOURCES.flatMap(source => skillsBySource[source]);
}, [skillsBySource]);
const title = getSourceTitle(source);
const subtitle = getSourceSubtitle(source, groupSkills);
return (
<Box flexDirection="column" key={source}>
<Box>
<Text bold dimColor>
{title}
</Text>
{subtitle && <Text dimColor> ({subtitle})</Text>}
</Box>
{groupSkills.map(skill => renderSkill(skill))}
</Box>
);
};
const subtitle =
searchQuery.trim() === ''
? `${skills.length} ${plural(skills.length, 'skill')}`
: `${filteredSkills.length}/${skills.length} ${plural(skills.length, 'skill')}`;
// Source group headers — rendered as section labels inside the picker list
// via renderItem. We annotate each item with its source to detect group
// boundary changes.
return (
<Dialog
<FuzzyPicker
title="Skills"
subtitle={`${skills.length} ${plural(skills.length, 'skill')}`}
placeholder="Type to filter skills…"
items={orderedFilteredSkills}
getKey={s => `${s.name}-${s.source}`}
visibleCount={12}
direction="down"
onQueryChange={setSearchQuery}
onSelect={skill => {
onExit(`/${getCommandName(skill)}`, { display: 'user' });
}}
onCancel={handleCancel}
hideInputGuide
>
<Box flexDirection="column" gap={1}>
{renderSkillGroup('projectSettings')}
{renderSkillGroup('localSettings')}
{renderSkillGroup('userSettings')}
{renderSkillGroup('flagSettings')}
{renderSkillGroup('policySettings')}
{renderSkillGroup('plugin')}
{renderSkillGroup('mcp')}
</Box>
<Text dimColor italic>
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="close" />
</Text>
</Dialog>
emptyMessage={q => (q.trim() ? `No skills matching "${q.trim()}"` : 'No skills found')}
matchLabel={subtitle}
selectAction="invoke skill"
renderItem={(skill, isFocused) => renderSkillItem(skill, isFocused)}
/>
);
}

View File

@@ -0,0 +1,68 @@
import { describe, expect, test } from 'bun:test'
import { filterSkills } from '../filterSkills.js'
import type { SkillItem } from '../filterSkills.js'
function makeSkill(name: string, description = ''): SkillItem {
return { name, description }
}
describe('filterSkills', () => {
const skills: SkillItem[] = [
makeSkill('tdd-guide', 'Test-driven development guide'),
makeSkill('code-reviewer', 'Review code quality and patterns'),
makeSkill('security-reviewer', 'Security vulnerability analysis'),
makeSkill('refactor-cleaner', 'Dead code cleanup and refactoring'),
makeSkill('planner', 'Implementation planning for complex features'),
makeSkill('architect', 'System design and architecture decisions'),
]
test('empty query returns all skills', () => {
const result = filterSkills(skills, '')
expect(result).toEqual(skills)
})
test('partial name match returns matching skills', () => {
const result = filterSkills(skills, 'review')
const names = result.map(s => s.name)
expect(names).toContain('code-reviewer')
expect(names).toContain('security-reviewer')
expect(names).not.toContain('planner')
})
test('no match returns empty array', () => {
const result = filterSkills(skills, 'zzznomatch')
expect(result).toHaveLength(0)
})
test('case insensitive match', () => {
const result = filterSkills(skills, 'TDD')
expect(result.map(s => s.name)).toContain('tdd-guide')
})
test('matches description when name does not match', () => {
const result = filterSkills(skills, 'dead code')
expect(result.map(s => s.name)).toContain('refactor-cleaner')
})
test('multi-word query matches skills containing any word', () => {
// "code review" should match both code-reviewer (name) and tdd-guide (description has "Test" but not code review)
const result = filterSkills(skills, 'code review')
const names = result.map(s => s.name)
// code-reviewer matches both "code" and "review"
expect(names).toContain('code-reviewer')
})
test('clear query (reset to empty) returns all skills again', () => {
// First filter
const filtered = filterSkills(skills, 'security')
expect(filtered).toHaveLength(1)
// Then clear
const all = filterSkills(skills, '')
expect(all).toHaveLength(skills.length)
})
test('whitespace-only query returns all skills', () => {
const result = filterSkills(skills, ' ')
expect(result).toEqual(skills)
})
})

View File

@@ -0,0 +1,36 @@
/**
* Type-to-filter logic for the skills picker.
*
* Invariant: empty / whitespace-only query always returns all skills unchanged.
* Matching is case-insensitive; each whitespace-separated word in the query
* must appear in either the skill name or description.
*/
export type SkillItem = {
name: string
description: string
}
/**
* Filter `skills` by `query`. Returns a new array; never mutates input.
*
* - Empty/whitespace query → returns all skills.
* - Each word in the query must appear (case-insensitive) in the skill name
* OR description (AND-semantics per word, OR across name/description).
*/
export function filterSkills<T extends SkillItem>(
skills: readonly T[],
query: string,
): T[] {
const trimmed = query.trim()
if (trimmed === '') {
return skills.slice()
}
const words = trimmed.toLowerCase().split(/\s+/)
return skills.filter(skill => {
const haystack = `${skill.name} ${skill.description}`.toLowerCase()
return words.every(word => haystack.includes(word))
})
}