Skip to main content

2. 主题令牌与半行背景块

这一篇讲两个最底层、被几乎所有 UI 模块引用的文件:集中式的主题令牌 themeConfig.ts,以及半行背景块用到的两个 glyph 常量 backgroundBlock.ts。它们对应主题计划文档里的 U2(半行背景块原语)和 U3(接入背景块与主题)。

主题令牌:themeConfig.ts

整个文件就是一份冻结的颜色表:

export const geminiDarkTheme = {
colors: {
bodyBackground: '#000000',
foreground: '#FFFFFF',
muted: '#AFAFAF',
accentBlue: '#87AFFF',
accentGreen: '#D7FFD7',
warning: '#FFFFAF',
errorRed: '#FF87AF',
border: '#878787',
messageBackground: '#5F5F5F',
inputBackground: '#5F5F5F'
}
} as const;

每个令牌的语义

设计上把颜色拆成“前景文本色”和“背景块色”两类,这样即使关闭背景渲染,前景色依然可靠:

  • bodyBackground #000000:主屏整体底色,也是半行块“未被填充的那一半”的颜色。
  • foreground #FFFFFF:正文主文本、输入框文本、滚动条 thumb。
  • muted #AFAFAF:次要信息,比如底部 / commands | @ mention | ? help 提示、空行占位。
  • accentBlue #87AFFF:强调色,用在 KQode logo、assistant 行的 marker、输入框首行的 > 前缀。
  • accentGreen #D7FFD7:成功态(success 类型正文)和右下角模型名 GPT-5.5
  • warning #FFFFAFpending 态正文(如 ... (pending))。
  • errorRed #FF87AF:前端校验失败与后端错误信息,配合 ERROR: 文本前缀使用。
  • border #878787:滚动条 track(未被 thumb 覆盖的部分)。
  • messageBackground / inputBackground #5F5F5F:用户消息块、输入框块的背景色。两者目前同值,但分成两个令牌,方便以后单独调。

两个设计要点

第一,as const 让这张表变成只读字面量类型,每个颜色键都是精确的字符串字面量类型,引用处既能自动补全又能在拼错 key 时报错。

第二,主题是内部静态的,没有用户可见的主题配置。主题计划文档明确把 /theme 命令、主题持久化、自定义主题文件等都划在范围之外——当前阶段只需要一份语义清晰、可被集中引用的令牌表。这也是为什么颜色没有散落在各组件里,而是全部从这一个文件导入。

这些颜色取自 Gemini CLI 默认深色配色:消息/输入背景用 #5F5F5F,主屏底色用 #000000

半行背景块:backgroundBlock.ts

文件只有两行,却是整套“无缝背景块”渲染的关键:

export const LOWER_HALF_BLOCK = '▄';
export const UPPER_HALF_BLOCK = '▀';

(U+2584,下半块)和 (U+2580,上半块)是两个 Unicode 半块字符。它们之所以能消除终端里的“行缝”,靠的是一个渲染技巧。

为什么需要半行块

主题计划文档记录了一个踩坑:直接给整行刷背景色,会在某些终端的相邻行之间露出可见的“接缝”(行间像素没有被填满)。Gemini CLI 的 HalfLinePaddedBox 给出的思路是——不要让背景块的边缘正好落在“整行”的边界上,而是用半块字符把边缘“柔化”掉。

glyph 的着色技巧

关键在于:一个半块字符会用前景色画它“实心的那一半”,剩下一半显示为背景色。所以一行 (下半块),如果设置 color = 块色backgroundColor = 底色,渲染出来就是“上半行是底色、下半行是块色”。

把它用在块的顶部,就得到一条只填了下半部分的过渡行;同理 (上半块)用在块的底部,得到只填上半部分的过渡行。中间夹着若干整行(整行都是块背景色)的内容行,最终肉眼看到的是一个上下边缘各“缩进半行”的色块,行与行之间不再有突兀的接缝。

这个技巧在两个地方被复用:

  • 正文里的用户消息块——见第 7 篇 bodyRows.tshalfLineRow()

    function halfLineRow(columns: number, glyph: string): BodyRow {
    return {
    backgroundColor: geminiDarkTheme.colors.bodyBackground,
    color: geminiDarkTheme.colors.messageBackground,
    text: glyph.repeat(columns)
    };
    }

    注意 backgroundColor 是底色、color 是消息块色,正好对应上面说的“实心半边用前景色、另半边用背景色”。

  • 输入框的上下边缘——见第 11 篇 ComposerFrame.tsxComposerHalfLine

    function ComposerHalfLine({ glyph, columns }: { glyph: string; columns: number }) {
    return (
    <Text
    backgroundColor={geminiDarkTheme.colors.bodyBackground}
    color={geminiDarkTheme.colors.inputBackground}
    >
    {glyph.repeat(Math.max(1, columns))}
    </Text>
    );
    }

从组件到常量的演变

在第一个 commit dd15b678 里,半行块是一个独立的 BackgroundBlock.tsx 组件,带 enabled fallback 等逻辑。重构 commit 5432e018 发现:真正需要复用的只是“上/下半块”这两个 glyph,而“怎么把它们拼成块”在正文和输入框里各有不同(正文是 BodyRow 数据、输入框是 JSX)。于是把组件砍掉,只保留两个常量,由各自的渲染处直接拼接。这既符合“纯 helper 放 libs/tui”的约定,也避免了一个为两种场景强行抽象的中间组件。

下一篇进入状态层,看 homeScreenAtoms.ts 如何用 Jotai 管理主屏的配置与派生状态。