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-<sessionId>.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 <sirmalloc@gmail.com>
This commit is contained in:
BapandMatthew Breedlove authored and GitHub committed 2026-03-05 01:35:37 -05:00
1 parent 53c8200c5a
commit 78d6dcc28c
19 files changed
+898 -6

No files matched your search

+3 -1
View File
@@ -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)
+1 -1
View File
@@ -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",
+68
View File
@@ -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<void> {
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();
+3
View File
@@ -96,6 +96,9 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ 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;
}
@@ -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');
});
});
+5 -1
View File
@@ -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)
+12
View File
@@ -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;
}
+2 -1
View File
@@ -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';
export type { SpeedMetrics } from './SpeedMetrics';
export type { SkillInvocation, SkillsMetrics } from './SkillsMetrics';
@@ -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<string, unknown[]>;
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<string, unknown[]>;
};
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);
+79
View File
@@ -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<string, unknown[]> };
expect(saved.hooks).toEqual({
PreToolUse: [
{
matcher: 'Other',
hooks: [{ type: 'command', command: 'keep-command' }]
}
]
});
});
});
+59
View File
@@ -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'
});
});
});
+36
View File
@@ -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<Settings | null> {
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<void> {
let settings: ClaudeSettings;
@@ -202,6 +225,12 @@ export async function installStatusLine(useBunx = false): Promise<void> {
};
await saveClaudeSettings(settings);
const savedSettings = await loadSavedSettingsForHookSync();
if (savedSettings) {
const { syncWidgetHooks } = await import('./hooks');
await syncWidgetHooks(savedSettings);
}
}
export async function uninstallStatusLine(): Promise<void> {
@@ -218,6 +247,13 @@ export async function uninstallStatusLine(): Promise<void> {
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<string | null> {
+6
View File
@@ -158,4 +158,10 @@ export async function saveSettings(settings: Settings): Promise<void> {
};
await writeSettingsJson(settingsWithVersion, paths);
// Sync widget hooks to Claude settings
try {
const { syncWidgetHooks } = await import('./hooks');
await syncWidgetHooks(settings);
} catch { /* ignore hook sync failures */ }
}
+96
View File
@@ -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<string, HookEntry[]>): 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<string>();
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<void> {
const needed = getActiveHookDefs(settings);
const claudeSettings = await loadClaudeSettings({ logErrors: false });
const hooks = (claudeSettings.hooks ?? {}) as Record<string, HookEntry[]>;
// 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<void> {
const claudeSettings = await loadClaudeSettings({ logErrors: false });
const hooks = (claudeSettings.hooks ?? {}) as Record<string, HookEntry[]>;
stripManagedHooks(hooks);
claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined;
await saveClaudeSettings(claudeSettings);
}
+58
View File
@@ -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<string>();
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;
}
}
+2 -1
View File
@@ -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[] = [
+205
View File
@@ -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<Mode, string> = { 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 <SkillsEditor {...props} />;
}
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<WidgetEditorProps> = ({ 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 (
<Box flexDirection='column'>
<Box>
<Text>Enter max skills to show (0 for unlimited): </Text>
<Text>{limitInput}</Text>
<Text backgroundColor='gray' color='black'>{' '}</Text>
</Box>
<Text dimColor>Press Enter to save, ESC to cancel</Text>
</Box>
);
}
return <Text>Unknown editor mode</Text>;
};
+127
View File
@@ -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();
});
});
+2 -1
View File
@@ -32,4 +32,5 @@ export { WeeklyUsageWidget } from './WeeklyUsage';
export { BlockResetTimerWidget } from './BlockResetTimer';
export { WeeklyResetTimerWidget } from './WeeklyResetTimer';
export { ContextBarWidget } from './ContextBar';
export { LinkWidget } from './Link';
export { LinkWidget } from './Link';
export { SkillsWidget } from './Skills';