让 Kitty 和 Starship 跟着 KDE 一起换色

换壁纸取色、切配色方案、昼夜自动切换 —— 让终端配色自动跟上 KDE。两个零依赖 Python 脚本、一条 systemd 链路,以及一个会静默失败的竞态陷阱。

KDE 那边把 accent 调成了玫瑰粉,终端还停在上一套主题;Starship 的 git 分支胶囊又是另一个颜色。壁纸一换,三处全不对。

手动改当然可以,但每次换壁纸、每次切深浅色都要记得改一遍 —— 那就等于没改。

这篇讲的是一套自动方案:让终端配色跟着 KDE 走。读完你能得到两个零依赖的 Python 脚本、一条 systemd 自动同步链路,以及一个会静默失败的竞态陷阱的完整避坑方案。

配套代码与完整脚本:https://github.com/LaT-SKY/kde-terminal-theme-sync(MIT)

先决条件

需要 说明
KDE Plasma 6 配色数据源。昼夜自动切换需要 6.5+
Kitty 终端本体,支持 `SIGUSR1` 热重载
Starship 提示符。`[palettes.*]` 需要 1.20+
Python 3.8+ 两个脚本只用标准库,没有 pip 依赖
systemd –user 自动同步用它。Arch / Fedora / Ubuntu 默认有

这套东西在什么环境上验证过

```plaintext
CachyOS (Arch) | KDE Plasma 6 | Kitty | Starship 1.26.0
Wayland | Python 3.14
```

只在上面这一个组合上实测过。 下面这份清单说明哪些地方会因环境而异 —— 标 ✅ 的可以放心,标 ⚠️ 和 ❌ 的要自己确认一下:

三个自查命令:

```bash
python3 --version                    # 要 3.8 以上
systemctl --user status              # 能输出就说明有 systemd --user
ls ~/.config/kdeglobals              # 存在就说明有 KDE 配置
```

原理:KDE 把配色存在哪

搞清楚数据从哪来,后面所有事都好办了。KDE 的配色分散在三个地方,按优先级回退:

  1. `~/.config/kdeglobals` —— 用户覆盖,优先级最高。accent 色这类个性化设置在这里
  2. `~/.local/share/color-schemes/<名称>.colors` —— 用户自己装的配色
  3. `/usr/share/color-schemes/<名称>.colors` —— 系统自带,最后兜底

当前用的是哪一套,从 `kdeglobals` 的 `[General] ColorScheme` 读。

⚠️ 一个反直觉的点

那个 `ColorScheme` 键不一定在 `kdeglobals` 里。

这不是配置写坏了。Plasma 在切换主题时是分阶段写文件的,而且写的地方不止一处 —— 方案名可能跑到了 `~/.config/kdedefaults/kdeglobals`。

自己确认一下:

```bash
# 看 kdeglobals 里有没有 ColorScheme
grep -n 'ColorScheme' ~/.config/kdeglobals

# 如果没有,看这里
grep -n 'ColorScheme' ~/.config/kdedefaults/kdeglobals 2>/dev/null
```

记住这个细节 —— 「一个会静默失败的陷阱」的根因就在这儿。

`.colors` 基座文件是什么

`<名称>.colors` 是完整的配色方案文件,里面是 `[Colors:View]`、`[Colors:Window]` 这样的段。脚本读它的顺序是:

  1. 先读 `kdeglobals` 里的 `[Colors:*]` 段(那是用户覆盖后的结果)
  2. 再用 `.colors` 文件补齐缺失的段

这个「补齐」在正常情况下是好事 —— 少几个键不影响。但它也正是那个陷阱 最难发现的地方,到时候会说。

第一步:读 KDE 配色,生成 Kitty 主题

先建一个目录放脚本。位置随意,但记住它 —— 后面 systemd 单元要指过来。

```bash
mkdir -p ~/.local/share/kde-terminal-theme-sync
cd ~/.local/share/kde-terminal-theme-sync
```

把 `generate-kitty-theme.py` 放进去。这个脚本 492 行,不铺在正文里 —— 下载或 clone 下来放到那个目录即可:

完整脚本:generate-kitty-theme.py 492 行 / 19.8 KB | 直接下载

下面讲它的两处设计决策。不用对着源码读,看完再回去翻也来得及。

