diff --git a/README.md b/README.md index 846f6fd..67310eb 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,8 @@ Chinese HUD labels are available as an explicit opt-in. English stays the defaul | `gitStatus.enabled` | boolean | true | Show git branch in HUD | | `gitStatus.showDirty` | boolean | true | Show `*` for uncommitted changes | | `gitStatus.showAheadBehind` | boolean | false | Show `↑N ↓N` for ahead/behind remote | +| `gitStatus.pushWarningThreshold` | number | 0 | Color the ahead count with the warning color at or above this unpushed-commit count (`0` disables it) | +| `gitStatus.pushCriticalThreshold` | number | 0 | Color the ahead count with the critical color at or above this unpushed-commit count (`0` disables it) | | `gitStatus.showFileStats` | boolean | false | Show file change counts `!M +A ✘D ?U` | | `display.showModel` | boolean | true | Show model name `[Opus]` | | `display.showContextBar` | boolean | true | Show visual context bar `████░░░░░░` | diff --git a/src/config.ts b/src/config.ts index 6edc0d8..d06e6c1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -69,6 +69,8 @@ export interface HudConfig { showDirty: boolean; showAheadBehind: boolean; showFileStats: boolean; + pushWarningThreshold: number; + pushCriticalThreshold: number; }; display: { showModel: boolean; @@ -111,6 +113,8 @@ export const DEFAULT_CONFIG: HudConfig = { showDirty: true, showAheadBehind: false, showFileStats: false, + pushWarningThreshold: 0, + pushCriticalThreshold: 0, }, display: { showModel: true, @@ -263,6 +267,13 @@ function validateThreshold(value: unknown, max = 100): number { return Math.max(0, Math.min(max, value)); } +function validateCountThreshold(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + return Math.max(0, Math.floor(value)); +} + export function mergeConfig(userConfig: Partial): HudConfig { const migrated = migrateConfig(userConfig); const language = validateLanguage(migrated.language) @@ -296,6 +307,8 @@ export function mergeConfig(userConfig: Partial): HudConfig { showFileStats: typeof migrated.gitStatus?.showFileStats === 'boolean' ? migrated.gitStatus.showFileStats : DEFAULT_CONFIG.gitStatus.showFileStats, + pushWarningThreshold: validateCountThreshold(migrated.gitStatus?.pushWarningThreshold), + pushCriticalThreshold: validateCountThreshold(migrated.gitStatus?.pushCriticalThreshold), }; const display = { diff --git a/src/render/lines/project.ts b/src/render/lines/project.ts index f17b4d7..3613e29 100644 --- a/src/render/lines/project.ts +++ b/src/render/lines/project.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; import type { RenderContext } from '../../types.js'; import { getModelName, formatModelName, getProviderLabel } from '../../stdin.js'; import { getOutputSpeed } from '../../speed-tracker.js'; -import { git as gitColor, gitBranch as gitBranchColor, label, model as modelColor, project as projectColor, red, green, yellow, dim, custom as customColor } from '../colors.js'; +import { git as gitColor, gitBranch as gitBranchColor, warning as warningColor, critical as criticalColor, label, model as modelColor, project as projectColor, red, green, yellow, dim, custom as customColor } from '../colors.js'; import { t } from '../../i18n/index.js'; function hyperlink(uri: string, text: string): string { @@ -45,7 +45,9 @@ export function renderProjectLine(ctx: RenderContext): string | null { const gitInner: string[] = [linkedBranch]; if (gitConfig?.showAheadBehind) { - if (ctx.gitStatus.ahead > 0) gitInner.push(gitBranchColor(`↑${ctx.gitStatus.ahead}`, colors)); + if (ctx.gitStatus.ahead > 0) { + gitInner.push(formatAheadCount(ctx.gitStatus.ahead, gitConfig, colors)); + } if (ctx.gitStatus.behind > 0) gitInner.push(gitBranchColor(`↓${ctx.gitStatus.behind}`, colors)); } @@ -105,6 +107,26 @@ export function renderProjectLine(ctx: RenderContext): string | null { return parts.join(' \u2502 '); } +function formatAheadCount( + ahead: number, + gitConfig: RenderContext['config']['gitStatus'] | undefined, + colors: RenderContext['config']['colors'] | undefined, +): string { + const value = `↑${ahead}`; + const criticalThreshold = gitConfig?.pushCriticalThreshold ?? 0; + const warningThreshold = gitConfig?.pushWarningThreshold ?? 0; + + if (criticalThreshold > 0 && ahead >= criticalThreshold) { + return criticalColor(value, colors); + } + + if (warningThreshold > 0 && ahead >= warningThreshold) { + return warningColor(value, colors); + } + + return gitBranchColor(value, colors); +} + export function renderGitFilesLine(ctx: RenderContext, terminalWidth: number | null = null): string | null { const gitConfig = ctx.config?.gitStatus; if (!(gitConfig?.showFileStats ?? false)) return null; diff --git a/tests/config.test.js b/tests/config.test.js index 187e984..0f758c2 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -41,6 +41,8 @@ test('loadConfig returns valid config structure', async () => { assert.equal(typeof config.gitStatus.enabled, 'boolean'); assert.equal(typeof config.gitStatus.showDirty, 'boolean'); assert.equal(typeof config.gitStatus.showAheadBehind, 'boolean'); + assert.equal(typeof config.gitStatus.pushWarningThreshold, 'number'); + assert.equal(typeof config.gitStatus.pushCriticalThreshold, 'number'); // display object with expected properties assert.equal(typeof config.display, 'object'); @@ -114,6 +116,20 @@ test('mergeConfig preserves explicit showMemoryUsage=true', () => { assert.equal(config.display.showMemoryUsage, true); }); +test('mergeConfig defaults git push thresholds to disabled', () => { + const config = mergeConfig({}); + assert.equal(config.gitStatus.pushWarningThreshold, 0); + assert.equal(config.gitStatus.pushCriticalThreshold, 0); +}); + +test('mergeConfig preserves explicit git push thresholds', () => { + const config = mergeConfig({ + gitStatus: { pushWarningThreshold: 15, pushCriticalThreshold: 30 }, + }); + assert.equal(config.gitStatus.pushWarningThreshold, 15); + assert.equal(config.gitStatus.pushCriticalThreshold, 30); +}); + test('mergeConfig defaults showOutputStyle to false', () => { const config = mergeConfig({}); assert.equal(config.display.showOutputStyle, false); diff --git a/tests/render.test.js b/tests/render.test.js index f853637..cc530b5 100644 --- a/tests/render.test.js +++ b/tests/render.test.js @@ -49,7 +49,7 @@ function baseContext() { showSeparators: false, pathLevels: 1, elementOrder: ['project', 'context', 'usage', 'memory', 'environment', 'tools', 'agents', 'todos'], - gitStatus: { enabled: true, showDirty: true, showAheadBehind: false, showFileStats: false }, + gitStatus: { enabled: true, showDirty: true, showAheadBehind: false, showFileStats: false, pushWarningThreshold: 0, pushCriticalThreshold: 0 }, display: { showModel: true, showProject: true, showContextBar: true, contextValue: 'percent', showConfigCounts: true, showDuration: true, showSpeed: false, showTokenBreakdown: true, showUsage: true, usageBarEnabled: false, showTools: true, showAgents: true, showTodos: true, showSessionTokens: false, showSessionName: false, showClaudeCodeVersion: false, showMemoryUsage: false, showOutputStyle: false, autocompactBuffer: 'enabled', usageThreshold: 0, sevenDayThreshold: 80, environmentThreshold: 0, customLine: '' }, colors: { context: 'green', @@ -1501,6 +1501,8 @@ test('renderSessionLine combines showFileStats with showDirty and showAheadBehin showDirty: true, showAheadBehind: true, showFileStats: true, + pushWarningThreshold: 0, + pushCriticalThreshold: 0, }; ctx.gitStatus = { branch: 'feature', @@ -1518,6 +1520,38 @@ test('renderSessionLine combines showFileStats with showDirty and showAheadBehin assert.ok(line.includes('✘1'), 'expected deleted count'); }); +test('renderProjectLine colors ahead count at warning threshold', () => { + const ctx = baseContext(); + ctx.config.gitStatus = { + enabled: true, + showDirty: true, + showAheadBehind: true, + showFileStats: false, + pushWarningThreshold: 10, + pushCriticalThreshold: 20, + }; + ctx.gitStatus = { branch: 'main', isDirty: false, ahead: 12, behind: 0 }; + + const line = renderProjectLine(ctx); + assert.ok(line?.includes('\x1b[33m↑12\x1b[0m'), 'ahead count should use warning color'); +}); + +test('renderProjectLine colors ahead count at critical threshold', () => { + const ctx = baseContext(); + ctx.config.gitStatus = { + enabled: true, + showDirty: true, + showAheadBehind: true, + showFileStats: false, + pushWarningThreshold: 10, + pushCriticalThreshold: 20, + }; + ctx.gitStatus = { branch: 'main', isDirty: false, ahead: 25, behind: 0 }; + + const line = renderProjectLine(ctx); + assert.ok(line?.includes('\x1b[31m↑25\x1b[0m'), 'ahead count should use critical color'); +}); + test('renderGitFilesLine renders tracked files with per-file line diffs', () => { const ctx = baseContext(); ctx.stdin.cwd = '/tmp/my-project';