feat: add git push count threshold coloring (#292) (#372)

This commit is contained in:
Jarrod Watts
2026-04-04 15:24:07 +11:00
committed by GitHub
parent 1d8b1ac491
commit 2cc8f28d21
5 changed files with 90 additions and 3 deletions

View File

@@ -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 `████░░░░░░` |

View File

@@ -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>): HudConfig {
const migrated = migrateConfig(userConfig);
const language = validateLanguage(migrated.language)
@@ -296,6 +307,8 @@ export function mergeConfig(userConfig: Partial<HudConfig>): 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 = {

View File

@@ -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;

View File

@@ -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);

View File

@@ -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';