先跑一次看看它能不能读出你的配色:

```bash
python3 generate-kitty-theme.py
```

我这边(BreezeDark)的输出是这样,这是真实运行结果:

```plaintext
[*] 当前 KDE 配色方案: BreezeDark  (来源: ~/.config/kdeglobals)
[*] 读取基础配色文件: /usr/share/color-schemes/BreezeDark.colors
[*] 检测到 暗色 主题 (背景亮度=22)

foreground  #fcfcfc
background  #151a1e
cursor      #3daee9
```

两个设计决策,值得单独讲

脚本里有两处看起来可以「更统一」、但故意不统一的地方。

① `background` 为什么要掺 5% accent

纯搬 KDE 的 `BackgroundNormal` 得到的是一块死板的灰。它和 KDE 窗口放在一起, 一眼能看出「不是一家人」。

所以脚本把 accent 按 5% 掺进背景 —— 上面那个 `#151a1e`, 原始 `BackgroundNormal` 是 `(20, 22, 24)`,掺入 accent 之后变成了 `#151a1e`。 差别很小,但整块背景「活」了。

② ANSI 蓝为什么不跟随 accent

这是更重要的一个。

如果你的 KDE `ForegroundLink` 被覆盖成了非蓝色(比如玫瑰粉), `color4`(ANSI 蓝)不会跟着变 —— 脚本会检测色相,不是蓝就回退到标准蓝 `#2980b9`。

理由很实际:终端里太多程序用 ANSI 蓝表示「目录」「信息」「链接」。 它变成粉色之后,`ls` 的输出会读不懂 —— 目录和普通文件分不出来。

功能语义色不能为了好看让路。 这和「配色统一」是两回事, 脚本选了前者。

第二步:生成 Starship 调色板

同样放进那个目录(589 行,一样不铺在正文里):

完整脚本:generate-starship-palette.py 589 行 / 22.4 KB | 直接下载

为什么只改 `[palettes.kde]` 段

`starship.toml` 是你自己的配置文件,里面可能有大量手动调过的东西。 脚本默认整份输出(第一次用),但加上 `--palette-only` 就只更新 `[palettes.kde]` 这一段,其它原样保留:

```bash
python3 generate-starship-palette.py --palette-only
```
```plaintext
[*] 首次备份到: ~/.config/starship.toml.bak
[✓] palette 段已更新: ~/.config/starship.toml
```

自动同步链路用的就是 `--palette-only` —— 自动化不该覆盖你的手动配置。

调色板是怎么派生的

从 KDE 拿到的只有一个 accent 色。脚本用混色派生出三个层次:

键 怎么来的 用在哪
`accent` KDE `DecorationFocus` 原色 Git 分支胶囊(最醒目)
`accent_soft` accent 与背景 55% 混合 主机名、用户名胶囊
`accent_dim` accent 与背景 25% 混合 目录、命令耗时胶囊

这样几个胶囊之间有层次,而不是一片同样的颜色。

第三步:让它自己跑起来

手动跑脚本能解决问题,但每次换壁纸都要记得跑一次,那就等于没有。

用 systemd 的用户级 Path 单元监听 `kdeglobals`。

下面这三个文件很短,直接铺开 —— 正文要讲解的部分必须能当场对着看:

```ini
[Unit]
Description=Watch kdeglobals for KDE theme changes

[Path]
# 监听这个文件被改写。KDE 换壁纸取色 / 切配色方案 / 昼夜自动切换
# 都会改写它,所以三种情况都能触发同步。
PathModified=%h/.config/kdeglobals

[Install]
WantedBy=default.target
```
```ini
[Unit]
Description=Sync Kitty/Starship colors from KDE theme
After=graphical-session.target

[Service]
Type=oneshot
# ⚠️ 路径按你的安装位置改。%h 是 systemd 的家目录占位符,
#    所以这一份不用改就能用(前提是你把脚本放在这个目录)。
#    用 /usr/bin/bash 显式调用,省得依赖脚本的可执行位。
ExecStart=/usr/bin/bash "%h/.local/share/kde-terminal-theme-sync/auto-sync.sh"
```

同步入口:

