mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
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:
128
src/components/BuiltinStatusLine.tsx
Normal file
128
src/components/BuiltinStatusLine.tsx
Normal 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);
|
||||
@@ -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>
|
||||
|
||||
190
src/components/__tests__/StatusLine.test.tsx
Normal file
190
src/components/__tests__/StatusLine.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
68
src/components/skills/__tests__/filterSkills.test.ts
Normal file
68
src/components/skills/__tests__/filterSkills.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
36
src/components/skills/filterSkills.ts
Normal file
36
src/components/skills/filterSkills.ts
Normal 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))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user