mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-13 22:15:27 +00:00
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:
1 parent
53c8200c5a
commit
78d6dcc28c
19 files changed
+898
-6
No files matched your search
@@ -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
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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' }]
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
Reference in new issue
Block a user