```bash
#!/usr/bin/env bash
# KDE 配色变化 → 同步 Kitty/Starship(由 kde-theme-sync.path 触发,也可手动运行)
# 幂等:内容无变化则不写盘、不发信号
#
# 2026-09-19 竞态修复:
#   KDE 分阶段写 kdeglobals(先 General/ColorSchemeHash,颜色段随后落盘),
#   而 systemd PathModified 在写入瞬间就触发,旧实现会读到半成品 →
#   生成浅色主题 → 与已有文件逐字节相同 → 判定“无变化” → 永不重试,
#   结果切到暗色模式后 kitty 背景仍是浅色(starship 因稍后读取而正常)。
#   现在:生成前等内容稳定,生成后复核,若期间又变则整体重跑;且生成器
#   遇到必需颜色缺失会退出码 3、本脚本跳过本次同步而不是写入浅色垃圾。
set -euo pipefail

exec 9>/tmp/kde-theme-sync.lock
flock -n 9 || exit 0  # 已有实例在跑则直接退出,防并发

# 脚本自己所在目录 —— **不写死路径**,clone 到哪都能跑。
# (原版写死了作者的私人路径,发布前必须去掉这类东西。)
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
KDEGLOBALS="$HOME/.config/kdeglobals"
KITTY_CONF="$HOME/.config/kitty/colors-kde.conf"

REDO_COUNT=0    # 本次同步内允许的“整体重跑”次数
MAX_REDO=2
SETTLE_WAIT=0.4 # 稳定采样间隔
SETTLE_TRIES=12 # 最多等 12×0.4s ≈ 4.8s

# 内容摘要:作为“kdeglobals 是否仍在变”的判据
file_sig() { md5sum "$1" 2>/dev/null | cut -d' ' -f1; }

# 等 kdeglobals 写完:连续两次采样摘要一致才继续(KDE 分阶段写盘)
settle_kdeglobals() {
    local prev="" cur i
    for ((i = 0; i < SETTLE_TRIES; i++)); do
        cur="$(file_sig "$KDEGLOBALS")"
        [[ -n "$cur" && "$cur" == "$prev" ]] && return 0
        prev="$cur"
        sleep "$SETTLE_WAIT"
    done
    echo "[$(date '+%F %T')] 警告:kdeglobals 在 $SETTLE_TRIES 次采样内未稳定,继续尝试" >&2
    return 0
}

# 返回 0 表示“内容在同步期间又变了,需要重跑”,1 表示稳定
kdeglobals_changed_since() {
    [[ "$(file_sig "$KDEGLOBALS")" != "$1" ]]
}

# ── 生成前:等 KDE 把 kdeglobals 写完 ──
settle_kdeglobals

while :; do
    SIG_BEFORE="$(file_sig "$KDEGLOBALS")"

    TMP_K="$(mktemp)"
    if ! python3 "$DIR/generate-kitty-theme.py" -o "$TMP_K"; then
        # 退出码 3 = kdeglobals 半成品(生成器已自行重试过);其余为真实错误
        echo "[$(date '+%F %T')] kitty 配色生成失败(kdeglobals 尚未写完或数据异常),本次跳过" >&2
        rm -f "$TMP_K"
        exit 0
    fi

    # 1) Kitty:生成到临时文件比对,仅内容变化时落盘并 SIGUSR1 热重载
    if ! cmp -s "$TMP_K" "$KITTY_CONF"; then
        cp "$TMP_K" "$KITTY_CONF"
        pkill -USR1 -x kitty 2>/dev/null || true
        echo "[$(date '+%F %T')] kitty colors-kde.conf 已更新并发送 SIGUSR1"
    else
        echo "[$(date '+%F %T')] kitty 配色无变化"
    fi
    rm -f "$TMP_K"

    # 2) Starship:只更新 [palettes.kde] 段(脚本内部自带幂等)
    OUT="$(python3 "$DIR/generate-starship-palette.py" --palette-only 2>&1 || true)"
    if echo "$OUT" | grep -q "palette 段已更新"; then
        # 真正写入了新 palette → 通知所有 fish 重绘 prompt(等效窗口 resize;
        # SIGWINCH 默认忽略、无杀伤。fish 重绘时 starship 重读配置即换色,
        # 解决 transience 下已开终端 prompt 不刷新问题)
        pkill -WINCH -x fish 2>/dev/null || true
        echo "[$(date '+%F %T')] starship palette 已更新,已通知 fish 重绘"
    elif echo "$OUT" | grep -q "palette 无变化"; then
        echo "[$(date '+%F %T')] starship palette 无变化"
    else
        echo "[$(date '+%F %T')] starship 更新异常:$OUT" >&2
    fi

    # ── 生成后复核:若 kdeglobals 在本次同步期间又被改写(说明读到的可能仍是半成品),整体重跑 ──
    if kdeglobals_changed_since "$SIG_BEFORE"; then
        if ((REDO_COUNT < MAX_REDO)); then
            REDO_COUNT=$((REDO_COUNT + 1))
            echo "[$(date '+%F %T')] 检测到 kdeglobals 在同步期间再次变化,重跑同步(第 $REDO_COUNT 次)"
            settle_kdeglobals
            continue
        fi
        echo "[$(date '+%F %T')] 警告:kdeglobals 在 $MAX_REDO 次重跑后仍在变化,放弃本次同步" >&2
    fi
    break
done
```

