jarrodwatts--claude-hud
354 行
16 KiB
JavaScript
354 行
16 KiB
JavaScript
import { isLimitReached } from '../types.js';
|
|
import { getContextPercent, getBufferedPercent, getModelName, formatModelName, getProviderLabel, getTotalTokens } from '../stdin.js';
|
|
import { getOutputSpeed } from '../speed-tracker.js';
|
|
import { coloredBar, critical, git as gitColor, gitBranch as gitBranchColor, label, model as modelColor, project as projectColor, getContextColor, getQuotaColor, quotaBar, custom as customColor, RESET } from './colors.js';
|
|
import { getAdaptiveBarWidth } from '../utils/terminal.js';
|
|
import { renderCostEstimate } from './lines/cost.js';
|
|
import { renderPromptCacheLine } from './lines/prompt-cache.js';
|
|
import { t } from '../i18n/index.js';
|
|
import { formatResetTime } from './format-reset-time.js';
|
|
const DEBUG = process.env.DEBUG?.includes('claude-hud') || process.env.DEBUG === '*';
|
|
/**
|
|
* Renders the full session line (model + context bar + project + git + counts + usage + duration).
|
|
* Used for compact layout mode.
|
|
*/
|
|
export function renderSessionLine(ctx) {
|
|
const model = formatModelName(getModelName(ctx.stdin), ctx.config?.display?.modelFormat, ctx.config?.display?.modelOverride);
|
|
const rawPercent = getContextPercent(ctx.stdin);
|
|
const bufferedPercent = getBufferedPercent(ctx.stdin);
|
|
const autocompactMode = ctx.config?.display?.autocompactBuffer ?? 'enabled';
|
|
const percent = autocompactMode === 'disabled' ? rawPercent : bufferedPercent;
|
|
if (DEBUG && autocompactMode === 'disabled') {
|
|
console.error(`[claude-hud:context] autocompactBuffer=disabled, showing raw ${rawPercent}% (buffered would be ${bufferedPercent}%)`);
|
|
}
|
|
const colors = ctx.config?.colors;
|
|
const barWidth = getAdaptiveBarWidth();
|
|
const bar = coloredBar(percent, barWidth, colors);
|
|
const parts = [];
|
|
const display = ctx.config?.display;
|
|
const timeFormat = display?.timeFormat ?? 'relative';
|
|
const resetsKey = timeFormat === 'absolute' ? 'format.resets' : 'format.resetsIn';
|
|
const contextValueMode = display?.contextValue ?? 'percent';
|
|
const contextValue = formatContextValue(ctx, percent, contextValueMode);
|
|
const contextValueDisplay = `${getContextColor(percent, colors)}${contextValue}${RESET}`;
|
|
// Model and context bar (FIRST)
|
|
const providerLabel = getProviderLabel(ctx.stdin);
|
|
const modelQualifier = providerLabel ?? undefined;
|
|
let modelDisplay = modelQualifier ? `${model} | ${modelQualifier}` : model;
|
|
if (ctx.effortLevel && ctx.effortSymbol) {
|
|
modelDisplay += ` ${ctx.effortSymbol}${ctx.effortLevel}`;
|
|
}
|
|
else if (ctx.effortLevel) {
|
|
modelDisplay += ` ${ctx.effortLevel}`;
|
|
}
|
|
if (display?.showModel !== false && display?.showContextBar !== false) {
|
|
parts.push(`${modelColor(`[${modelDisplay}]`, colors)} ${bar} ${contextValueDisplay}`);
|
|
}
|
|
else if (display?.showModel !== false) {
|
|
parts.push(`${modelColor(`[${modelDisplay}]`, colors)} ${contextValueDisplay}`);
|
|
}
|
|
else if (display?.showContextBar !== false) {
|
|
parts.push(`${bar} ${contextValueDisplay}`);
|
|
}
|
|
else {
|
|
parts.push(contextValueDisplay);
|
|
}
|
|
// Project path + git status (SECOND)
|
|
let projectPart = null;
|
|
if (display?.showProject !== false && ctx.stdin.cwd) {
|
|
// Split by both Unix (/) and Windows (\) separators for cross-platform support
|
|
const segments = ctx.stdin.cwd.split(/[/\\]/).filter(Boolean);
|
|
const pathLevels = ctx.config?.pathLevels ?? 1;
|
|
// Always join with forward slash for consistent display
|
|
// Handle root path (/) which results in empty segments
|
|
const projectPath = segments.length > 0 ? segments.slice(-pathLevels).join('/') : '/';
|
|
projectPart = projectColor(projectPath, colors);
|
|
}
|
|
let gitPart = '';
|
|
const gitConfig = ctx.config?.gitStatus;
|
|
const showGit = gitConfig?.enabled ?? true;
|
|
const branchOverflow = gitConfig?.branchOverflow ?? 'truncate';
|
|
if (showGit && ctx.gitStatus) {
|
|
const gitParts = [ctx.gitStatus.branch];
|
|
// Show dirty indicator
|
|
if ((gitConfig?.showDirty ?? true) && ctx.gitStatus.isDirty) {
|
|
gitParts.push('*');
|
|
}
|
|
// Show ahead/behind (with space separator for readability)
|
|
if (gitConfig?.showAheadBehind) {
|
|
if (ctx.gitStatus.ahead > 0) {
|
|
gitParts.push(` ↑${ctx.gitStatus.ahead}`);
|
|
}
|
|
if (ctx.gitStatus.behind > 0) {
|
|
gitParts.push(` ↓${ctx.gitStatus.behind}`);
|
|
}
|
|
}
|
|
// Show file stats in Starship-compatible format (!modified +added ✘deleted ?untracked)
|
|
if (gitConfig?.showFileStats && ctx.gitStatus.fileStats) {
|
|
const { modified, added, deleted, untracked } = ctx.gitStatus.fileStats;
|
|
const statParts = [];
|
|
if (modified > 0)
|
|
statParts.push(`!${modified}`);
|
|
if (added > 0)
|
|
statParts.push(`+${added}`);
|
|
if (deleted > 0)
|
|
statParts.push(`✘${deleted}`);
|
|
if (untracked > 0)
|
|
statParts.push(`?${untracked}`);
|
|
if (statParts.length > 0) {
|
|
gitParts.push(` ${statParts.join(' ')}`);
|
|
}
|
|
}
|
|
gitPart = `${gitColor('git:(', colors)}${gitBranchColor(gitParts.join(''), colors)}${gitColor(')', colors)}`;
|
|
}
|
|
if (projectPart && gitPart) {
|
|
if (branchOverflow === 'wrap') {
|
|
parts.push(projectPart);
|
|
parts.push(gitPart);
|
|
}
|
|
else {
|
|
parts.push(`${projectPart} ${gitPart}`);
|
|
}
|
|
}
|
|
else if (projectPart) {
|
|
parts.push(projectPart);
|
|
}
|
|
else if (gitPart) {
|
|
parts.push(gitPart);
|
|
}
|
|
// Session name (custom title from /rename, or auto-generated slug)
|
|
if (display?.showSessionName && ctx.transcript.sessionName) {
|
|
parts.push(label(ctx.transcript.sessionName, colors));
|
|
}
|
|
if (display?.showClaudeCodeVersion && ctx.claudeCodeVersion) {
|
|
parts.push(label(`CC v${ctx.claudeCodeVersion}`, colors));
|
|
}
|
|
// Config counts (respects environmentThreshold)
|
|
if (display?.showConfigCounts !== false) {
|
|
const totalCounts = ctx.claudeMdCount + ctx.rulesCount + ctx.mcpCount + ctx.hooksCount;
|
|
const envThreshold = display?.environmentThreshold ?? 0;
|
|
if (totalCounts > 0 && totalCounts >= envThreshold) {
|
|
if (ctx.claudeMdCount > 0) {
|
|
parts.push(label(`${ctx.claudeMdCount} CLAUDE.md`, colors));
|
|
}
|
|
if (ctx.rulesCount > 0) {
|
|
parts.push(label(`${ctx.rulesCount} ${t('label.rules')}`, colors));
|
|
}
|
|
if (ctx.mcpCount > 0) {
|
|
parts.push(label(`${ctx.mcpCount} MCPs`, colors));
|
|
}
|
|
if (ctx.hooksCount > 0) {
|
|
parts.push(label(`${ctx.hooksCount} ${t('label.hooks')}`, colors));
|
|
}
|
|
}
|
|
}
|
|
// Usage limits display (shown when enabled in config, respects usageThreshold)
|
|
if (display?.showUsage !== false && ctx.usageData && !providerLabel) {
|
|
const usageCompact = display?.usageCompact ?? false;
|
|
const showResetLabel = display?.showResetLabel ?? true;
|
|
if (isLimitReached(ctx.usageData)) {
|
|
const resetTime = ctx.usageData.fiveHour === 100
|
|
? formatResetTime(ctx.usageData.fiveHourResetAt, timeFormat)
|
|
: formatResetTime(ctx.usageData.sevenDayResetAt, timeFormat);
|
|
if (usageCompact) {
|
|
parts.push(critical(`⚠ Limit${resetTime ? ` (${resetTime})` : ''}`, colors));
|
|
}
|
|
else {
|
|
const resetSuffix = resetTime
|
|
? showResetLabel
|
|
? ` (${t(resetsKey)} ${resetTime})`
|
|
: ` (${resetTime})`
|
|
: '';
|
|
parts.push(critical(`⚠ ${t('status.limitReached')}${resetSuffix}`, colors));
|
|
}
|
|
}
|
|
else {
|
|
const usageThreshold = display?.usageThreshold ?? 0;
|
|
const fiveHour = ctx.usageData.fiveHour;
|
|
const sevenDay = ctx.usageData.sevenDay;
|
|
const effectiveUsage = Math.max(fiveHour ?? 0, sevenDay ?? 0);
|
|
if (effectiveUsage >= usageThreshold) {
|
|
const usageBarEnabled = display?.usageBarEnabled ?? true;
|
|
if (usageCompact) {
|
|
const fiveHourPart = fiveHour !== null
|
|
? formatCompactWindowPart('5h', fiveHour, ctx.usageData.fiveHourResetAt, timeFormat, colors)
|
|
: null;
|
|
const sevenDayThreshold = display?.sevenDayThreshold ?? 80;
|
|
const sevenDayPart = (sevenDay !== null && (fiveHour === null || sevenDay >= sevenDayThreshold))
|
|
? formatCompactWindowPart('7d', sevenDay, ctx.usageData.sevenDayResetAt, timeFormat, colors)
|
|
: null;
|
|
if (fiveHourPart && sevenDayPart) {
|
|
parts.push(fiveHourPart);
|
|
parts.push(sevenDayPart);
|
|
}
|
|
else if (fiveHourPart) {
|
|
parts.push(fiveHourPart);
|
|
}
|
|
else if (sevenDayPart) {
|
|
parts.push(sevenDayPart);
|
|
}
|
|
}
|
|
else if (fiveHour === null && sevenDay !== null) {
|
|
const weeklyOnlyPart = formatUsageWindowPart({
|
|
label: t('label.weekly'),
|
|
percent: sevenDay,
|
|
resetAt: ctx.usageData.sevenDayResetAt,
|
|
colors,
|
|
usageBarEnabled,
|
|
barWidth,
|
|
timeFormat,
|
|
showResetLabel,
|
|
forceLabel: true,
|
|
});
|
|
parts.push(weeklyOnlyPart);
|
|
}
|
|
else {
|
|
const fiveHourPart = formatUsageWindowPart({
|
|
label: '5h',
|
|
percent: fiveHour,
|
|
resetAt: ctx.usageData.fiveHourResetAt,
|
|
colors,
|
|
usageBarEnabled,
|
|
barWidth,
|
|
timeFormat,
|
|
showResetLabel,
|
|
});
|
|
const sevenDayThreshold = display?.sevenDayThreshold ?? 80;
|
|
if (sevenDay !== null && sevenDay >= sevenDayThreshold) {
|
|
const sevenDayPart = formatUsageWindowPart({
|
|
label: t('label.weekly'),
|
|
percent: sevenDay,
|
|
resetAt: ctx.usageData.sevenDayResetAt,
|
|
colors,
|
|
usageBarEnabled,
|
|
barWidth,
|
|
timeFormat,
|
|
showResetLabel,
|
|
forceLabel: true,
|
|
});
|
|
parts.push(`${label(t('label.usage'), colors)} ${fiveHourPart}`);
|
|
parts.push(sevenDayPart);
|
|
}
|
|
else {
|
|
parts.push(`${label(t('label.usage'), colors)} ${fiveHourPart}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Session token usage (cumulative)
|
|
if (display?.showSessionTokens && ctx.transcript.sessionTokens) {
|
|
const st = ctx.transcript.sessionTokens;
|
|
const total = st.inputTokens + st.outputTokens + st.cacheCreationTokens + st.cacheReadTokens;
|
|
if (total > 0) {
|
|
parts.push(label(`tok: ${formatTokens(total)} (in: ${formatTokens(st.inputTokens)}, out: ${formatTokens(st.outputTokens)})`, colors));
|
|
}
|
|
}
|
|
if (display?.showDuration !== false && ctx.sessionDuration) {
|
|
parts.push(label(`⏱️ ${ctx.sessionDuration}`, colors));
|
|
}
|
|
const promptCacheLine = renderPromptCacheLine(ctx);
|
|
if (promptCacheLine) {
|
|
parts.push(promptCacheLine);
|
|
}
|
|
const costEstimate = renderCostEstimate(ctx);
|
|
if (costEstimate) {
|
|
parts.push(costEstimate);
|
|
}
|
|
if (display?.showSpeed) {
|
|
const speed = getOutputSpeed(ctx.stdin);
|
|
if (speed !== null) {
|
|
parts.push(label(`${t('format.out')}: ${speed.toFixed(1)} ${t('format.tokPerSec')}`, colors));
|
|
}
|
|
}
|
|
if (ctx.extraLabel) {
|
|
parts.push(label(ctx.extraLabel, colors));
|
|
}
|
|
// Custom line (static user-defined text)
|
|
const customLine = display?.customLine;
|
|
if (customLine) {
|
|
parts.push(customColor(customLine, colors));
|
|
}
|
|
let line = parts.join(' | ');
|
|
// Token breakdown at high context
|
|
if (display?.showTokenBreakdown !== false && percent >= 85) {
|
|
const usage = ctx.stdin.context_window?.current_usage;
|
|
if (usage) {
|
|
const input = formatTokens(usage.input_tokens ?? 0);
|
|
const cache = formatTokens((usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0));
|
|
line += label(` (${t('format.in')}: ${input}, ${t('format.cache')}: ${cache})`, colors);
|
|
}
|
|
}
|
|
return line;
|
|
}
|
|
function formatTokens(n) {
|
|
if (n >= 1000000) {
|
|
return `${(n / 1000000).toFixed(1)}M`;
|
|
}
|
|
if (n >= 1000) {
|
|
return `${(n / 1000).toFixed(0)}k`;
|
|
}
|
|
return n.toString();
|
|
}
|
|
function formatContextValue(ctx, percent, mode) {
|
|
const totalTokens = getTotalTokens(ctx.stdin);
|
|
const size = ctx.stdin.context_window?.context_window_size ?? 0;
|
|
if (mode === 'tokens') {
|
|
if (size > 0) {
|
|
return `${formatTokens(totalTokens)}/${formatTokens(size)}`;
|
|
}
|
|
return formatTokens(totalTokens);
|
|
}
|
|
if (mode === 'both') {
|
|
if (size > 0) {
|
|
return `${percent}% (${formatTokens(totalTokens)}/${formatTokens(size)})`;
|
|
}
|
|
return `${percent}%`;
|
|
}
|
|
if (mode === 'remaining') {
|
|
return `${Math.max(0, 100 - percent)}%`;
|
|
}
|
|
return `${percent}%`;
|
|
}
|
|
function formatCompactWindowPart(windowLabel, percent, resetAt, timeFormat, colors) {
|
|
const usageDisplay = formatUsagePercent(percent, colors);
|
|
const reset = formatResetTime(resetAt, timeFormat);
|
|
const styledLabel = label(`${windowLabel}:`, colors);
|
|
return reset
|
|
? `${styledLabel} ${usageDisplay} ${label(`(${reset})`, colors)}`
|
|
: `${styledLabel} ${usageDisplay}`;
|
|
}
|
|
function formatUsagePercent(percent, colors) {
|
|
if (percent === null) {
|
|
return label('--', colors);
|
|
}
|
|
const color = getQuotaColor(percent, colors);
|
|
return `${color}${percent}%${RESET}`;
|
|
}
|
|
function formatUsageWindowPart({ label: windowLabel, percent, resetAt, colors, usageBarEnabled, barWidth, timeFormat = 'relative', showResetLabel, forceLabel = false, }) {
|
|
const usageDisplay = formatUsagePercent(percent, colors);
|
|
const reset = formatResetTime(resetAt, timeFormat);
|
|
const styledLabel = label(windowLabel, colors);
|
|
// "resets in X" for relative/both; "resets X" for absolute (avoids "resets in at 14:30")
|
|
const resetsKey = timeFormat === 'absolute' ? 'format.resets' : 'format.resetsIn';
|
|
if (usageBarEnabled) {
|
|
// Relative mode keeps the upstream "(duration / windowLabel)" pattern (e.g. "2h 30m / 5h").
|
|
// Absolute/both modes use the preposition form instead — "(at 14:30 / 5h)" is incoherent.
|
|
const barReset = timeFormat === 'relative'
|
|
? (reset ? `${reset} / ${windowLabel}` : null)
|
|
: (reset ? (showResetLabel ? `${t(resetsKey)} ${reset}` : reset) : null);
|
|
const body = barReset
|
|
? `${quotaBar(percent ?? 0, barWidth, colors)} ${usageDisplay} (${barReset})`
|
|
: `${quotaBar(percent ?? 0, barWidth, colors)} ${usageDisplay}`;
|
|
return forceLabel ? `${styledLabel} ${body}` : body;
|
|
}
|
|
const resetSuffix = reset
|
|
? showResetLabel
|
|
? `(${t(resetsKey)} ${reset})`
|
|
: `(${reset})`
|
|
: '';
|
|
return resetSuffix
|
|
? `${styledLabel} ${usageDisplay} ${resetSuffix}`
|
|
: `${styledLabel} ${usageDisplay}`;
|
|
}
|
|
//# sourceMappingURL=session-line.js.map
|