From 78d6dcc28c6fd7134f301a8442ef881eb3b451dd Mon Sep 17 00:00:00 2001 From: Bap <83458751+fernandezbaptiste@users.noreply.github.com> Date: Thu, 5 Mar 2026 06:35:37 +0000 Subject: [PATCH] feat: Add Skills widget with cross-platform hook framework (#201) * feat: Add Skills widget with cross-platform hook framework Replaces bash/jq hook with a built-in --hook CLI handler that works on all platforms. Widgets can declare hooks via getHooks() which are auto-synced to Claude settings when the statusline config is saved. * feat: tighten hook lifecycle and upgrade skills widget Notes:\n- Hook sync now follows saved-state semantics: installing statusline syncs hooks from saved ccstatusline settings on disk, and uninstall always removes all ccstatusline-managed hooks.\n- syncWidgetHooks always strips managed hooks first, including when statusline.command is missing, then persists cleanup.\n- Skills metrics storage path is now ~/.cache/ccstatusline/skills/skills-.jsonl.\n- Skills unique list ordering is now most-recent-first.\n- Skills widget updates: view toggle on (v), hide-when-empty toggle on (h), explicit empty outputs when not hidden, and new list-only (l)imit editor option where 0 means unlimited (default).\n- Added list-limit custom editor flow and list-only keybind visibility in ItemsEditor.\n- Maintained Node 14 compatibility by avoiding Array.prototype.at in skills metrics parsing.\n- Added/updated regression tests for hook sync/install/uninstall behavior, skills metrics path/order, Skills widget rendering/editing, and items-editor custom keybind handling. * chore: bump version to 2.2.1 and update release notes --------- Co-authored-by: Matthew Breedlove --- README.md | 4 +- package.json | 2 +- src/ccstatusline.ts | 68 ++++++ src/tui/components/ItemsEditor.tsx | 3 + .../__tests__/input-handlers.test.ts | 57 +++++ src/types/RenderContext.ts | 6 +- src/types/SkillsMetrics.ts | 12 + src/types/index.ts | 3 +- src/utils/__tests__/claude-settings.test.ts | 77 +++++++ src/utils/__tests__/hooks.test.ts | 79 +++++++ src/utils/__tests__/skills.test.ts | 59 +++++ src/utils/claude-settings.ts | 36 +++ src/utils/config.ts | 6 + src/utils/hooks.ts | 96 ++++++++ src/utils/skills.ts | 58 +++++ src/utils/widget-manifest.ts | 3 +- src/widgets/Skills.tsx | 205 ++++++++++++++++++ src/widgets/__tests__/Skills.test.ts | 127 +++++++++++ src/widgets/index.ts | 3 +- 19 files changed, 898 insertions(+), 6 deletions(-) create mode 100644 src/types/SkillsMetrics.ts create mode 100644 src/utils/__tests__/hooks.test.ts create mode 100644 src/utils/__tests__/skills.test.ts create mode 100644 src/utils/hooks.ts create mode 100644 src/utils/skills.ts create mode 100644 src/widgets/Skills.tsx create mode 100644 src/widgets/__tests__/Skills.test.ts diff --git a/README.md b/README.md index ac7af5d..5f711d0 100644 --- a/README.md +++ b/README.md @@ -46,12 +46,13 @@ ## 🆕 Recent Updates -### v2.2.0 - New Token Speed widgets with optional windows +### v2.2.0 - v2.2.1 - Token Speed + Skills widget updates - **🚀 New Token Speed widgets** - Added three widgets: **Input Speed**, **Output Speed**, and **Total Speed**. - Each speed widget supports a configurable window of `0-120` seconds in the widget editor (`w` key). - `0` disables window mode and uses a full-session average speed. - `1-120` calculates recent speed over the selected rolling window. +- **🧩 New Skills widget controls (v2.2.1)** - Added configurable Skills modes (last/count/list), optional hide-when-empty behavior, and list-size limiting with most-recent-first ordering. - **🤝 Better subagent-aware speed reporting** - Token speed calculations continue to include referenced subagent activity so displayed speeds better reflect actual concurrent work. ### v2.1.0 - v2.1.10 - Usage widgets, links, new git insertions / deletions widgets, and reliability fixes @@ -459,6 +460,7 @@ bun run example - **Block Reset Timer** - Shows time remaining until current 5-hour block reset window - **Weekly Reset Timer** - Shows time remaining until weekly usage reset - **Context Bar** - Shows context usage as a progress bar with short/full display modes +- **Skills** - Shows skill activity as last used, total count, or unique list (with optional list limit and hide-when-empty toggle) - **Separator** - Visual divider between widgets (available when Powerline mode is off and no default separator is configured) - **Flex Separator** - Expands to fill available space (available when Powerline mode is off) diff --git a/package.json b/package.json index 4e08db6..2989ddd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccstatusline", - "version": "2.2.0", + "version": "2.2.1", "description": "A customizable status line formatter for Claude Code CLI", "module": "src/ccstatusline.ts", "type": "module", diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 0dac483..6fe1c22 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import { runTUI } from './tui'; import type { + SkillsMetrics, SpeedMetrics, TokenMetrics } from './types'; @@ -27,6 +28,10 @@ import { renderStatusLine } from './utils/renderer'; import { advanceGlobalSeparatorIndex } from './utils/separator-index'; +import { + getSkillsFilePath, + getSkillsMetrics +} from './utils/skills'; import { getWidgetSpeedWindowSeconds, isWidgetSpeedWindowEnabled @@ -130,6 +135,11 @@ async function renderMultipleLines(data: StatusJSON) { windowedSpeedMetrics = speedMetricsCollection.windowed; } + let skillsMetrics: SkillsMetrics | null = null; + if (data.session_id) { + skillsMetrics = getSkillsMetrics(data.session_id); + } + // Create render context const context: RenderContext = { data, @@ -138,6 +148,7 @@ async function renderMultipleLines(data: StatusJSON) { windowedSpeedMetrics, usageData, sessionDuration, + skillsMetrics, isPreview: false }; @@ -213,10 +224,67 @@ function parseConfigArg(): string | undefined { return configPath; } +interface HookInput { + session_id?: string; + hook_event_name?: string; + tool_name?: string; + tool_input?: { skill?: string }; + prompt?: string; +} + +async function handleHook(): Promise { + const input = await readStdin(); + if (!input) { + console.log('{}'); + return; + } + try { + const data = JSON.parse(input) as HookInput; + const sessionId = data.session_id; + if (!sessionId) { + console.log('{}'); + return; + } + + let skillName = ''; + if (data.hook_event_name === 'PreToolUse' && data.tool_name === 'Skill') { + skillName = data.tool_input?.skill ?? ''; + } else if (data.hook_event_name === 'UserPromptSubmit') { + const match = /^\/([a-zA-Z0-9_:-]+)/.exec(data.prompt ?? ''); + if (match) { + skillName = match[1] ?? ''; + } + } + if (!skillName) { + console.log('{}'); + return; + } + + const filePath = getSkillsFilePath(sessionId); + const fs = await import('fs'); + const path = await import('path'); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const entry = JSON.stringify({ + timestamp: new Date().toISOString(), + session_id: sessionId, + skill: skillName, + source: data.hook_event_name + }); + fs.appendFileSync(filePath, entry + '\n'); + } catch { /* ignore parse errors */ } + console.log('{}'); +} + async function main() { // Parse --config before anything else initConfigPath(parseConfigArg()); + // Handle --hook mode (cross-platform hook handler for widgets) + if (process.argv.includes('--hook')) { + await handleHook(); + return; + } + // Check if we're in a piped/non-TTY environment first if (!process.stdin.isTTY) { await ensureWindowsUtf8CodePage(); diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 0161931..c4d1458 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -96,6 +96,9 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const shouldShowCustomKeybind = (widget: WidgetItem, keybind: CustomKeybind): boolean => { if (keybind.action !== 'toggle-invert') { + if (keybind.action === 'edit-list-limit') { + return widget.type === 'skills' && widget.metadata?.mode === 'list'; + } return true; } diff --git a/src/tui/components/items-editor/__tests__/input-handlers.test.ts b/src/tui/components/items-editor/__tests__/input-handlers.test.ts index e87734d..8064d3a 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -225,4 +225,61 @@ describe('items-editor input handlers', () => { const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; expect(updated?.[0]?.metadata?.display).toBe('progress'); }); + + it('uses v to cycle skills widget mode', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'skills' } + ]; + const onUpdate = vi.fn(); + + handleNormalInputMode({ + input: 'v', + key: {}, + widgets, + selectedIndex: 0, + separatorChars: ['|', '-'], + onBack: vi.fn(), + onUpdate, + setSelectedIndex: vi.fn(), + setMoveMode: vi.fn(), + setShowClearConfirm: vi.fn(), + openWidgetPicker: vi.fn(), + getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [], + setCustomEditorWidget: vi.fn() + }); + + const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; + expect(updated?.[0]?.metadata?.mode).toBe('count'); + }); + + it('opens custom editor for skills list limit action', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'skills', metadata: { mode: 'list' } } + ]; + const onUpdate = vi.fn(); + const setCustomEditorWidget = vi.fn(); + + handleNormalInputMode({ + input: 'l', + key: {}, + widgets, + selectedIndex: 0, + separatorChars: ['|', '-'], + onBack: vi.fn(), + onUpdate, + setSelectedIndex: vi.fn(), + setMoveMode: vi.fn(), + setShowClearConfirm: vi.fn(), + openWidgetPicker: vi.fn(), + getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [], + setCustomEditorWidget + }); + + expect(onUpdate).not.toHaveBeenCalled(); + const customEditorState = setCustomEditorWidget.mock.calls[0]?.[0] as + | { action?: string; widget?: WidgetItem } + | undefined; + expect(customEditorState?.action).toBe('edit-list-limit'); + expect(customEditorState?.widget?.type).toBe('skills'); + }); }); \ No newline at end of file diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index c2f3b49..33aa854 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -1,4 +1,7 @@ -import type { BlockMetrics } from '../types'; +import type { + BlockMetrics, + SkillsMetrics +} from '../types'; import type { SpeedMetrics } from './SpeedMetrics'; import type { StatusJSON } from './StatusJSON'; @@ -24,6 +27,7 @@ export interface RenderContext { usageData?: RenderUsageData | null; sessionDuration?: string | null; blockMetrics?: BlockMetrics | null; + skillsMetrics?: SkillsMetrics | null; terminalWidth?: number | null; isPreview?: boolean; lineIndex?: number; // Index of the current line being rendered (for theme cycling) diff --git a/src/types/SkillsMetrics.ts b/src/types/SkillsMetrics.ts new file mode 100644 index 0000000..e4905d1 --- /dev/null +++ b/src/types/SkillsMetrics.ts @@ -0,0 +1,12 @@ +export interface SkillInvocation { + timestamp: string; + session_id: string; + skill: string; + source: string; +} + +export interface SkillsMetrics { + totalInvocations: number; + uniqueSkills: string[]; + lastSkill: string | null; +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 164b3c7..cff5a10 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -18,4 +18,5 @@ export type { PowerlineFontStatus } from './PowerlineFontStatus'; export type { ClaudeSettings } from './ClaudeSettings'; export type { ColorEntry } from './ColorEntry'; export type { BlockMetrics } from './BlockMetrics'; -export type { SpeedMetrics } from './SpeedMetrics'; \ No newline at end of file +export type { SpeedMetrics } from './SpeedMetrics'; +export type { SkillInvocation, SkillsMetrics } from './SkillsMetrics'; \ No newline at end of file diff --git a/src/utils/__tests__/claude-settings.test.ts b/src/utils/__tests__/claude-settings.test.ts index 9108d38..702d04e 100644 --- a/src/utils/__tests__/claude-settings.test.ts +++ b/src/utils/__tests__/claude-settings.test.ts @@ -11,6 +11,7 @@ import { vi } from 'vitest'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; import { CCSTATUSLINE_COMMANDS, getClaudeSettingsPath, @@ -143,6 +144,36 @@ describe('buildCommand via installStatusLine', () => { await installStatusLine(true); expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.BUNX} --config '/my path/settings.json'`); }); + + it('should sync hooks on install when settings include hook-enabled widgets', async () => { + const configPath = path.join(testClaudeConfigDir, 'ccstatusline-settings.json'); + initConfigPath(configPath); + const settingsWithSkills = { + ...DEFAULT_SETTINGS, + lines: [[{ id: 'skills-1', type: 'skills' }], [], []] + }; + fs.writeFileSync(configPath, JSON.stringify(settingsWithSkills, null, 2), 'utf-8'); + + await installStatusLine(false); + + const installedCommand = `${CCSTATUSLINE_COMMANDS.NPM} --config ${configPath}`; + const claudeSettings = await loadClaudeSettings(); + expect(claudeSettings.statusLine?.command).toBe(installedCommand); + const hooks = (claudeSettings.hooks ?? {}) as Record; + expect(hooks.PreToolUse).toEqual([ + { + _tag: 'ccstatusline-managed', + matcher: 'Skill', + hooks: [{ type: 'command', command: `${installedCommand} --hook` }] + } + ]); + expect(hooks.UserPromptSubmit).toEqual([ + { + _tag: 'ccstatusline-managed', + hooks: [{ type: 'command', command: `${installedCommand} --hook` }] + } + ]); + }); }); describe('backup and error handling behavior', () => { @@ -257,6 +288,52 @@ describe('backup and error handling behavior', () => { } }); + it('uninstallStatusLine should remove all managed hooks', async () => { + writeRawClaudeSettings(JSON.stringify({ + statusLine: { + type: 'command', + command: CCSTATUSLINE_COMMANDS.NPM, + padding: 0 + }, + hooks: { + PreToolUse: [ + { + _tag: 'ccstatusline-managed', + matcher: 'Skill', + hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }] + }, + { + matcher: 'Other', + hooks: [{ type: 'command', command: 'keep-me' }] + } + ], + UserPromptSubmit: [ + { + _tag: 'ccstatusline-managed', + hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }] + } + ] + } + })); + + await uninstallStatusLine(); + + const settingsPath = getClaudeSettingsPath(); + const updated = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + statusLine?: unknown; + hooks?: Record; + }; + expect(updated.statusLine).toBeUndefined(); + expect(updated.hooks).toEqual({ + PreToolUse: [ + { + matcher: 'Other', + hooks: [{ type: 'command', command: 'keep-me' }] + } + ] + }); + }); + it('getExistingStatusLine should return null when settings cannot be loaded', async () => { writeRawClaudeSettings('{ invalid json'); const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); diff --git a/src/utils/__tests__/hooks.test.ts b/src/utils/__tests__/hooks.test.ts new file mode 100644 index 0000000..f1a8441 --- /dev/null +++ b/src/utils/__tests__/hooks.test.ts @@ -0,0 +1,79 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it +} from 'vitest'; + +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import { syncWidgetHooks } from '../hooks'; + +const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR; +let testClaudeConfigDir = ''; + +function getClaudeSettingsPath(): string { + return path.join(testClaudeConfigDir, 'settings.json'); +} + +describe('syncWidgetHooks', () => { + beforeEach(() => { + testClaudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-hooks-')); + process.env.CLAUDE_CONFIG_DIR = testClaudeConfigDir; + }); + + afterEach(() => { + if (testClaudeConfigDir) { + fs.rmSync(testClaudeConfigDir, { recursive: true, force: true }); + } + }); + + afterAll(() => { + if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR; + } + }); + + it('removes managed hooks and persists cleanup when status line is unset', async () => { + const settingsPath = getClaudeSettingsPath(); + fs.writeFileSync(settingsPath, JSON.stringify({ + hooks: { + PreToolUse: [ + { + _tag: 'ccstatusline-managed', + matcher: 'Skill', + hooks: [{ type: 'command', command: 'old-command --hook' }] + }, + { + matcher: 'Other', + hooks: [{ type: 'command', command: 'keep-command' }] + } + ], + UserPromptSubmit: [ + { + _tag: 'ccstatusline-managed', + hooks: [{ type: 'command', command: 'old-command --hook' }] + } + ] + } + }, null, 2), 'utf-8'); + + await syncWidgetHooks(DEFAULT_SETTINGS); + + const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { hooks?: Record }; + expect(saved.hooks).toEqual({ + PreToolUse: [ + { + matcher: 'Other', + hooks: [{ type: 'command', command: 'keep-command' }] + } + ] + }); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/skills.test.ts b/src/utils/__tests__/skills.test.ts new file mode 100644 index 0000000..1f29408 --- /dev/null +++ b/src/utils/__tests__/skills.test.ts @@ -0,0 +1,59 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { + getSkillsFilePath, + getSkillsMetrics +} from '../skills'; + +let testHomeDir = ''; + +function writeSkillsLog(sessionId: string, lines: string[]): void { + const skillsPath = getSkillsFilePath(sessionId); + fs.mkdirSync(path.dirname(skillsPath), { recursive: true }); + fs.writeFileSync(skillsPath, lines.join('\n'), 'utf-8'); +} + +describe('skills metrics', () => { + beforeEach(() => { + testHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-home-')); + vi.spyOn(os, 'homedir').mockReturnValue(testHomeDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (testHomeDir) { + fs.rmSync(testHomeDir, { recursive: true, force: true }); + } + }); + + it('uses ~/.cache/ccstatusline/skills path for skill logs', () => { + expect(getSkillsFilePath('session-1')).toBe( + path.join(testHomeDir, '.cache', 'ccstatusline', 'skills', 'skills-session-1.jsonl') + ); + }); + + it('returns total, unique (most-recent-first), and last skill from a valid log', () => { + writeSkillsLog('session-1', [ + JSON.stringify({ skill: 'commit', session_id: 'session-1' }), + JSON.stringify({ skill: 'review-pr', session_id: 'session-1' }), + JSON.stringify({ skill: 'lint', session_id: 'session-1' }), + JSON.stringify({ skill: 'commit', session_id: 'session-1' }) + ]); + + expect(getSkillsMetrics('session-1')).toEqual({ + totalInvocations: 4, + uniqueSkills: ['commit', 'lint', 'review-pr'], + lastSkill: 'commit' + }); + }); +}); \ No newline at end of file diff --git a/src/utils/claude-settings.ts b/src/utils/claude-settings.ts index 07be371..57f3603 100644 --- a/src/utils/claude-settings.ts +++ b/src/utils/claude-settings.ts @@ -4,6 +4,10 @@ import * as os from 'os'; import * as path from 'path'; import type { ClaudeSettings } from '../types/ClaudeSettings'; +import { + SettingsSchema, + type Settings +} from '../types/Settings'; import { getConfigPath, @@ -178,6 +182,25 @@ function buildCommand(baseCommand: string): string { return baseCommand; } +async function loadSavedSettingsForHookSync(): Promise { + const configPath = getConfigPath(); + if (!fs.existsSync(configPath)) { + return null; + } + + try { + const content = await readFile(configPath, 'utf-8'); + const parsed = JSON.parse(content) as unknown; + const result = SettingsSchema.safeParse(parsed); + if (!result.success) { + return null; + } + return result.data; + } catch { + return null; + } +} + export async function installStatusLine(useBunx = false): Promise { let settings: ClaudeSettings; @@ -202,6 +225,12 @@ export async function installStatusLine(useBunx = false): Promise { }; await saveClaudeSettings(settings); + + const savedSettings = await loadSavedSettingsForHookSync(); + if (savedSettings) { + const { syncWidgetHooks } = await import('./hooks'); + await syncWidgetHooks(savedSettings); + } } export async function uninstallStatusLine(): Promise { @@ -218,6 +247,13 @@ export async function uninstallStatusLine(): Promise { delete settings.statusLine; await saveClaudeSettings(settings); } + + try { + const { removeManagedHooks } = await import('./hooks'); + await removeManagedHooks(); + } catch { + // Ignore hook cleanup failures during uninstall + } } export async function getExistingStatusLine(): Promise { diff --git a/src/utils/config.ts b/src/utils/config.ts index 4ff139f..083e2bc 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -158,4 +158,10 @@ export async function saveSettings(settings: Settings): Promise { }; await writeSettingsJson(settingsWithVersion, paths); + + // Sync widget hooks to Claude settings + try { + const { syncWidgetHooks } = await import('./hooks'); + await syncWidgetHooks(settings); + } catch { /* ignore hook sync failures */ } } \ No newline at end of file diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts new file mode 100644 index 0000000..7471b51 --- /dev/null +++ b/src/utils/hooks.ts @@ -0,0 +1,96 @@ +import type { Settings } from '../types/Settings'; +import type { Widget } from '../types/Widget'; + +import { + getExistingStatusLine, + loadClaudeSettings, + saveClaudeSettings +} from './claude-settings'; +import { getWidget } from './widgets'; + +export interface WidgetHookDef { + event: string; + matcher?: string; +} + +const HOOK_TAG = 'ccstatusline-managed'; + +interface HookEntry { + _tag?: string; + matcher?: string; + hooks?: { type: string; command: string }[]; +} + +function stripManagedHooks(hooks: Record): void { + for (const event of Object.keys(hooks)) { + hooks[event] = (hooks[event] ?? []).filter(entry => entry._tag !== HOOK_TAG); + if (hooks[event].length === 0) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete hooks[event]; + } + } +} + +function getActiveHookDefs(settings: Settings): WidgetHookDef[] { + const seen = new Set(); + const defs: WidgetHookDef[] = []; + for (const line of settings.lines) { + for (const item of line) { + const widget = getWidget(item.type) as (Widget & { getHooks?: () => WidgetHookDef[] }) | null; + if (!widget?.getHooks) { + continue; + } + for (const hook of widget.getHooks()) { + const key = `${hook.event}:${hook.matcher ?? ''}`; + if (!seen.has(key)) { + seen.add(key); + defs.push(hook); + } + } + } + } + return defs; +} + +export async function syncWidgetHooks(settings: Settings): Promise { + const needed = getActiveHookDefs(settings); + const claudeSettings = await loadClaudeSettings({ logErrors: false }); + const hooks = (claudeSettings.hooks ?? {}) as Record; + + // Remove all ccstatusline-managed hooks + stripManagedHooks(hooks); + + const statusCommand = await getExistingStatusLine(); + if (!statusCommand) { + claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined; + await saveClaudeSettings(claudeSettings); + return; + } + const hookCommand = `${statusCommand} --hook`; + + // Add needed hooks + for (const def of needed) { + const entry: HookEntry = { + _tag: HOOK_TAG, + hooks: [{ type: 'command', command: hookCommand }] + }; + if (def.matcher) { + entry.matcher = def.matcher; + } + const list = hooks[def.event] ??= []; + list.push(entry); + } + + claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined; + await saveClaudeSettings(claudeSettings); +} + +export async function removeManagedHooks(): Promise { + const claudeSettings = await loadClaudeSettings({ logErrors: false }); + const hooks = (claudeSettings.hooks ?? {}) as Record; + + stripManagedHooks(hooks); + + claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined; + await saveClaudeSettings(claudeSettings); +} \ No newline at end of file diff --git a/src/utils/skills.ts b/src/utils/skills.ts new file mode 100644 index 0000000..a85448f --- /dev/null +++ b/src/utils/skills.ts @@ -0,0 +1,58 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import type { + SkillInvocation, + SkillsMetrics +} from '../types/SkillsMetrics'; + +const EMPTY: SkillsMetrics = { totalInvocations: 0, uniqueSkills: [], lastSkill: null }; + +function getSkillsDir(): string { + return path.join(os.homedir(), '.cache', 'ccstatusline', 'skills'); +} + +export function getSkillsFilePath(sessionId: string): string { + return path.join(getSkillsDir(), `skills-${sessionId}.jsonl`); +} + +export function getSkillsMetrics(sessionId: string): SkillsMetrics { + const filePath = getSkillsFilePath(sessionId); + if (!fs.existsSync(filePath)) { + return EMPTY; + } + + try { + const invocations: SkillInvocation[] = fs.readFileSync(filePath, 'utf-8') + .trim().split('\n') + .filter(line => line.trim()) + .map((line) => { + try { return JSON.parse(line) as SkillInvocation; } catch { + return null; + } + }) + .filter((e): e is SkillInvocation => e !== null && typeof e.skill === 'string' && typeof e.session_id === 'string'); + if (invocations.length === 0) { + return EMPTY; + } + + const uniqueSkills: string[] = []; + const seenSkills = new Set(); + for (let i = invocations.length - 1; i >= 0; i--) { + const skill = invocations[i]?.skill; + if (skill && !seenSkills.has(skill)) { + seenSkills.add(skill); + uniqueSkills.push(skill); + } + } + + return { + totalInvocations: invocations.length, + uniqueSkills, + lastSkill: invocations[invocations.length - 1]?.skill ?? null + }; + } catch { + return EMPTY; + } +} \ No newline at end of file diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index 4345300..ef3011b 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -51,7 +51,8 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'weekly-usage', create: () => new widgets.WeeklyUsageWidget() }, { type: 'reset-timer', create: () => new widgets.BlockResetTimerWidget() }, { type: 'weekly-reset-timer', create: () => new widgets.WeeklyResetTimerWidget() }, - { type: 'context-bar', create: () => new widgets.ContextBarWidget() } + { type: 'context-bar', create: () => new widgets.ContextBarWidget() }, + { type: 'skills', create: () => new widgets.SkillsWidget() } ]; export const LAYOUT_WIDGET_MANIFEST: LayoutWidgetManifestEntry[] = [ diff --git a/src/widgets/Skills.tsx b/src/widgets/Skills.tsx new file mode 100644 index 0000000..1dd7446 --- /dev/null +++ b/src/widgets/Skills.tsx @@ -0,0 +1,205 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetEditorProps, + WidgetItem +} from '../types/Widget'; +import type { WidgetHookDef } from '../utils/hooks'; +import { shouldInsertInput } from '../utils/input-guards'; + +import { makeModifierText } from './shared/editor-display'; +import { + isMetadataFlagEnabled, + toggleMetadataFlag +} from './shared/metadata'; + +type Mode = 'current' | 'count' | 'list'; +const MODES: Mode[] = ['current', 'count', 'list']; +const MODE_LABELS: Record = { current: 'last used', count: 'total count', list: 'unique list' }; +const HIDE_WHEN_EMPTY_KEY = 'hideWhenEmpty'; +const LIST_LIMIT_KEY = 'listLimit'; +const TOGGLE_HIDE_EMPTY_ACTION = 'toggle-hide-empty'; +const EDIT_LIST_LIMIT_ACTION = 'edit-list-limit'; + +function parseListLimit(item: WidgetItem): number { + const parsed = parseInt(item.metadata?.[LIST_LIMIT_KEY] ?? '0', 10); + if (Number.isNaN(parsed) || parsed < 0) { + return 0; + } + return parsed; +} + +function setListLimit(item: WidgetItem, limit: number): WidgetItem { + if (limit <= 0) { + const { [LIST_LIMIT_KEY]: removedLimit, ...restMetadata } = item.metadata ?? {}; + void removedLimit; + return { + ...item, + metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined + }; + } + + return { + ...item, + metadata: { + ...item.metadata, + [LIST_LIMIT_KEY]: limit.toString() + } + }; +} + +export class SkillsWidget implements Widget { + getDefaultColor(): string { return 'magenta'; } + getDescription(): string { return 'Shows Claude Code skill invocations from hook data'; } + getDisplayName(): string { return 'Skills'; } + getCategory(): string { return 'Session'; } + supportsRawValue(): boolean { return true; } + supportsColors(): boolean { return true; } + + getHooks(): WidgetHookDef[] { + return [ + { event: 'PreToolUse', matcher: 'Skill' }, + { event: 'UserPromptSubmit' } + ]; + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + { key: 'v', label: '(v)iew: last/count/list', action: 'cycle-mode' }, + { key: 'h', label: '(h)ide when empty', action: TOGGLE_HIDE_EMPTY_ACTION }, + { key: 'l', label: '(l)imit', action: EDIT_LIST_LIMIT_ACTION } + ]; + } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers = [MODE_LABELS[this.getMode(item)]]; + if (this.getMode(item) === 'list') { + const limit = parseListLimit(item); + if (limit > 0) { + modifiers.push(`limit: ${limit}`); + } + } + if (this.isHideWhenEmptyEnabled(item)) { + modifiers.push('hide when empty'); + } + return { displayText: 'Skills', modifierText: makeModifierText(modifiers) }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'cycle-mode') { + const next = MODES[(MODES.indexOf(this.getMode(item)) + 1) % MODES.length] ?? 'current'; + return { ...item, metadata: { ...item.metadata, mode: next } }; + } + if (action === TOGGLE_HIDE_EMPTY_ACTION) { + return toggleMetadataFlag(item, HIDE_WHEN_EMPTY_KEY); + } + return null; + } + + renderEditor(props: WidgetEditorProps): React.ReactElement { + return ; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const mode = this.getMode(item); + const raw = item.rawValue; + const hideWhenEmpty = this.isHideWhenEmptyEnabled(item); + + if (context.isPreview) { + if (mode === 'current') { + return raw ? 'commit' : 'Skill: commit'; + } + if (mode === 'count') { + return raw ? '5' : 'Skills: 5'; + } + return raw ? 'commit, review-pr' : 'Skills: commit, review-pr'; + } + + if (mode === 'current') { + const currentSkill = context.skillsMetrics?.lastSkill; + if (!currentSkill) { + if (hideWhenEmpty) { + return null; + } + return raw ? 'none' : 'Skill: none'; + } + return raw ? currentSkill : `Skill: ${currentSkill}`; + } + if (mode === 'count') { + const total = context.skillsMetrics?.totalInvocations ?? 0; + if (hideWhenEmpty && total === 0) { + return null; + } + return raw ? String(total) : `Skills: ${total}`; + } + + const uniqueSkills = context.skillsMetrics?.uniqueSkills ?? []; + if (uniqueSkills.length === 0) { + if (hideWhenEmpty) { + return null; + } + return raw ? 'none' : 'Skills: none'; + } + + const limit = parseListLimit(item); + const visibleSkills = limit > 0 ? uniqueSkills.slice(0, limit) : uniqueSkills; + const list = visibleSkills.join(', '); + return raw ? list : `Skills: ${list}`; + } + + private getMode(item: WidgetItem): Mode { + const mode = item.metadata?.mode; + return mode && MODES.includes(mode as Mode) ? mode as Mode : 'current'; + } + + private isHideWhenEmptyEnabled(item: WidgetItem): boolean { + return isMetadataFlagEnabled(item, HIDE_WHEN_EMPTY_KEY); + } +} + +const SkillsEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { + const [limitInput, setLimitInput] = useState(() => parseListLimit(widget).toString()); + + useInput((input, key) => { + if (action !== EDIT_LIST_LIMIT_ACTION) { + return; + } + + if (key.return) { + const parsed = parseInt(limitInput, 10); + const limit = Number.isNaN(parsed) || parsed < 0 ? 0 : parsed; + onComplete(setListLimit(widget, limit)); + } else if (key.escape) { + onCancel(); + } else if (key.backspace) { + setLimitInput(limitInput.slice(0, -1)); + } else if (shouldInsertInput(input, key) && /\d/.test(input)) { + setLimitInput(limitInput + input); + } + }); + + if (action === EDIT_LIST_LIMIT_ACTION) { + return ( + + + Enter max skills to show (0 for unlimited): + {limitInput} + {' '} + + Press Enter to save, ESC to cancel + + ); + } + + return Unknown editor mode; +}; \ No newline at end of file diff --git a/src/widgets/__tests__/Skills.test.ts b/src/widgets/__tests__/Skills.test.ts new file mode 100644 index 0000000..a8eae7c --- /dev/null +++ b/src/widgets/__tests__/Skills.test.ts @@ -0,0 +1,127 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { + RenderContext, + WidgetItem +} from '../../types'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import { SkillsWidget } from '../Skills'; + +function render(item: WidgetItem, context: RenderContext): string | null { + return new SkillsWidget().render(item, context, DEFAULT_SETTINGS); +} + +describe('SkillsWidget', () => { + it('uses v as the mode toggle keybind', () => { + const widget = new SkillsWidget(); + expect(widget.getCustomKeybinds()).toEqual([ + { key: 'v', label: '(v)iew: last/count/list', action: 'cycle-mode' }, + { key: 'h', label: '(h)ide when empty', action: 'toggle-hide-empty' }, + { key: 'l', label: '(l)imit', action: 'edit-list-limit' } + ]); + }); + + it('cycles mode current -> count -> list -> current', () => { + const widget = new SkillsWidget(); + const base: WidgetItem = { id: 'skills', type: 'skills' }; + const count = widget.handleEditorAction('cycle-mode', base); + const list = widget.handleEditorAction('cycle-mode', count ?? base); + const current = widget.handleEditorAction('cycle-mode', list ?? base); + + expect(count?.metadata?.mode).toBe('count'); + expect(list?.metadata?.mode).toBe('list'); + expect(current?.metadata?.mode).toBe('current'); + }); + + it('toggles hide-when-empty metadata', () => { + const widget = new SkillsWidget(); + const base: WidgetItem = { id: 'skills', type: 'skills' }; + const hidden = widget.handleEditorAction('toggle-hide-empty', base); + const shown = widget.handleEditorAction('toggle-hide-empty', hidden ?? base); + + expect(hidden?.metadata?.hideWhenEmpty).toBe('true'); + expect(shown?.metadata?.hideWhenEmpty).toBe('false'); + }); + + it('shows hide-when-empty in editor modifier text when enabled', () => { + const widget = new SkillsWidget(); + const display = widget.getEditorDisplay({ + id: 'skills', + type: 'skills', + metadata: { hideWhenEmpty: 'true' } + }); + + expect(display.modifierText).toBe('(last used, hide when empty)'); + }); + + it('shows list limit in editor modifier text when configured', () => { + const widget = new SkillsWidget(); + const display = widget.getEditorDisplay({ + id: 'skills', + type: 'skills', + metadata: { mode: 'list', listLimit: '2' } + }); + + expect(display.modifierText).toBe('(unique list, limit: 2)'); + }); + + it('renders current, count, and list modes from skills metrics', () => { + const context: RenderContext = { + skillsMetrics: { + totalInvocations: 3, + uniqueSkills: ['commit', 'review-pr'], + lastSkill: 'review-pr' + } + }; + + expect(render({ id: 'skills', type: 'skills' }, context)).toBe('Skill: review-pr'); + expect(render({ id: 'skills', type: 'skills', metadata: { mode: 'count' } }, context)).toBe('Skills: 3'); + expect(render({ id: 'skills', type: 'skills', metadata: { mode: 'list' } }, context)).toBe('Skills: commit, review-pr'); + expect(render({ id: 'skills', type: 'skills', metadata: { mode: 'list', listLimit: '1' } }, context)).toBe('Skills: commit'); + expect(render({ id: 'skills', type: 'skills', metadata: { mode: 'list', listLimit: '0' } }, context)).toBe('Skills: commit, review-pr'); + }); + + it('shows non-hidden empty outputs by default', () => { + const context: RenderContext = { + skillsMetrics: { + totalInvocations: 0, + uniqueSkills: [], + lastSkill: null + } + }; + + expect(render({ id: 'skills', type: 'skills' }, context)).toBe('Skill: none'); + expect(render({ id: 'skills', type: 'skills', metadata: { mode: 'count' } }, context)).toBe('Skills: 0'); + expect(render({ id: 'skills', type: 'skills', metadata: { mode: 'list' } }, context)).toBe('Skills: none'); + }); + + it('hides empty outputs when hide-when-empty is enabled', () => { + const context: RenderContext = { + skillsMetrics: { + totalInvocations: 0, + uniqueSkills: [], + lastSkill: null + } + }; + + expect(render({ + id: 'skills', + type: 'skills', + metadata: { hideWhenEmpty: 'true' } + }, context)).toBeNull(); + expect(render({ + id: 'skills', + type: 'skills', + metadata: { mode: 'count', hideWhenEmpty: 'true' } + }, context)).toBeNull(); + expect(render({ + id: 'skills', + type: 'skills', + metadata: { mode: 'list', hideWhenEmpty: 'true' } + }, context)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/widgets/index.ts b/src/widgets/index.ts index c6f8b8a..13fc932 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -32,4 +32,5 @@ export { WeeklyUsageWidget } from './WeeklyUsage'; export { BlockResetTimerWidget } from './BlockResetTimer'; export { WeeklyResetTimerWidget } from './WeeklyResetTimer'; export { ContextBarWidget } from './ContextBar'; -export { LinkWidget } from './Link'; \ No newline at end of file +export { LinkWidget } from './Link'; +export { SkillsWidget } from './Skills'; \ No newline at end of file