整条链路是这样:

```plaintext
kdeglobals 变化
 └─ kde-theme-sync.path
     │  (systemd --user,监听文件修改)
     └─ kde-theme-sync.service  oneshot
         └─ auto-sync.sh
             ├─ 生成到临时文件
             ├─ 与现有配置逐字节比对
             ├─ 有变化才落盘
             ├─ kitty: SIGUSR1 热重载
             └─ starship: 只更新
                [palettes.kde] 段
```

这张图刻意每行不超过 44 列 —— 手机上不用横向滚动就能一眼看全。 代码块本身是支持横向滚动的(超宽时在块内滑动,页面不动), 但对树形图来说,能一眼看全比能滚动有用。

为什么用 `PathModified=` 而不是定时轮询

KDE 改配置是事件驱动的。轮询要么延迟高(间隔大),要么空转(间隔小)。 `PathModified=` 精确到「这个文件被写了」这个事件。

热重载:两个信号,各有各的原因

Kitty —— `SIGUSR1`。 Kitty 收到就重新读配置文件。没有它,你得重启终端。

Starship —— `SIGWINCH`。 这个稍微绕一点:

Starship 每次画 prompt 都会重读配置,所以新开的终端本来就会用新色。 但已经开着的终端不会 —— 它会一直停在旧色上。

`SIGWINCH` 是终端窗口大小改变的信号。fish 收到会重绘 prompt, 重绘时 Starship 重新读配置,于是已开终端也即时换色。

```bash
pkill -WINCH -x fish 2>/dev/null || true
```

⚠️ 注意 `-x`:它要求进程名精确匹配。不要用 `pkill -f` —— 那个模式串会匹配到命令行里任何含该字符串的进程,包括执行它的那个 shell 自己。

不用 fish 怎么办

上面那一行是唯一和 shell 绑定的地方。换 shell 就换它:

其它部分与 shell 无关。 你也可以直接删掉那一行 —— 代价只是已开终端要新开一个才换色。

安装

```bash
# 1. 装 systemd 单元
cp kde-theme-sync.path kde-theme-sync.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now kde-theme-sync.path

# 2. Kitty 那边接上 include(~/.config/kitty/kitty.conf)
#    include colors-kde.conf

# 3. 看状态
systemctl --user status kde-theme-sync.path
journalctl --user -u kde-theme-sync.service -f
```

`.service` 里默认写的是 `%h/.local/share/kde-terminal-theme-sync/auto-sync.sh` (`%h` 是 systemd 的家目录占位符)。脚本放在别处就改那一行。

⚠️ 一个会静默失败的陷阱

这一节是全文最值钱的部分。 任何监听 KDE 配置文件的工具都可能踩进去, 而且它失败的时候日志里一切正常。

现象

自动同步上线两周后,某天切到暗色模式:

kitty 背景仍是浅色,而 starship 已经正确变暗。

同一个桌面、同一次切换、同一份输入,一个跟上了,一个没跟上。

journal 里更奇怪:

```plaintext
9月 19 18:30:16  Starting Sync colors...
9月 19 18:30:16  kitty 配色无变化
                 ↑ 该更新却判定"无变化"
9月 19 18:30:16  starship palette 已更新
                 ↑ 同一份输入却变了
```

