Files
claude-code-router/src/utils/statusline.ts
2025-08-15 23:50:57 +08:00

748 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from "node:fs/promises";
import { execSync } from "child_process";
import path from "node:path";
import { CONFIG_FILE, HOME_DIR } from "../constants";
import JSON5 from "json5";
export interface StatusLineModuleConfig {
type: string;
icon?: string;
text: string;
color?: string;
background?: string;
}
export interface StatusLineThemeConfig {
modules: StatusLineModuleConfig[];
}
export interface StatusLineInput {
hook_event_name: string;
session_id: string;
transcript_path: string;
cwd: string;
model: {
id: string;
display_name: string;
};
workspace: {
current_dir: string;
project_dir: string;
};
}
export interface AssistantMessage {
type: "assistant";
message: {
model: string;
usage: {
input_tokens: number;
output_tokens: number;
};
};
}
// ANSIColor代码
const COLORS: Record<string, string> = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
// 标准颜色
black: "\x1b[30m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
white: "\x1b[37m",
// 亮色
bright_black: "\x1b[90m",
bright_red: "\x1b[91m",
bright_green: "\x1b[92m",
bright_yellow: "\x1b[93m",
bright_blue: "\x1b[94m",
bright_magenta: "\x1b[95m",
bright_cyan: "\x1b[96m",
bright_white: "\x1b[97m",
// 背景颜色
bg_black: "\x1b[40m",
bg_red: "\x1b[41m",
bg_green: "\x1b[42m",
bg_yellow: "\x1b[43m",
bg_blue: "\x1b[44m",
bg_magenta: "\x1b[45m",
bg_cyan: "\x1b[46m",
bg_white: "\x1b[47m",
// 亮背景色
bg_bright_black: "\x1b[100m",
bg_bright_red: "\x1b[101m",
bg_bright_green: "\x1b[102m",
bg_bright_yellow: "\x1b[103m",
bg_bright_blue: "\x1b[104m",
bg_bright_magenta: "\x1b[105m",
bg_bright_cyan: "\x1b[106m",
bg_bright_white: "\x1b[107m",
};
// 使用TrueColor(24位色)支持十六进制颜色
const TRUE_COLOR_PREFIX = "\x1b[38;2;";
const TRUE_COLOR_BG_PREFIX = "\x1b[48;2;";
// 将十六进制颜色转为RGB格式
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
// 移除#和空格
hex = hex.replace(/^#/, '').trim();
// 处理简写形式 (#RGB -> #RRGGBB)
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
if (hex.length !== 6) {
return null;
}
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
// 验证RGB值是否有效
if (isNaN(r) || isNaN(g) || isNaN(b) || r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) {
return null;
}
return { r, g, b };
}
// 获取颜色代码
function getColorCode(colorName: string): string {
// 检查是否是十六进制颜色
if (colorName.startsWith('#') || /^[0-9a-fA-F]{6}$/.test(colorName) || /^[0-9a-fA-F]{3}$/.test(colorName)) {
const rgb = hexToRgb(colorName);
if (rgb) {
return `${TRUE_COLOR_PREFIX}${rgb.r};${rgb.g};${rgb.b}m`;
}
}
// 默认返回空字符串
return "";
}
// 变量替换函数,支持{{var}}格式的变量替换
function replaceVariables(text: string, variables: Record<string, string>): string {
return text.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
return variables[varName] || match;
});
}
// 默认主题配置 - 使用Nerd Fonts图标和美观配色
const DEFAULT_THEME: StatusLineThemeConfig = {
modules: [
{
type: "workDir",
icon: "󰉋", // nf-md-folder_outline
text: "{{workDirName}}",
color: "bright_blue"
},
{
type: "gitBranch",
icon: "", // nf-dev-git_branch
text: "{{gitBranch}}",
color: "bright_magenta"
},
{
type: "model",
icon: "󰚩", // nf-md-robot_outline
text: "{{model}}",
color: "bright_cyan"
},
{
type: "usage",
icon: "↑", // 上箭头
text: "{{inputTokens}}",
color: "bright_green"
},
{
type: "usage",
icon: "↓", // 下箭头
text: "{{outputTokens}}",
color: "bright_yellow"
}
]
};
// Powerline风格主题配置
const POWERLINE_THEME: StatusLineThemeConfig = {
modules: [
{
type: "workDir",
icon: "󰉋", // nf-md-folder_outline
text: "{{workDirName}}",
color: "white",
background: "bg_bright_blue"
},
{
type: "gitBranch",
icon: "", // nf-dev-git_branch
text: "{{gitBranch}}",
color: "white",
background: "bg_bright_magenta"
},
{
type: "model",
icon: "󰚩", // nf-md-robot_outline
text: "{{model}}",
color: "white",
background: "bg_bright_cyan"
},
{
type: "usage",
icon: "↑", // 上箭头
text: "{{inputTokens}}",
color: "white",
background: "bg_bright_green"
},
{
type: "usage",
icon: "↓", // 下箭头
text: "{{outputTokens}}",
color: "white",
background: "bg_bright_yellow"
}
]
};
// 简单文本主题配置 - 用于图标无法显示时的fallback
const SIMPLE_THEME: StatusLineThemeConfig = {
modules: [
{
type: "workDir",
icon: "",
text: "{{workDirName}}",
color: "bright_blue"
},
{
type: "gitBranch",
icon: "",
text: "{{gitBranch}}",
color: "bright_magenta"
},
{
type: "model",
icon: "",
text: "{{model}}",
color: "bright_cyan"
},
{
type: "usage",
icon: "↑",
text: "{{inputTokens}}",
color: "bright_green"
},
{
type: "usage",
icon: "↓",
text: "{{outputTokens}}",
color: "bright_yellow"
}
]
};
// 格式化usage信息如果大于1000则使用k单位
function formatUsage(input_tokens: number, output_tokens: number): string {
if (input_tokens > 1000 || output_tokens > 1000) {
const inputFormatted = input_tokens > 1000 ? `${(input_tokens / 1000).toFixed(1)}k` : `${input_tokens}`;
const outputFormatted = output_tokens > 1000 ? `${(output_tokens / 1000).toFixed(1)}k` : `${output_tokens}`;
return `${inputFormatted} ${outputFormatted}`;
}
return `${input_tokens} ${output_tokens}`;
}
// 读取用户主目录的主题配置
async function getProjectThemeConfig(): Promise<{ theme: StatusLineThemeConfig | null, style: string }> {
try {
// 只使用主目录的固定配置文件
const configPath = CONFIG_FILE;
// 检查配置文件是否存在
try {
await fs.access(configPath);
} catch {
return { theme: null, style: 'default' };
}
const configContent = await fs.readFile(configPath, "utf-8");
const config = JSON5.parse(configContent);
// 检查是否有StatusLine配置
if (config.StatusLine) {
// 获取当前使用的风格默认为default
const currentStyle = config.StatusLine.currentStyle || 'default';
// 检查是否有对应风格的配置
if (config.StatusLine[currentStyle] && config.StatusLine[currentStyle].modules) {
return { theme: config.StatusLine[currentStyle], style: currentStyle };
}
}
} catch (error) {
// 如果读取失败返回null
// console.error("Failed to read theme config:", error);
}
return { theme: null, style: 'default' };
}
// 检查是否应该使用简单主题fallback方案
// 当环境变量 USE_SIMPLE_ICONS 被设置时或者当检测到可能不支持Nerd Fonts的终端时
function shouldUseSimpleTheme(): boolean {
// 检查环境变量
if (process.env.USE_SIMPLE_ICONS === 'true') {
return true;
}
// 检查终端类型(一些常见的不支持复杂图标的终端)
const term = process.env.TERM || '';
const unsupportedTerms = ['dumb', 'unknown'];
if (unsupportedTerms.includes(term)) {
return true;
}
// 默认情况下假设终端支持Nerd Fonts
return false;
}
// 检查Nerd Fonts图标是否能正确显示
// 通过检查终端字体信息或使用试探性方法
function canDisplayNerdFonts(): boolean {
// 如果环境变量明确指定使用简单图标则不能显示Nerd Fonts
if (process.env.USE_SIMPLE_ICONS === 'true') {
return false;
}
// 检查一些常见的支持Nerd Fonts的终端环境变量
const fontEnvVars = ['NERD_FONT', 'NERDFONT', 'FONT'];
for (const envVar of fontEnvVars) {
const value = process.env[envVar];
if (value && (value.includes('Nerd') || value.includes('nerd'))) {
return true;
}
}
// 检查终端类型
const termProgram = process.env.TERM_PROGRAM || '';
const supportedTerminals = ['iTerm.app', 'vscode', 'Hyper', 'kitty', 'alacritty'];
if (supportedTerminals.includes(termProgram)) {
return true;
}
// 检查COLORTERM环境变量
const colorTerm = process.env.COLORTERM || '';
if (colorTerm.includes('truecolor') || colorTerm.includes('24bit')) {
return true;
}
// 默认情况下假设可以显示Nerd Fonts但允许用户通过环境变量覆盖
return process.env.USE_SIMPLE_ICONS !== 'true';
}
// 检查特定Unicode字符是否能正确显示
// 这是一个简单的试探性检查
function canDisplayUnicodeCharacter(char: string): boolean {
// 对于Nerd Fonts图标我们假设支持UTF-8的终端可以显示
// 但实际上很难准确检测,所以我们依赖环境变量和终端类型检测
try {
// 检查终端是否支持UTF-8
const lang = process.env.LANG || process.env.LC_ALL || process.env.LC_CTYPE || '';
if (lang.includes('UTF-8') || lang.includes('utf8') || lang.includes('UTF8')) {
return true;
}
// 检查LC_*环境变量
const lcVars = ['LC_ALL', 'LC_CTYPE', 'LANG'];
for (const lcVar of lcVars) {
const value = process.env[lcVar];
if (value && (value.includes('UTF-8') || value.includes('utf8'))) {
return true;
}
}
} catch (e) {
// 如果检查失败默认返回true
return true;
}
// 默认情况下,假设可以显示
return true;
}
export async function parseStatusLineData(input: StatusLineInput): Promise<string> {
try {
// 检查是否应该使用简单主题
const useSimpleTheme = shouldUseSimpleTheme();
// 检查是否可以显示Nerd Fonts图标
const canDisplayNerd = canDisplayNerdFonts();
// 确定使用的主题如果用户强制使用简单主题或无法显示Nerd Fonts则使用简单主题
const effectiveTheme = useSimpleTheme || !canDisplayNerd ? SIMPLE_THEME : DEFAULT_THEME;
// 获取主目录的主题配置,如果没有则使用确定的默认配置
const { theme: projectTheme, style: currentStyle } = await getProjectThemeConfig();
const theme = projectTheme || effectiveTheme;
// 获取当前工作目录和Git分支
const workDir = input.workspace.current_dir;
let gitBranch = "";
try {
// 尝试获取Git分支名
gitBranch = execSync("git branch --show-current", {
cwd: workDir,
stdio: ["pipe", "pipe", "ignore"],
})
.toString()
.trim();
} catch (error) {
// 如果不是Git仓库或获取失败则忽略错误
}
// 从transcript_path文件中读取最后一条assistant消息
const transcriptContent = await fs.readFile(input.transcript_path, "utf-8");
const lines = transcriptContent.trim().split("\n");
// 反向遍历寻找最后一条assistant消息
let model = "";
let inputTokens = 0;
let outputTokens = 0;
for (let i = lines.length - 1; i >= 0; i--) {
try {
const message: AssistantMessage = JSON.parse(lines[i]);
if (message.type === "assistant" && message.message.model) {
model = message.message.model;
if (message.message.usage) {
inputTokens = message.message.usage.input_tokens;
outputTokens = message.message.usage.output_tokens;
}
break;
}
} catch (parseError) {
// 忽略解析错误,继续查找
continue;
}
}
// 如果没有从transcript中获取到模型名称则尝试从配置文件中获取
if (!model) {
try {
// 获取项目配置文件路径
const projectConfigPath = path.join(workDir, ".claude-code-router", "config.json");
let configPath = projectConfigPath;
// 检查项目配置文件是否存在,如果不存在则使用用户主目录的配置文件
try {
await fs.access(projectConfigPath);
} catch {
configPath = CONFIG_FILE;
}
// 读取配置文件
const configContent = await fs.readFile(configPath, "utf-8");
const config = JSON5.parse(configContent);
// 从Router字段的default内容中获取模型名称
if (config.Router && config.Router.default) {
const [, defaultModel] = config.Router.default.split(",");
if (defaultModel) {
model = defaultModel.trim();
}
}
} catch (configError) {
// 如果配置文件读取失败,则忽略错误
}
}
// 如果仍然没有获取到模型名称则使用传入的JSON数据中的model字段的display_name
if (!model) {
model = input.model.display_name;
}
// 获取工作目录名
const workDirName = workDir.split("/").pop() || "";
// 格式化usage信息
const usage = formatUsage(inputTokens, outputTokens);
const [formattedInputTokens, formattedOutputTokens] = usage.split(" ");
// 定义变量替换映射
const variables = {
workDirName,
gitBranch,
model,
inputTokens: formattedInputTokens,
outputTokens: formattedOutputTokens
};
// 确定使用的风格
const isPowerline = currentStyle === 'powerline';
// 根据风格渲染状态行
if (isPowerline) {
return renderPowerlineStyle(theme, variables);
} else {
return renderDefaultStyle(theme, variables);
}
} catch (error) {
// 发生错误时返回空字符串
return "";
}
}
// 读取用户主目录的主题配置(指定风格)
async function getProjectThemeConfigForStyle(style: string): Promise<StatusLineThemeConfig | null> {
try {
// 只使用主目录的固定配置文件
const configPath = CONFIG_FILE;
// 检查配置文件是否存在
try {
await fs.access(configPath);
} catch {
return null;
}
const configContent = await fs.readFile(configPath, "utf-8");
const config = JSON5.parse(configContent);
// 检查是否有StatusLine配置
if (config.StatusLine && config.StatusLine[style] && config.StatusLine[style].modules) {
return config.StatusLine[style];
}
} catch (error) {
// 如果读取失败返回null
// console.error("Failed to read theme config:", error);
}
return null;
}
// 渲染默认风格的状态行
function renderDefaultStyle(
theme: StatusLineThemeConfig,
variables: Record<string, string>
): string {
const modules = theme.modules || DEFAULT_THEME.modules;
const parts: string[] = [];
// 遍历模块数组,渲染每个模块
for (let i = 0; i < Math.min(modules.length, 5); i++) {
const module = modules[i];
const color = module.color ? getColorCode(module.color) : "";
const background = module.background ? getColorCode(module.background) : "";
const icon = module.icon || "";
const text = replaceVariables(module.text, variables);
// 如果text为空且不是usage类型则跳过该模块
if (!text && module.type !== "usage") {
continue;
}
// 构建模块字符串
let part = `${background}${color}`;
if (icon) {
part += `${icon} `;
}
part += `${text}${COLORS.reset}`;
parts.push(part);
}
// 使用空格连接所有部分
return parts.join(" ");
}
// Powerline符号
const SEP_RIGHT = "\uE0B0"; // 
// 颜色编号256色表
const COLOR_MAP: Record<string, number> = {
// 基础颜色映射到256色
black: 0,
red: 1,
green: 2,
yellow: 3,
blue: 4,
magenta: 5,
cyan: 6,
white: 7,
bright_black: 8,
bright_red: 9,
bright_green: 10,
bright_yellow: 11,
bright_blue: 12,
bright_magenta: 13,
bright_cyan: 14,
bright_white: 15,
// 亮背景色映射
bg_black: 0,
bg_red: 1,
bg_green: 2,
bg_yellow: 3,
bg_blue: 4,
bg_magenta: 5,
bg_cyan: 6,
bg_white: 7,
bg_bright_black: 8,
bg_bright_red: 9,
bg_bright_green: 10,
bg_bright_yellow: 11,
bg_bright_blue: 12,
bg_bright_magenta: 13,
bg_bright_cyan: 14,
bg_bright_white: 15,
// 自定义颜色映射
bg_bright_orange: 202,
bg_bright_purple: 129,
};
// 获取TrueColor的RGB值
function getTrueColorRgb(colorName: string): { r: number; g: number; b: number } | null {
// 如果是预定义颜色返回对应RGB
if (COLOR_MAP[colorName] !== undefined) {
const color256 = COLOR_MAP[colorName];
return color256ToRgb(color256);
}
// 处理十六进制颜色
if (colorName.startsWith('#') || /^[0-9a-fA-F]{6}$/.test(colorName) || /^[0-9a-fA-F]{3}$/.test(colorName)) {
return hexToRgb(colorName);
}
// 处理背景色十六进制
if (colorName.startsWith('bg_#')) {
return hexToRgb(colorName.substring(3));
}
return null;
}
// 将256色表索引转换为RGB值
function color256ToRgb(index: number): { r: number; g: number; b: number } | null {
if (index < 0 || index > 255) return null;
// ANSI 256色表转换
if (index < 16) {
// 基本颜色
const basicColors = [
[0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],
[0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192],
[128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0],
[0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255]
];
return { r: basicColors[index][0], g: basicColors[index][1], b: basicColors[index][2] };
} else if (index < 232) {
// 216色6×6×6的颜色立方体
const i = index - 16;
const r = Math.floor(i / 36);
const g = Math.floor((i % 36) / 6);
const b = i % 6;
const rgb = [0, 95, 135, 175, 215, 255];
return { r: rgb[r], g: rgb[g], b: rgb[b] };
} else {
// 灰度色
const gray = 8 + (index - 232) * 10;
return { r: gray, g: gray, b: gray };
}
}
// 生成一个无缝拼接的段:文本在 bgN 上显示,分隔符从 bgN 过渡到 nextBgN
function segment(text: string, textFg: string, bgColor: string, nextBgColor: string | null): string {
const bgRgb = getTrueColorRgb(bgColor);
if (!bgRgb) {
// 如果无法获取RGB使用默认蓝色背景
const defaultBlueRgb = { r: 33, g: 150, b: 243 };
const curBg = `\x1b[48;2;${defaultBlueRgb.r};${defaultBlueRgb.g};${defaultBlueRgb.b}m`;
const fgColor = `\x1b[38;2;255;255;255m`;
const body = `${curBg}${fgColor} ${text} \x1b[0m`;
return body;
}
const curBg = `\x1b[48;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
// 获取前景色RGB
let fgRgb = { r: 255, g: 255, b: 255 }; // 默认前景色为白色
const textFgRgb = getTrueColorRgb(textFg);
if (textFgRgb) {
fgRgb = textFgRgb;
}
const fgColor = `\x1b[38;2;${fgRgb.r};${fgRgb.g};${fgRgb.b}m`;
const body = `${curBg}${fgColor} ${text} \x1b[0m`;
if (nextBgColor != null) {
const nextBgRgb = getTrueColorRgb(nextBgColor);
if (nextBgRgb) {
// 分隔符:前景色是当前段的背景色,背景色是下一段的背景色
const sepCurFg = `\x1b[38;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
const sepNextBg = `\x1b[48;2;${nextBgRgb.r};${nextBgRgb.g};${nextBgRgb.b}m`;
const sep = `${sepCurFg}${sepNextBg}${SEP_RIGHT}\x1b[0m`;
return body + sep;
}
// 如果没有下一个背景色,假设终端背景为黑色并渲染黑色箭头
const sepCurFg = `\x1b[38;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
const sepNextBg = `\x1b[48;2;0;0;0m`; // 黑色背景
const sep = `${sepCurFg}${sepNextBg}${SEP_RIGHT}\x1b[0m`;
return body + sep;
}
return body;
}
// 渲染Powerline风格的状态行
function renderPowerlineStyle(
theme: StatusLineThemeConfig,
variables: Record<string, string>
): string {
const modules = theme.modules || POWERLINE_THEME.modules;
const segments: string[] = [];
// 遍历模块数组,渲染每个模块
for (let i = 0; i < Math.min(modules.length, 5); i++) {
const module = modules[i];
const color = module.color || "white";
const backgroundName = module.background || "";
const icon = module.icon || "";
const text = replaceVariables(module.text, variables);
// 如果text为空且不是usage类型则跳过该模块
if (!text && module.type !== "usage") {
continue;
}
// 构建显示文本
let displayText = "";
if (icon) {
displayText += `${icon} `;
}
displayText += text;
// 获取下一个模块的背景色(用于分隔符)
let nextBackground: string | null = null;
if (i < modules.length - 1) {
const nextModule = modules[i + 1];
nextBackground = nextModule.background || null;
}
// 使用模块定义的背景色或者为Powerline风格提供默认背景色
const actualBackground = backgroundName || "bg_bright_blue";
// 生成段,支持十六进制颜色
const segmentStr = segment(displayText, color, actualBackground, nextBackground);
segments.push(segmentStr);
}
return segments.join("");
}