diff --git a/AGENTS.md b/AGENTS.md index 0e4d40a..eb46901 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ All widgets must implement: **Available Widgets:** - Model, Version, OutputStyle - Claude Code metadata display -- GitBranch, GitChanges, GitWorktree - Git repository status +- GitBranch, GitChanges, GitInsertions, GitDeletions, GitWorktree - Git repository status - TokensInput, TokensOutput, TokensCached, TokensTotal - Token usage metrics - ContextLength, ContextPercentage, ContextPercentageUsable - Context window metrics (uses dynamic model-based context windows: 1M for Sonnet 4.5 with [1m] suffix, 200k for all other models) - BlockTimer, SessionClock, SessionCost - Time and cost tracking diff --git a/README.md b/README.md index b952f53..25ed1e5 100644 --- a/README.md +++ b/README.md @@ -46,12 +46,14 @@ ## 🆕 Recent Updates -### v2.1.0 - v2.1.3 - Usage widgets, links, and reliability fixes +### v2.1.0 - v2.1.4 - Usage widgets, links, new git insertions / deletions widgets, and reliability fixes - **🧩 New Usage widgets (v2.1.0)** - Added **Session Usage**, **Weekly Usage**, **Reset Timer**, and **Context Bar** widgets. - **📊 More accurate counts (v2.1.0)** - Usage/context widgets now use new statusline JSON metrics when available for more accurate token and context counts. - **🪟 Windows empty file bug fix (v2.1.1)** - Fixed a Windows issue that could create an empty `c:\dev\null` file. - **🔗 New Link widget (v2.1.3)** - Added a new **Link** widget with clickable OSC8 rendering, preview parity, and raw mode support. +- **➕ New Git Insertions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted insertions (e.g., `+42`). +- **➖ New Git Deletions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted deletions (e.g., `-10`). ### v2.0.26 - v2.0.29 - Performance, git internals, and workflow improvements @@ -412,6 +414,8 @@ bun run example - **Model Name** - Shows the current Claude model (e.g., "Claude 3.5 Sonnet") - **Git Branch** - Displays current git branch name - **Git Changes** - Shows uncommitted insertions/deletions (e.g., "+42,-10") +- **Git Insertions** - Shows uncommitted insertions only (e.g., "+42") +- **Git Deletions** - Shows uncommitted deletions only (e.g., "-10") - **Git Root Dir** - Shows the git repository root directory name - **Git Worktree** - Shows the name of the current git worktree - **Session Clock** - Shows elapsed time since session start (e.g., "2hr 15m") diff --git a/package.json b/package.json index c0aeb74..9d98416 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccstatusline", - "version": "2.1.3", + "version": "2.1.4", "description": "A customizable status line formatter for Claude Code CLI", "module": "src/ccstatusline.ts", "type": "module", diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index 19e1201..74ddb31 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -9,6 +9,7 @@ import { import type { RenderContext } from '../../types/RenderContext'; import { + getGitChangeCounts, isInsideGitWorkTree, resolveGitCwd, runGit @@ -20,6 +21,7 @@ const mockExecSync = execSync as unknown as { mock: { calls: unknown[][] }; mockImplementation: (impl: () => never) => void; mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; }; describe('git utils', () => { @@ -134,4 +136,35 @@ describe('git utils', () => { expect(isInsideGitWorkTree({})).toBe(false); }); }); + + describe('getGitChangeCounts', () => { + it('sums staged and unstaged insertions/deletions', () => { + mockExecSync.mockReturnValueOnce('1 file changed, 2 insertions(+), 1 deletion(-)'); + mockExecSync.mockReturnValueOnce('1 file changed, 3 insertions(+), 4 deletions(-)'); + + expect(getGitChangeCounts({})).toEqual({ + insertions: 5, + deletions: 5 + }); + }); + + it('handles singular insertion/deletion forms', () => { + mockExecSync.mockReturnValueOnce('1 file changed, 1 insertion(+), 1 deletion(-)'); + mockExecSync.mockReturnValueOnce(''); + + expect(getGitChangeCounts({})).toEqual({ + insertions: 1, + deletions: 1 + }); + }); + + it('returns zero counts when git diff commands fail', () => { + mockExecSync.mockImplementation(() => { throw new Error('git failed'); }); + + expect(getGitChangeCounts({})).toEqual({ + insertions: 0, + deletions: 0 + }); + }); + }); }); \ No newline at end of file diff --git a/src/utils/__tests__/widgets.test.ts b/src/utils/__tests__/widgets.test.ts index 1a677f2..076ab89 100644 --- a/src/utils/__tests__/widgets.test.ts +++ b/src/utils/__tests__/widgets.test.ts @@ -28,6 +28,8 @@ describe('widget catalog', () => { const model = catalog.find(entry => entry.type === 'model'); const separator = catalog.find(entry => entry.type === 'separator'); const link = catalog.find(entry => entry.type === 'link'); + const gitInsertions = catalog.find(entry => entry.type === 'git-insertions'); + const gitDeletions = catalog.find(entry => entry.type === 'git-deletions'); expect(model?.displayName).toBe('Model'); expect(model?.category).toBe('Core'); @@ -35,6 +37,10 @@ describe('widget catalog', () => { expect(separator?.category).toBe('Layout'); expect(link?.displayName).toBe('Link'); expect(link?.category).toBe('Custom'); + expect(gitInsertions?.displayName).toBe('Git Insertions'); + expect(gitInsertions?.category).toBe('Git'); + expect(gitDeletions?.displayName).toBe('Git Deletions'); + expect(gitDeletions?.category).toBe('Git'); }); it('hides manual separator when default separator is configured', () => { diff --git a/src/utils/git.ts b/src/utils/git.ts index 3f768ab..e73b999 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -2,6 +2,11 @@ import { execSync } from 'child_process'; import type { RenderContext } from '../types/RenderContext'; +export interface GitChangeCounts { + insertions: number; + deletions: number; +} + export function resolveGitCwd(context: RenderContext): string | undefined { const candidates = [ context.data?.cwd, @@ -35,4 +40,26 @@ export function runGit(command: string, context: RenderContext): string | null { export function isInsideGitWorkTree(context: RenderContext): boolean { return runGit('rev-parse --is-inside-work-tree', context) === 'true'; +} + +function parseDiffShortStat(stat: string): GitChangeCounts { + const insertMatch = /(\d+)\s+insertions?/.exec(stat); + const deleteMatch = /(\d+)\s+deletions?/.exec(stat); + + return { + insertions: insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0, + deletions: deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0 + }; +} + +export function getGitChangeCounts(context: RenderContext): GitChangeCounts { + const unstagedStat = runGit('diff --shortstat', context) ?? ''; + const stagedStat = runGit('diff --cached --shortstat', context) ?? ''; + const unstagedCounts = parseDiffShortStat(unstagedStat); + const stagedCounts = parseDiffShortStat(stagedStat); + + return { + insertions: unstagedCounts.insertions + stagedCounts.insertions, + deletions: unstagedCounts.deletions + stagedCounts.deletions + }; } \ No newline at end of file diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 6a2c4c7..3b6ad36 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -11,6 +11,8 @@ const widgetRegistry = new Map([ ['output-style', new widgets.OutputStyleWidget()], ['git-branch', new widgets.GitBranchWidget()], ['git-changes', new widgets.GitChangesWidget()], + ['git-insertions', new widgets.GitInsertionsWidget()], + ['git-deletions', new widgets.GitDeletionsWidget()], ['git-root-dir', new widgets.GitRootDirWidget()], ['git-worktree', new widgets.GitWorktreeWidget()], ['current-working-dir', new widgets.CurrentWorkingDirWidget()], diff --git a/src/widgets/GitChanges.ts b/src/widgets/GitChanges.ts index 56dc2ab..165e059 100644 --- a/src/widgets/GitChanges.ts +++ b/src/widgets/GitChanges.ts @@ -7,8 +7,8 @@ import type { WidgetItem } from '../types/Widget'; import { - isInsideGitWorkTree, - runGit + getGitChangeCounts, + isInsideGitWorkTree } from '../utils/git'; export class GitChangesWidget implements Widget { @@ -44,7 +44,7 @@ export class GitChangesWidget implements Widget { return null; } - render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { const hideNoGit = item.metadata?.hideNoGit === 'true'; if (context.isPreview) { @@ -55,35 +55,8 @@ export class GitChangesWidget implements Widget { return hideNoGit ? null : '(no git)'; } - const changes = this.getGitChanges(context); - if (changes) - return `(+${changes.insertions},-${changes.deletions})`; - else - return hideNoGit ? null : '(no git)'; - } - - private getGitChanges(context: RenderContext): { insertions: number; deletions: number } | null { - let totalInsertions = 0; - let totalDeletions = 0; - - const unstagedStat = runGit('diff --shortstat', context) ?? ''; - const stagedStat = runGit('diff --cached --shortstat', context) ?? ''; - - if (unstagedStat) { - const insertMatch = /(\d+) insertion/.exec(unstagedStat); - const deleteMatch = /(\d+) deletion/.exec(unstagedStat); - totalInsertions += insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0; - totalDeletions += deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0; - } - - if (stagedStat) { - const insertMatch = /(\d+) insertion/.exec(stagedStat); - const deleteMatch = /(\d+) deletion/.exec(stagedStat); - totalInsertions += insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0; - totalDeletions += deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0; - } - - return { insertions: totalInsertions, deletions: totalDeletions }; + const changes = getGitChangeCounts(context); + return `(+${changes.insertions},-${changes.deletions})`; } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/GitDeletions.ts b/src/widgets/GitDeletions.ts new file mode 100644 index 0000000..575896a --- /dev/null +++ b/src/widgets/GitDeletions.ts @@ -0,0 +1,70 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitChangeCounts, + isInsideGitWorkTree +} from '../utils/git'; + +export class GitDeletionsWidget implements Widget { + getDefaultColor(): string { return 'red'; } + getDescription(): string { return 'Shows git deletions count'; } + getDisplayName(): string { return 'Git Deletions'; } + getCategory(): string { return 'Git'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const hideNoGit = item.metadata?.hideNoGit === 'true'; + const modifiers: string[] = []; + + if (hideNoGit) { + modifiers.push('hide \'no git\''); + } + + return { + displayText: this.getDisplayName(), + modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-nogit') { + const currentState = item.metadata?.hideNoGit === 'true'; + return { + ...item, + metadata: { + ...item.metadata, + hideNoGit: (!currentState).toString() + } + }; + } + return null; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = item.metadata?.hideNoGit === 'true'; + + if (context.isPreview) { + return '-10'; + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const changes = getGitChangeCounts(context); + return `-${changes.deletions}`; + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + { key: 'h', label: '(h)ide \'no git\' message', action: 'toggle-nogit' } + ]; + } + + supportsRawValue(): boolean { return false; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitInsertions.ts b/src/widgets/GitInsertions.ts new file mode 100644 index 0000000..998be61 --- /dev/null +++ b/src/widgets/GitInsertions.ts @@ -0,0 +1,70 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitChangeCounts, + isInsideGitWorkTree +} from '../utils/git'; + +export class GitInsertionsWidget implements Widget { + getDefaultColor(): string { return 'green'; } + getDescription(): string { return 'Shows git insertions count'; } + getDisplayName(): string { return 'Git Insertions'; } + getCategory(): string { return 'Git'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const hideNoGit = item.metadata?.hideNoGit === 'true'; + const modifiers: string[] = []; + + if (hideNoGit) { + modifiers.push('hide \'no git\''); + } + + return { + displayText: this.getDisplayName(), + modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-nogit') { + const currentState = item.metadata?.hideNoGit === 'true'; + return { + ...item, + metadata: { + ...item.metadata, + hideNoGit: (!currentState).toString() + } + }; + } + return null; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = item.metadata?.hideNoGit === 'true'; + + if (context.isPreview) { + return '+42'; + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const changes = getGitChangeCounts(context); + return `+${changes.insertions}`; + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + { key: 'h', label: '(h)ide \'no git\' message', action: 'toggle-nogit' } + ]; + } + + supportsRawValue(): boolean { return false; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/__tests__/GitDeletions.test.ts b/src/widgets/__tests__/GitDeletions.test.ts new file mode 100644 index 0000000..a7b71bd --- /dev/null +++ b/src/widgets/__tests__/GitDeletions.test.ts @@ -0,0 +1,106 @@ +import { execSync } from 'child_process'; +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { GitDeletionsWidget } from '../GitDeletions'; + +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as unknown as { + mock: { calls: unknown[][] }; + mockImplementation: (impl: () => never) => void; + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; +}; + +function render(options: { + cwd?: string; + hideNoGit?: boolean; + isPreview?: boolean; +} = {}) { + const widget = new GitDeletionsWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'git-deletions', + type: 'git-deletions', + metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('GitDeletionsWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('-10'); + }); + + it('should render combined staged and unstaged deletions', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('1 file changed, 2 insertions(+), 1 deletion(-)'); + mockExecSync.mockReturnValueOnce('1 file changed, 3 insertions(+), 4 deletions(-)'); + + expect(render({ cwd: '/tmp/worktree' })).toBe('-5'); + expect(mockExecSync.mock.calls[0]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); + expect(mockExecSync.mock.calls[1]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); + expect(mockExecSync.mock.calls[2]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); + }); + + it('should render zero count when repo is clean', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce(''); + + expect(render()).toBe('-0'); + }); + + it('should render no git when probe returns false', () => { + mockExecSync.mockReturnValue('false\n'); + + expect(render()).toBe('(no git)'); + }); + + it('should hide no git when configured', () => { + mockExecSync.mockReturnValue('false\n'); + + expect(render({ hideNoGit: true })).toBeNull(); + }); + + it('should render no git when command fails', () => { + mockExecSync.mockImplementation(() => { throw new Error('No git'); }); + + expect(render()).toBe('(no git)'); + }); + + it('should disable raw value support', () => { + const widget = new GitDeletionsWidget(); + + expect(widget.supportsRawValue()).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/widgets/__tests__/GitInsertions.test.ts b/src/widgets/__tests__/GitInsertions.test.ts new file mode 100644 index 0000000..519df17 --- /dev/null +++ b/src/widgets/__tests__/GitInsertions.test.ts @@ -0,0 +1,106 @@ +import { execSync } from 'child_process'; +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { GitInsertionsWidget } from '../GitInsertions'; + +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as unknown as { + mock: { calls: unknown[][] }; + mockImplementation: (impl: () => never) => void; + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; +}; + +function render(options: { + cwd?: string; + hideNoGit?: boolean; + isPreview?: boolean; +} = {}) { + const widget = new GitInsertionsWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'git-insertions', + type: 'git-insertions', + metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('GitInsertionsWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('+42'); + }); + + it('should render combined staged and unstaged insertions', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('1 file changed, 2 insertions(+), 1 deletion(-)'); + mockExecSync.mockReturnValueOnce('1 file changed, 3 insertions(+), 4 deletions(-)'); + + expect(render({ cwd: '/tmp/worktree' })).toBe('+5'); + expect(mockExecSync.mock.calls[0]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); + expect(mockExecSync.mock.calls[1]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); + expect(mockExecSync.mock.calls[2]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); + }); + + it('should render zero count when repo is clean', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce(''); + + expect(render()).toBe('+0'); + }); + + it('should render no git when probe returns false', () => { + mockExecSync.mockReturnValue('false\n'); + + expect(render()).toBe('(no git)'); + }); + + it('should hide no git when configured', () => { + mockExecSync.mockReturnValue('false\n'); + + expect(render({ hideNoGit: true })).toBeNull(); + }); + + it('should render no git when command fails', () => { + mockExecSync.mockImplementation(() => { throw new Error('No git'); }); + + expect(render()).toBe('(no git)'); + }); + + it('should disable raw value support', () => { + const widget = new GitInsertionsWidget(); + + expect(widget.supportsRawValue()).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/widgets/index.ts b/src/widgets/index.ts index bf44bae..849dc1b 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -2,6 +2,8 @@ export { ModelWidget } from './Model'; export { OutputStyleWidget } from './OutputStyle'; export { GitBranchWidget } from './GitBranch'; export { GitChangesWidget } from './GitChanges'; +export { GitInsertionsWidget } from './GitInsertions'; +export { GitDeletionsWidget } from './GitDeletions'; export { GitRootDirWidget } from './GitRootDir'; export { GitWorktreeWidget } from './GitWorktree'; export { TokensInputWidget } from './TokensInput';