同一秒、同一份 `kdeglobals`,两个脚本得出相反结论。

根因链

六步,一步都不能少。

  1. KDE 写 `kdeglobals` 是分阶段的。 不是一次写完 —— 可能先落 `[General]`,颜色段随后才写
  2. `PathModified=` 在第一次写入事件就触发。 它不知道「文件还没写完」这回事
  3. 同步抢在颜色段落盘之前读文件。 读到的是「有 `[General]`、无 `[Colors:View]`」的中间态
  4. 取色函数拿不到值,回退到默认。 于是生成一份看起来正确的浅色主题
  5. 它与磁盘上已有的浅色文件逐字节相同 → 幂等比对判定「无变化」→ 不写盘、不发信号
  6. 颜色段真正落盘时,`PathModified` 不再触发(文件已经“改过”了)→ 永远不重试 → kitty 永久僵在浅色

而 starship 那个脚本晚了一点点拿到完整文件,所以它变暗了。

结论:这是同步链路的竞态 ——「读到半成品 + 只比较一次」,不在 kitty,也不在 starship。

为什么它特别隐蔽

三个「看起来正常」叠在一起:

没有任何一个环节会亮红灯。 你只会觉得「这次怎么没跟上」, 手动跑一次又好了 —— 于是永远查不到根因。

三条防线

一、生成前等它写完。 不抢跑,连续两次采样内容一致才继续:

```bash
settle_kdeglobals() {
    local f="$HOME/.config/kdeglobals" prev="" cur i
    for ((i = 0; i < 12; i++)); do
        cur="$(md5sum "$f" | cut -d' ' -f1)"
        [[ "$cur" == "$prev" ]] && return 0
        prev="$cur"; sleep 0.4
    done
    return 0   # 超时也继续,避免卡死整条链路
}
```

二、只接受完整输入。 `[Colors:View] BackgroundNormal` 缺失就视为 「还没写完」,报错退出(退出码 3),让调用方跳过本次 —— 而不是拿默认值生成一份浅色垃圾。

三、生成后复核。 再校验一次摘要;如果期间又变了(说明 KDE 还在写), 整体重跑,上限 2 次。

三层叠起来的效果一句话:

宁可这次不更新,也绝不写入错误的颜色。 不更新只是晚一点,写错了会一直错到下次切换。

退出码约定

调用方要据此决策,所以写进文档:

调试重试节奏用 `--wait <秒>` / `--attempts <次数>`; 需要退回旧行为(缺失时用回退默认值)时加 `--allow-missing-colors` —— 但那个开关只该用来排错,不该进自动化链路。

⭐ 「浅色垃圾」到底是什么颜色

这里有个连我自己第一版都复现错的地方,值得讲清楚。

拿一个半成品 `kdeglobals`(只剩 `[General]`)配合 `--allow-missing-colors` 跑:

关键在第二条。 只有方案名也缺失时,兜底路径才断掉, 取色函数才会拿到默认值 `(255,255,255)` → 判为亮色 → 生成近白的浅色。

而那个近白的浅色,恰好与磁盘上已有的浅色文件逐字节相同 —— 于是触发第 5 步的「无变化」判定,永远不再重试。

这也解释了为什么这个问题那么难查:它需要两个条件同时成立 (颜色段缺失 + 方案名缺失)才会发生。偶发、难复现、日志正常。

第二个坑:判断「完整性」必须看原始文件

第一版我把「必需颜色是否齐全」的判断放在合并后的字典上。测试直接打脸:

`.colors` 基座文件会把缺失的 `[Colors:View]` 补齐 —— 防护形同虚设, 真实缺色的 `kdeglobals` 照样生成了浅色主题。

必须判断原始的 `kdeglobals`。 因为「半成品」的定义就是 原始文件里颜色段还没落盘,跟合并后的结果没关系。

怎么验证它真的在工作

隔离测试:用假 `HOME`,不碰真实配置

不要去动你自己的 `~/.config/kdeglobals`。造一个假的:

