UNPKG

@owloops/claude-powerline

Version:

Beautiful vim-style powerline statusline for Claude Code with real-time usage tracking, git integration, and custom themes

1,512 lines (1,375 loc) 40.2 kB
import type { PowerlineConfig } from "../config/loader"; import type { PowerlineColors } from "../themes"; import type { TuiData, SymbolSet, BoxChars, RenderCtx, SegmentTemplate, JustifyValue, TuiTitleConfig, } from "./types"; import { visibleLength } from "../utils/terminal"; import { formatCost, formatTokenCount, collapseHome, formatDuration, formatModelName, formatResponseTime, formatTimeRemaining, formatLongTimeRemaining, minutesUntilReset, abbreviateFishStyle, formatCacheTimerElapsed, } from "../utils/formatters"; import { resolveBudgetDisplay } from "../utils/budget"; import { colorize, truncateAnsi } from "./primitives"; import { getEffortLevel, getThinkingEnabled } from "../utils/claude"; import { resolveIconVisibility } from "../utils/icon-visibility"; export function resolveTitleToken( template: string, data: TuiData, resolvedData?: Record<string, string>, ): string { const rawName = data.hookData.model?.display_name || "Claude"; const modelName = formatModelName(rawName).toLowerCase(); return template.replace(/\{([^}]+)\}/g, (_match, token: string) => { if (resolvedData) { const value = resolvedData[token]; if (value !== undefined) return value; } if (token === "model") return modelName; return ""; }); } export function buildTitleBar( data: TuiData, box: BoxChars, innerWidth: number, titleConfig?: TuiTitleConfig, resolvedData?: Record<string, string>, ): string { const leftTemplate = titleConfig?.left ?? "{model}"; const rightTemplate = titleConfig?.right; const leftResolved = resolveTitleToken(leftTemplate, data, resolvedData); const leftText = leftResolved ? ` ${leftResolved} ` : ""; const leftLen = visibleLength(leftText); if (!rightTemplate) { const simpleFill = innerWidth - leftLen; return ( box.topLeft + leftText + box.horizontal.repeat(Math.max(0, simpleFill)) + box.topRight ); } const rightResolved = resolveTitleToken(rightTemplate, data, resolvedData); const rightText = rightResolved ? ` ${rightResolved} ` : ""; const rightLen = visibleLength(rightText); // Truncate if combined text exceeds innerWidth let finalLeft = leftText; let finalLeftLen = leftLen; let finalRight = rightText; let finalRightLen = rightLen; if (finalLeftLen + finalRightLen > innerWidth) { const maxLeft = Math.max(0, innerWidth - finalRightLen); if (finalLeftLen > maxLeft) { finalLeft = truncateAnsi(finalLeft, maxLeft); finalLeftLen = visibleLength(finalLeft); } if (finalLeftLen + finalRightLen > innerWidth) { const maxRight = Math.max(0, innerWidth - finalLeftLen); finalRight = truncateAnsi(finalRight, maxRight); finalRightLen = visibleLength(finalRight); } } const fillCount = innerWidth - finalLeftLen - finalRightLen; if (fillCount < 2) { const simpleFill = innerWidth - finalLeftLen; return ( box.topLeft + finalLeft + box.horizontal.repeat(Math.max(0, simpleFill)) + box.topRight ); } return ( box.topLeft + finalLeft + box.horizontal.repeat(fillCount) + finalRight + box.topRight ); } function resolveThresholdStyle( pct: number, defaultFg: string, defaultBold: boolean, colors: PowerlineColors, warningAt = 60, criticalAt = 80, ): { fg: string; bold: boolean } { if (pct >= criticalAt) return { fg: colors.contextCriticalFg, bold: colors.contextCriticalBold }; if (pct >= warningAt) return { fg: colors.contextWarningFg, bold: colors.contextWarningBold }; return { fg: defaultFg, bold: defaultBold }; } function buildBarString( pct: number, barWidth: number, sym: SymbolSet, reset: string, fgColor: string, bold = false, ): string { barWidth = Math.max(5, barWidth); const filledCount = Math.max( 0, Math.min(barWidth, Math.round((pct / 100) * barWidth)), ); const emptyCount = barWidth - filledCount; const bar = sym.bar_filled.repeat(filledCount) + sym.bar_empty.repeat(emptyCount); return colorize(bar, fgColor, reset, bold); } export function formatContextParts( data: TuiData, sym: SymbolSet, iconVisible = true, ): Record<string, string> { if (!data.contextInfo) return { icon: "", label: "context", bar: "", pct: "", tokens: "" }; const usedPct = data.contextInfo.usablePercentage; const tokenStr = formatTokenCount(data.contextInfo.totalTokens); const maxStr = formatTokenCount(data.contextInfo.maxTokens); return { icon: iconVisible ? sym.context_time : "", label: "context", bar: " ", pct: `${usedPct}%`, tokens: `${tokenStr}/${maxStr}`, }; } export function buildContextBar( data: TuiData, barWidth: number, sym: SymbolSet, reset: string, colors: PowerlineColors, partFg?: Record<string, string>, ): string { if (!data.contextInfo) return ""; const usedPct = data.contextInfo.usablePercentage; const defaultFg = partFg?.["context.bar"] ?? partFg?.["context"] ?? colors.contextFg; const { fg, bold } = resolveThresholdStyle( usedPct, defaultFg, colors.contextBold, colors, ); return buildBarString(usedPct, barWidth, sym, reset, fg, bold); } export function buildBlockBar( data: TuiData, barWidth: number, sym: SymbolSet, reset: string, colors: PowerlineColors, config: PowerlineConfig, partFg?: Record<string, string>, ): string { if (!data.blockInfo) return ""; const pct = data.blockInfo.nativeUtilization; const warningThreshold = config.budget?.block?.warningThreshold ?? 80; const defaultFg = partFg?.["block.bar"] ?? partFg?.["block"] ?? colors.blockFg; const { fg, bold } = resolveThresholdStyle( pct, defaultFg, colors.blockBold, colors, 50, warningThreshold, ); return buildBarString(pct, barWidth, sym, reset, fg, bold); } export function buildWeeklyBar( data: TuiData, barWidth: number, sym: SymbolSet, reset: string, colors: PowerlineColors, partFg?: Record<string, string>, ): string { const sevenDay = data.hookData.rate_limits?.seven_day; if (!sevenDay) return ""; const pct = sevenDay.used_percentage; const defaultFg = partFg?.["weekly.bar"] ?? partFg?.["weekly"] ?? colors.weeklyFg; const { fg, bold } = resolveThresholdStyle( pct, defaultFg, colors.weeklyBold, colors, ); return buildBarString(pct, barWidth, sym, reset, fg, bold); } export function buildContextLine( data: TuiData, contentWidth: number, sym: SymbolSet, reset: string, colors: PowerlineColors, ): string | null { if (!data.contextInfo) { return null; } const usedPct = data.contextInfo.usablePercentage; const tokenStr = formatTokenCount(data.contextInfo.totalTokens); const maxStr = formatTokenCount(data.contextInfo.maxTokens); const suffix = ` ${usedPct}% ${tokenStr}/${maxStr}`; const barLen = Math.max(5, contentWidth - suffix.length); const filledCount = Math.max( 0, Math.min(barLen, Math.round((usedPct / 100) * barLen)), ); const emptyCount = barLen - filledCount; const bar = sym.bar_filled.repeat(filledCount) + sym.bar_empty.repeat(emptyCount); const { fg, bold } = resolveThresholdStyle( usedPct, colors.contextFg, colors.contextBold, colors, ); return colorize(`${bar}${suffix}`, fg, reset, bold); } function getDirectoryDisplay(hookData: TuiData["hookData"]): string { const currentDir = hookData.workspace?.current_dir || hookData.cwd || "/"; return collapseHome(currentDir); } export function collectMetricSegments( data: TuiData, sym: SymbolSet, config: PowerlineConfig, reset: string, colors: PowerlineColors, ): string[] { const segments: string[] = []; if (data.blockInfo) { segments.push( colorize( formatBlockSegment( data.blockInfo, sym, config, resolveIconVisibility(config, "block"), ), colors.blockFg, reset, colors.blockBold, ), ); } const sevenDay = data.hookData.rate_limits?.seven_day; if (sevenDay) { segments.push( colorize( formatWeeklySegment( sevenDay, sym, resolveIconVisibility(config, "weekly"), ), colors.weeklyFg, reset, colors.weeklyBold, ), ); } if (data.usageInfo) { const sessionStr = formatSessionSegment( data.usageInfo, sym, config, resolveIconVisibility(config, "session"), ); if (sessionStr) { segments.push( colorize(sessionStr, colors.sessionFg, reset, colors.sessionBold), ); } } if (data.todayInfo) { const todayStr = formatTodaySegment( data.todayInfo, sym, config, resolveIconVisibility(config, "today"), ); if (todayStr) { segments.push( colorize(todayStr, colors.todayFg, reset, colors.todayBold), ); } } const activityParts = collectActivityParts(data, sym); if (activityParts.length > 0) { segments.push( colorize( activityParts.join(" · "), colors.metricsFg, reset, colors.metricsBold, ), ); } return segments; } export function collectActivityParts(data: TuiData, sym: SymbolSet): string[] { const parts: string[] = []; if (data.metricsInfo) { if ( data.metricsInfo.sessionDuration !== null && data.metricsInfo.sessionDuration > 0 ) { parts.push( `${sym.metrics_duration} ${formatDuration(data.metricsInfo.sessionDuration)}`, ); } if ( data.metricsInfo.messageCount !== null && data.metricsInfo.messageCount > 0 ) { parts.push(`${sym.metrics_messages} ${data.metricsInfo.messageCount}`); } } return parts; } export function collectWorkspaceParts( data: TuiData, sym: SymbolSet, reset: string, colors: PowerlineColors, config: PowerlineConfig, ): string[] { const parts: string[] = []; const gitStr = formatGitSegment( data, sym, resolveIconVisibility(config, "git"), ); if (gitStr) parts.push(colorize(gitStr, colors.gitFg, reset, colors.gitBold)); const dir = abbreviateFishStyle(getDirectoryDisplay(data.hookData)); parts.push(colorize(dir, colors.modeFg, reset, colors.modeBold)); return parts; } export function collectFooterParts( data: TuiData, sym: SymbolSet, config: PowerlineConfig, reset: string, colors: PowerlineColors, ): string[] { const parts: string[] = []; const versionText = formatVersionSegment( data, sym, resolveIconVisibility(config, "version"), ); if (versionText) { parts.push( colorize(versionText, colors.versionFg, reset, colors.versionBold), ); } const thinkingSegConfig = config.display.lines .map((line) => line.segments.thinking) .find((t) => t?.enabled); const thinkingText = formatThinkingSegment( data, sym, thinkingSegConfig, resolveIconVisibility(config, "thinking"), ); if (thinkingText) { parts.push( colorize(thinkingText, colors.thinkingFg, reset, colors.thinkingBold), ); } const cacheTimerEnabled = config.display.lines.some( (line) => line.segments.cacheTimer?.enabled, ); if (cacheTimerEnabled && data.cacheTimerInfo) { const cacheTimerText = formatCacheTimerSegment( data, sym, resolveIconVisibility(config, "cacheTimer"), ); if (cacheTimerText) { const { fg, bold } = cacheTimerStyle( data.cacheTimerInfo.elapsedSeconds, colors, ); parts.push(colorize(cacheTimerText, fg, reset, bold)); } } if (data.tmuxSessionId) { parts.push( colorize( `tmux:${data.tmuxSessionId}`, colors.tmuxFg, reset, colors.tmuxBold, ), ); } if (data.metricsInfo) { const metricParts: string[] = []; if ( data.metricsInfo.responseTime !== null && !isNaN(data.metricsInfo.responseTime) && data.metricsInfo.responseTime > 0 ) { metricParts.push( `${sym.metrics_response} ${formatResponseTime(data.metricsInfo.responseTime)}`, ); } if ( data.metricsInfo.linesAdded !== null && data.metricsInfo.linesAdded > 0 ) { metricParts.push( `${sym.metrics_lines_added}${data.metricsInfo.linesAdded}`, ); } if ( data.metricsInfo.linesRemoved !== null && data.metricsInfo.linesRemoved > 0 ) { metricParts.push( `${sym.metrics_lines_removed}${data.metricsInfo.linesRemoved}`, ); } if (metricParts.length > 0) { parts.push( colorize( metricParts.join(" · "), colors.metricsFg, reset, colors.metricsBold, ), ); } } const envConfig = config.display.lines .map((line) => line.segments.env) .find((env) => env?.enabled); if (envConfig && envConfig.variable) { const envVal = globalThis.process?.env?.[envConfig.variable]; if (envVal) { const prefix = envConfig.prefix ?? envConfig.variable; parts.push( colorize( prefix ? `${prefix}:${envVal}` : envVal, colors.envFg, reset, colors.envBold, ), ); } } return parts; } export function formatBlockParts( blockInfo: TuiData["blockInfo"] & {}, sym: SymbolSet, _config: PowerlineConfig, iconVisible = true, ): Record<string, string> { const value = `${Math.round(blockInfo.nativeUtilization)}%`; const time = formatTimeRemaining(blockInfo.timeRemaining); return { icon: iconVisible ? sym.block_cost : "", label: "block", value, time, budget: "", bar: " ", }; } export function formatBlockSegment( blockInfo: TuiData["blockInfo"] & {}, sym: SymbolSet, config: PowerlineConfig, iconVisible = true, ): string { const parts = formatBlockParts(blockInfo, sym, config, iconVisible); let text = parts.icon ? `${parts.icon} ${parts.value}` : (parts.value ?? ""); if (parts.time) text += ` · ${parts.time}`; if (parts.budget) text += parts.budget; return text; } export function formatWeeklyParts( sevenDay: { used_percentage: number; resets_at: number }, sym: SymbolSet, iconVisible = true, ): Record<string, string> { const pct = `${Math.round(sevenDay.used_percentage)}%`; const time = formatLongTimeRemaining(minutesUntilReset(sevenDay.resets_at)); return { icon: iconVisible ? sym.weekly_cost : "", label: "weekly", pct, time, bar: " ", }; } export function formatWeeklySegment( sevenDay: { used_percentage: number; resets_at: number }, sym: SymbolSet, iconVisible = true, ): string { const parts = formatWeeklyParts(sevenDay, sym, iconVisible); let text = parts.icon ? `${parts.icon} ${parts.pct}` : (parts.pct ?? ""); if (parts.time) text += ` · ${parts.time}`; return text; } export function formatSessionParts( usageInfo: TuiData["usageInfo"] & {}, sym: SymbolSet, config: PowerlineConfig, iconVisible = true, ): Record<string, string> { const state = resolveBudgetDisplay( usageInfo.session.cost, usageInfo.session.tokens, config.budget?.session, ); if (state.suppressAll) { return { icon: "", label: "", cost: "", tokens: "", budget: "" }; } const sessionTokens = usageInfo.session.tokens; const tokenStr = state.showBase && sessionTokens !== null && sessionTokens > 0 ? formatTokenCount(sessionTokens) : ""; return { icon: iconVisible ? sym.session_cost : "", label: state.percentageOnly ? "" : "session", cost: state.showBase ? formatCost(usageInfo.session.cost) : "", tokens: tokenStr, budget: state.percentText ? ` ${state.percentText}` : "", }; } export function formatSessionSegment( usageInfo: TuiData["usageInfo"] & {}, sym: SymbolSet, config: PowerlineConfig, iconVisible = true, ): string { const state = resolveBudgetDisplay( usageInfo.session.cost, usageInfo.session.tokens, config.budget?.session, ); if (state.suppressAll) return ""; const icon = iconVisible ? sym.session_cost : ""; if (!state.showBase) { return icon ? `${icon} ${state.percentText}` : state.percentText; } const costStr = formatCost(usageInfo.session.cost); const sessionTokens = usageInfo.session.tokens; let text = icon ? `${icon} ${costStr}` : costStr; if (sessionTokens !== null && sessionTokens > 0) { text += ` · ${formatTokenCount(sessionTokens)}`; } if (state.percentText) text += ` ${state.percentText}`; return text; } export function formatTodayParts( todayInfo: TuiData["todayInfo"] & {}, sym: SymbolSet, config: PowerlineConfig, iconVisible = true, ): Record<string, string> { const state = resolveBudgetDisplay( todayInfo.cost, todayInfo.tokens, config.budget?.today, ); if (state.suppressAll) { return { icon: "", label: "", cost: "", budget: "" }; } return { icon: iconVisible ? sym.today_cost : "", cost: state.showBase ? formatCost(todayInfo.cost) : "", label: state.percentageOnly ? "" : "today", budget: state.percentText ? ` ${state.percentText}` : "", }; } export function formatTodaySegment( todayInfo: TuiData["todayInfo"] & {}, sym: SymbolSet, config: PowerlineConfig, iconVisible = true, ): string { const state = resolveBudgetDisplay( todayInfo.cost, todayInfo.tokens, config.budget?.today, ); if (state.suppressAll) return ""; const icon = iconVisible ? sym.today_cost : ""; if (!state.showBase) { return icon ? `${icon} ${state.percentText}` : state.percentText; } const costStr = formatCost(todayInfo.cost); let text = icon ? `${icon} ${costStr} today` : `${costStr} today`; if (state.percentText) text += ` ${state.percentText}`; return text; } function formatMetricsParts( data: TuiData, sym: SymbolSet, ): Record<string, string> { const empty = { response: "", responseIcon: "", responseVal: "", lastResponse: "", lastResponseIcon: "", lastResponseVal: "", added: "", addedIcon: "", addedVal: "", removed: "", removedIcon: "", removedVal: "", }; if (!data.metricsInfo) return empty; const hasResponse = data.metricsInfo.responseTime !== null && !isNaN(data.metricsInfo.responseTime) && data.metricsInfo.responseTime > 0; const responseValStr = hasResponse ? formatResponseTime(data.metricsInfo.responseTime!) : ""; const hasLast = data.metricsInfo.lastResponseTime !== null && !isNaN(data.metricsInfo.lastResponseTime) && data.metricsInfo.lastResponseTime > 0; const lastValStr = hasLast ? formatResponseTime(data.metricsInfo.lastResponseTime!) : ""; const hasAdded = data.metricsInfo.linesAdded !== null && data.metricsInfo.linesAdded > 0; const addedValStr = hasAdded ? `${data.metricsInfo.linesAdded}` : ""; const hasRemoved = data.metricsInfo.linesRemoved !== null && data.metricsInfo.linesRemoved > 0; const removedValStr = hasRemoved ? `${data.metricsInfo.linesRemoved}` : ""; return { response: hasResponse ? `${sym.metrics_response} ${responseValStr}` : "", responseIcon: hasResponse ? sym.metrics_response : "", responseVal: responseValStr, lastResponse: hasLast ? `${sym.metrics_last_response} ${lastValStr}` : `${sym.metrics_last_response} --`, lastResponseIcon: sym.metrics_last_response, lastResponseVal: hasLast ? lastValStr : "--", added: hasAdded ? `${sym.metrics_lines_added}${addedValStr}` : "", addedIcon: hasAdded ? sym.metrics_lines_added : "", addedVal: addedValStr, removed: hasRemoved ? `${sym.metrics_lines_removed}${removedValStr}` : "", removedIcon: hasRemoved ? sym.metrics_lines_removed : "", removedVal: removedValStr, }; } function formatMetricsSegment(data: TuiData, sym: SymbolSet): string { const parts = formatMetricsParts(data, sym); const filled = [ parts.response, parts.lastResponse, parts.added, parts.removed, ].filter(Boolean); return filled.length > 0 ? filled.join(" · ") : ""; } function formatActivityParts( data: TuiData, sym: SymbolSet, ): Record<string, string> { const empty = { icon: "", duration: "", durationIcon: "", durationVal: "", messages: "", messagesIcon: "", messagesVal: "", }; if (!data.metricsInfo) return empty; const hasDuration = data.metricsInfo.sessionDuration !== null && data.metricsInfo.sessionDuration > 0; const durationValStr = hasDuration ? formatDuration(data.metricsInfo.sessionDuration!) : ""; const hasMessages = data.metricsInfo.messageCount !== null && data.metricsInfo.messageCount > 0; const messagesValStr = hasMessages ? `${data.metricsInfo.messageCount}` : ""; return { icon: sym.activity, duration: hasDuration ? `${sym.metrics_duration} ${durationValStr}` : "", durationIcon: hasDuration ? sym.metrics_duration : "", durationVal: durationValStr, messages: hasMessages ? `${sym.metrics_messages} ${messagesValStr}` : "", messagesIcon: hasMessages ? sym.metrics_messages : "", messagesVal: messagesValStr, }; } function formatActivitySegment(data: TuiData, sym: SymbolSet): string { const parts = formatActivityParts(data, sym); const filled = [parts.duration, parts.messages].filter(Boolean); return filled.length > 0 ? filled.join(" · ") : ""; } function formatGitParts( data: TuiData, sym: SymbolSet, iconVisible = true, ): Record<string, string> { if (!data.gitInfo) return { icon: "", headVal: "", branch: "", status: "", ahead: "", behind: "", working: "", head: "", }; let statusIcon: string; if (data.gitInfo.status === "conflicts") { statusIcon = sym.git_conflicts; } else if (data.gitInfo.status === "dirty") { statusIcon = sym.git_dirty; } else { statusIcon = sym.git_clean; } const ahead = data.gitInfo.ahead > 0 ? `${sym.git_ahead}${data.gitInfo.ahead}` : ""; const behind = data.gitInfo.behind > 0 ? `${sym.git_behind}${data.gitInfo.behind}` : ""; const counts: string[] = []; if (data.gitInfo.staged && data.gitInfo.staged > 0) counts.push(`+${data.gitInfo.staged}`); if (data.gitInfo.unstaged && data.gitInfo.unstaged > 0) counts.push(`~${data.gitInfo.unstaged}`); if (data.gitInfo.untracked && data.gitInfo.untracked > 0) counts.push(`?${data.gitInfo.untracked}`); const working = counts.length > 0 ? `(${counts.join(" ")})` : ""; const headParts: string[] = []; if (iconVisible) headParts.push(sym.branch); headParts.push(data.gitInfo.branch, statusIcon); if (ahead) headParts.push(ahead); if (behind) headParts.push(behind); const infoParts = [data.gitInfo.branch, statusIcon]; if (ahead) infoParts.push(ahead); if (behind) infoParts.push(behind); return { icon: iconVisible ? sym.branch : "", headVal: infoParts.join(" "), branch: data.gitInfo.branch, status: statusIcon, ahead, behind, working, head: headParts.join(" "), }; } function formatGitSegment( data: TuiData, sym: SymbolSet, iconVisible = true, ): string { const parts = formatGitParts(data, sym, iconVisible); if (!parts.branch) return ""; let text = parts.icon ? `${parts.icon} ${parts.branch} ${parts.status}` : `${parts.branch} ${parts.status}`; if (parts.ahead) text += ` ${parts.ahead}`; if (parts.behind) text += `${parts.behind}`; if (parts.working) text += ` ${parts.working}`; return text; } function formatDirParts( data: TuiData, config: PowerlineConfig, sym: SymbolSet, iconVisible = true, ): Record<string, string> { return { icon: iconVisible ? sym.dir : "", value: formatDirValue(data, config), }; } function formatDirValue(data: TuiData, config: PowerlineConfig): string { const raw = getDirectoryDisplay(data.hookData); const dirConfig = config.display.lines .map((line) => line.segments.directory) .find((d) => d?.enabled); const style = dirConfig?.style ?? (dirConfig?.showBasename ? "basename" : "fish"); if (style === "basename") { const sep = raw.includes("/") ? "/" : "\\"; return raw.split(sep).pop() || raw; } if (style === "full") return raw; return abbreviateFishStyle(raw); } function formatVersionParts( data: TuiData, sym: SymbolSet, iconVisible = true, ): Record<string, string> { if (!data.hookData.version) return { icon: "", value: "" }; return { icon: iconVisible ? sym.version : "", value: `v${data.hookData.version}`, }; } function formatVersionSegment( data: TuiData, sym: SymbolSet, iconVisible = true, ): string { const parts = formatVersionParts(data, sym, iconVisible); if (!parts.value) return ""; return parts.icon ? `${parts.icon} ${parts.value}` : parts.value; } function formatAgentParts( data: TuiData, sym: SymbolSet, iconVisible = true, ): Record<string, string> { const raw = data.hookData.agent?.name; if (typeof raw !== "string") return { icon: "", name: "" }; const name = raw.trim(); if (!name) return { icon: "", name: "" }; return { icon: iconVisible ? sym.agent : "", name, }; } function formatAgentSegment( data: TuiData, sym: SymbolSet, config: PowerlineConfig, iconVisible = true, ): string { const parts = formatAgentParts(data, sym, iconVisible); if (!parts.name) return ""; const agentConfig = config.display.lines .map((line) => line.segments.agent) .find((a) => a?.enabled); const body = agentConfig?.showLabel ? `agent: ${parts.name}` : parts.name; return parts.icon ? `${parts.icon} ${body}` : body; } function buildThinkingBody( data: TuiData, thinkingConfig: { showEnabled?: boolean; showEffort?: boolean } | undefined, ): string { const showEnabled = thinkingConfig?.showEnabled ?? true; const showEffort = thinkingConfig?.showEffort ?? true; if (!showEnabled && !showEffort) return ""; const enabled = showEnabled ? getThinkingEnabled(data.hookData) : null; const level = showEffort ? getEffortLevel(data.hookData) : null; const segments: string[] = []; if (enabled !== null) segments.push(enabled ? "On" : "Off"); if (level) segments.push(level); return segments.join(" · "); } function formatThinkingParts( data: TuiData, sym: SymbolSet, thinkingConfig: { showEnabled?: boolean; showEffort?: boolean } | undefined, iconVisible = true, ): Record<string, string> { const showEnabled = thinkingConfig?.showEnabled ?? true; const showEffort = thinkingConfig?.showEffort ?? true; const enabled = showEnabled ? getThinkingEnabled(data.hookData) : null; const level = showEffort ? getEffortLevel(data.hookData) : null; const enabledText = enabled === null ? "" : enabled ? "On" : "Off"; const effortText = level ?? ""; const hasAny = enabledText !== "" || effortText !== ""; return { icon: hasAny && iconVisible ? sym.thinking : "", enabled: enabledText, effort: effortText, }; } function formatThinkingSegment( data: TuiData, sym: SymbolSet, thinkingConfig: { showEnabled?: boolean; showEffort?: boolean } | undefined, iconVisible = true, ): string { const body = buildThinkingBody(data, thinkingConfig); if (!body) return ""; return iconVisible ? `${sym.thinking} ${body}` : body; } function formatCacheTimerParts( data: TuiData, sym: SymbolSet, iconVisible = true, ): Record<string, string> { if (!data.cacheTimerInfo) return { icon: "", value: "" }; return { icon: iconVisible ? sym.cache_timer : "", value: formatCacheTimerElapsed(data.cacheTimerInfo.elapsedSeconds), }; } function formatCacheTimerSegment( data: TuiData, sym: SymbolSet, iconVisible = true, ): string { const parts = formatCacheTimerParts(data, sym, iconVisible); if (!parts.value) return ""; return parts.icon ? `${parts.icon} ${parts.value}` : parts.value; } function cacheTimerStyle( elapsed: number, colors: PowerlineColors, ): { fg: string; bold: boolean } { if (elapsed >= 300) { return { fg: colors.contextCriticalFg, bold: colors.contextCriticalBold }; } if (elapsed >= 180) { return { fg: colors.contextWarningFg, bold: colors.contextWarningBold }; } return { fg: colors.cacheTimerFg, bold: colors.cacheTimerBold }; } function formatTmuxParts(data: TuiData): Record<string, string> { if (!data.tmuxSessionId) return { label: "", value: "" }; return { label: "tmux", value: data.tmuxSessionId }; } function formatTmuxSegment(data: TuiData): string { const parts = formatTmuxParts(data); if (!parts.label) return ""; return `${parts.label}:${parts.value}`; } function formatEnvParts(config: PowerlineConfig): Record<string, string> { const envConfig = config.display.lines .map((line) => line.segments.env) .find((env) => env?.enabled); if (!envConfig || !envConfig.variable) return { prefix: "", value: "" }; const envVal = globalThis.process?.env?.[envConfig.variable]; if (!envVal) return { prefix: "", value: "" }; const prefix = envConfig.prefix ?? envConfig.variable; return { prefix: prefix || "", value: envVal }; } function formatEnvSegment(config: PowerlineConfig): string { const parts = formatEnvParts(config); if (!parts.value) return ""; return parts.prefix ? `${parts.prefix}:${parts.value}` : parts.value; } function addParts( result: Record<string, string>, segment: string, parts: Record<string, string>, color: string, reset: string, partFg?: Record<string, string>, bold = false, ): void { for (const [key, value] of Object.entries(parts)) { const partKey = `${segment}.${key}`; const partColor = partFg?.[partKey] ?? partFg?.[segment] ?? color; result[partKey] = value ? colorize(value, partColor, reset, bold) : ""; } } // --- Template Composition --- export interface ResolvedTemplate { items: string[]; gap: number; justify: JustifyValue; } function resolveTemplateItems( template: SegmentTemplate, segmentRef: string, resolvedData: Record<string, string>, ): string[] { const dotIdx = segmentRef.indexOf("."); const baseSegment = dotIdx !== -1 ? segmentRef.slice(0, dotIdx) : segmentRef; return template.items .map((item) => { const match = item.match(/^\{(.+)\}$/); if (!match) return item ? colorize(item, "", "") : ""; const partName = match[1]!; const key = `${baseSegment}.${partName}`; return resolvedData[key] ?? ""; }) .filter(Boolean); } export function composeTemplate( items: string[], gap: number, justify: JustifyValue, cellWidth?: number, ): string { if (items.length === 0) return ""; if (justify === "between" && cellWidth !== undefined && items.length > 1) { const totalContent = items.reduce( (sum, item) => sum + visibleLength(item), 0, ); const totalGap = Math.max( gap * (items.length - 1), cellWidth - totalContent, ); const baseGap = Math.floor(totalGap / (items.length - 1)); const extraSpaces = totalGap % (items.length - 1); let result = items[0]!; for (let i = 1; i < items.length; i++) { result += " ".repeat(baseGap + (i <= extraSpaces ? 1 : 0)) + items[i]; } return result; } return items.join(" ".repeat(gap)); } export interface ResolvedSegments { data: Record<string, string>; templates: Record<string, ResolvedTemplate>; } export function resolveSegments( data: TuiData, ctx: RenderCtx, ): ResolvedSegments { const { sym, config, reset, colors } = ctx; const pf = colors.partFg; const colorizeOrEmpty = ( text: string, color: string, bold = false, ): string => (text ? colorize(text, color, reset, bold) : ""); const result: Record<string, string> = {}; const iconVisible = { model: resolveIconVisibility(config, "model"), context: resolveIconVisibility(config, "context"), block: resolveIconVisibility(config, "block"), session: resolveIconVisibility(config, "session"), today: resolveIconVisibility(config, "today"), weekly: resolveIconVisibility(config, "weekly"), git: resolveIconVisibility(config, "git"), directory: resolveIconVisibility(config, "directory"), version: resolveIconVisibility(config, "version"), agent: resolveIconVisibility(config, "agent"), thinking: resolveIconVisibility(config, "thinking"), cacheTimer: resolveIconVisibility(config, "cacheTimer"), }; // Model const rawModelName = data.hookData.model?.display_name || "Claude"; const modelName = formatModelName(rawModelName).toLowerCase(); const modelColor = pf?.["model"] ?? colors.modelFg; const modelIcon = iconVisible.model ? sym.model : ""; result.model = colorizeOrEmpty( modelIcon ? `${modelIcon} ${modelName}` : modelName, modelColor, colors.modelBold, ); addParts( result, "model", { icon: modelIcon, value: modelName }, colors.modelFg, reset, pf, colors.modelBold, ); // Context (bar is width-dependent, resolved later via lateResolve) const contextLine = buildContextLine( data, ctx.contentWidth, sym, reset, colors, ); result.context = contextLine ?? ""; const ctxParts = formatContextParts(data, sym, iconVisible.context); const ctxStyle = data.contextInfo ? resolveThresholdStyle( data.contextInfo.usablePercentage, colors.contextFg, colors.contextBold, colors, ) : { fg: colors.contextFg, bold: colors.contextBold }; addParts(result, "context", ctxParts, ctxStyle.fg, reset, pf, ctxStyle.bold); // Block if (data.blockInfo) { const blockColor = pf?.["block"] ?? colors.blockFg; result.block = colorizeOrEmpty( formatBlockSegment(data.blockInfo, sym, config, iconVisible.block), blockColor, colors.blockBold, ); addParts( result, "block", formatBlockParts(data.blockInfo, sym, config, iconVisible.block), colors.blockFg, reset, pf, colors.blockBold, ); } else { result.block = ""; } // Session if (data.usageInfo) { const sessionColor = pf?.["session"] ?? colors.sessionFg; result.session = colorizeOrEmpty( formatSessionSegment(data.usageInfo, sym, config, iconVisible.session), sessionColor, colors.sessionBold, ); addParts( result, "session", formatSessionParts(data.usageInfo, sym, config, iconVisible.session), colors.sessionFg, reset, pf, colors.sessionBold, ); } else { result.session = ""; } // Today if (data.todayInfo) { const todayColor = pf?.["today"] ?? colors.todayFg; result.today = colorizeOrEmpty( formatTodaySegment(data.todayInfo, sym, config, iconVisible.today), todayColor, colors.todayBold, ); addParts( result, "today", formatTodayParts(data.todayInfo, sym, config, iconVisible.today), colors.todayFg, reset, pf, colors.todayBold, ); } else { result.today = ""; } // Weekly const sevenDay = data.hookData.rate_limits?.seven_day; if (sevenDay) { const weeklyColor = pf?.["weekly"] ?? colors.weeklyFg; result.weekly = colorizeOrEmpty( formatWeeklySegment(sevenDay, sym, iconVisible.weekly), weeklyColor, colors.weeklyBold, ); addParts( result, "weekly", formatWeeklyParts(sevenDay, sym, iconVisible.weekly), colors.weeklyFg, reset, pf, colors.weeklyBold, ); } else { result.weekly = ""; } // Git const gitColor = pf?.["git"] ?? colors.gitFg; result.git = colorizeOrEmpty( formatGitSegment(data, sym, iconVisible.git), gitColor, colors.gitBold, ); addParts( result, "git", formatGitParts(data, sym, iconVisible.git), colors.gitFg, reset, pf, colors.gitBold, ); // Dir const dirColor = pf?.["dir"] ?? colors.modeFg; result.dir = colorizeOrEmpty( formatDirValue(data, config), dirColor, colors.modeBold, ); addParts( result, "dir", formatDirParts(data, config, sym, iconVisible.directory), colors.modeFg, reset, pf, colors.modeBold, ); // Version const versionColor = pf?.["version"] ?? colors.versionFg; result.version = colorizeOrEmpty( formatVersionSegment(data, sym, iconVisible.version), versionColor, colors.versionBold, ); addParts( result, "version", formatVersionParts(data, sym, iconVisible.version), colors.versionFg, reset, pf, colors.versionBold, ); // Tmux const tmuxColor = pf?.["tmux"] ?? colors.tmuxFg; result.tmux = colorizeOrEmpty( formatTmuxSegment(data), tmuxColor, colors.tmuxBold, ); addParts( result, "tmux", formatTmuxParts(data), colors.tmuxFg, reset, pf, colors.tmuxBold, ); // Metrics const metricsColor = pf?.["metrics"] ?? colors.metricsFg; result.metrics = colorizeOrEmpty( formatMetricsSegment(data, sym), metricsColor, colors.metricsBold, ); addParts( result, "metrics", formatMetricsParts(data, sym), colors.metricsFg, reset, pf, colors.metricsBold, ); // Activity const activityColor = pf?.["activity"] ?? colors.metricsFg; result.activity = colorizeOrEmpty( formatActivitySegment(data, sym), activityColor, colors.metricsBold, ); addParts( result, "activity", formatActivityParts(data, sym), colors.metricsFg, reset, pf, colors.metricsBold, ); // Env const envColor = pf?.["env"] ?? colors.envFg; result.env = colorizeOrEmpty( formatEnvSegment(config), envColor, colors.envBold, ); addParts( result, "env", formatEnvParts(config), colors.envFg, reset, pf, colors.envBold, ); // Agent const agentColor = pf?.["agent"] ?? colors.agentFg; result.agent = colorizeOrEmpty( formatAgentSegment(data, sym, config, iconVisible.agent), agentColor, colors.agentBold, ); addParts( result, "agent", formatAgentParts(data, sym, iconVisible.agent), colors.agentFg, reset, pf, colors.agentBold, ); // Thinking (combined enabled + effort) const thinkingSegConfig = config.display.lines .map((line) => line.segments.thinking) .find((t) => t?.enabled); const thinkingColor = pf?.["thinking"] ?? colors.thinkingFg; result.thinking = colorizeOrEmpty( formatThinkingSegment(data, sym, thinkingSegConfig, iconVisible.thinking), thinkingColor, colors.thinkingBold, ); addParts( result, "thinking", formatThinkingParts(data, sym, thinkingSegConfig, iconVisible.thinking), colors.thinkingFg, reset, pf, colors.thinkingBold, ); // CacheTimer const cacheTimerElapsed = data.cacheTimerInfo?.elapsedSeconds ?? 0; const cacheTimerStyleResolved = cacheTimerStyle(cacheTimerElapsed, colors); const cacheTimerColor = pf?.["cacheTimer"] ?? cacheTimerStyleResolved.fg; result.cacheTimer = colorizeOrEmpty( formatCacheTimerSegment(data, sym, iconVisible.cacheTimer), cacheTimerColor, cacheTimerStyleResolved.bold, ); addParts( result, "cacheTimer", formatCacheTimerParts(data, sym, iconVisible.cacheTimer), cacheTimerStyleResolved.fg, reset, pf, cacheTimerStyleResolved.bold, ); // Apply segment templates: resolve items and compose default value const templates: Record<string, ResolvedTemplate> = {}; const segmentConfigs = config.display.tui?.segments; if (segmentConfigs) { for (const [segRef, tmpl] of Object.entries(segmentConfigs)) { const items = resolveTemplateItems(tmpl, segRef, result); const gap = tmpl.gap ?? 1; const justify = tmpl.justify ?? "start"; templates[segRef] = { items, gap, justify }; // Compose default (without cell width for "between") result[segRef] = composeTemplate( items, gap, justify === "between" ? "start" : justify, ); } } return { data: result, templates }; }