```bash
FAKE=/tmp/fakehome
mkdir -p $FAKE/.config

# 用系统自带的 BreezeDark 造一份"完整"的 kdeglobals
{
  echo '[General]'
  echo 'ColorScheme=BreezeDark'
  echo 'ColorSchemeHash=deadbeef'
  echo
  # 把系统配色文件里的 [Colors:*] 段搬进来
  awk '/^\[Colors:/{p=1} /^\[(?!Colors:)/{p=0} p' \
      /usr/share/color-schemes/BreezeDark.colors
} > $FAKE/.config/kdeglobals
```

然后 `HOME=$FAKE python3 generate-kitty-theme.py` —— 输出与真实环境一致。

手动复现那个陷阱

这是最值得跑一遍的测试。

```bash
# ① 只写 [General],模拟"KDE 还在写"的中间态
printf '[General]\nColorSchemeHash=deadbeef\n' > $FAKE/.config/kdeglobals

# ② 跑一次 —— 应当退出码 3、一个字节都不写
HOME=$FAKE python3 generate-kitty-theme.py --wait 0.3 --attempts 2
echo "退出码: $?"
```

真实的输出:

```plaintext
[!] kdeglobals 尚未写完(缺 kdeglobals[Colors:View]/BackgroundNormal),0.3s 后重试 (2/2)
[✗] KDE 配色数据不完整,缺少: kdeglobals[Colors:View]/BackgroundNormal。
    通常是 kdeglobals 正在被写入的半成品状态。 本次跳过,不更新 Kitty 配色。
```
```plaintext
退出码: 3      ← stdout 零行,什么都没写
```

再试「写到一半又补上」 —— 也就是当初 bug 的原始场景:

```bash
# 先写半成品,1.2 秒后在后台补全
printf '[General]\nColorSchemeHash=deadbeef\n' > $FAKE/.config/kdeglobals
( sleep 1.2; cp /tmp/full-kdeglobals $FAKE/.config/kdeglobals ) &

HOME=$FAKE python3 generate-kitty-theme.py --wait 0.4 --attempts 6
```
```plaintext
[!] kdeglobals 尚未写完(缺 kdeglobals[Colors:View]/BackgroundNormal),0.4s 后重试 (2/6)
[!] kdeglobals 尚未写完(缺 kdeglobals[Colors:View]/BackgroundNormal),0.4s 后重试 (3/6)
[!] kdeglobals 尚未写完(缺 kdeglobals[Colors:View]/BackgroundNormal),0.4s 后重试 (4/6)
[*] 当前 KDE 配色方案: BreezeDark  (来源: ~/.config/kdeglobals)
[*] 检测到 暗色 主题 (背景亮度=22)
```
```plaintext
退出码: 0      ← 重试机制接住了,拿到正确的暗色
```

旧代码在这个场景下必然留下浅色。 现在它会重试到数据完整为止。

排错

备选方案:kitty 自带的 `auto-color-scheme`

Kitty 0.38 起自带这个功能:跑一次 `kitten themes`,把主题分别存成 light / dark / no-preference 三份 `*.auto.conf`,之后 kitty 自己查询系统配色并切换。

不需要任何外部脚本。 听起来应该直接换过去,但它有个要命的性质:

When these files exist, the colors in these files override all other colors, and also all background image settings, even those specified using the `kitty --override` command line flag.

它会完全压制 `include colors-kde.conf`。也就是说,用了它就等于放弃跟随 KDE 的 accent 和壁纸取色 —— 而那正是这套东西存在的理由。

什么时候它更合适:你只需要亮/暗跟随,不需要终端配色「从壁纸长出来」。

文件清单

`generate-kitty-theme.py`
读 KDE 配色 → 生成 Kitty 主题。放在 `~/.local/share/kde-terminal-theme-sync/`。
`generate-starship-palette.py`
读 KDE 配色 → 生成 Starship 调色板。同上。
`auto-sync.sh`
同步入口:幂等比对 + 热重载信号。同上。
`kde-theme-sync.path`
监听 `kdeglobals`。放在 `~/.config/systemd/user/`。
`kde-theme-sync.service`
oneshot 执行 `auto-sync.sh`。同上。

完整代码:https://github.com/LaT-SKY/kde-terminal-theme-sync(MIT,可直接 clone)

```bash
git clone https://github.com/LaT-SKY/kde-terminal-theme-sync
```

参考