Lesson 0005 refreshAllSessions:从数据到 UI

Lesson 0004 的终点是 SessionInfo[]——一个干净的结构化数据数组。本课的起点正是这个数组:refreshAllSessions() 把它变成你每天在状态栏看到的彩色标签。这是整个扩展中唯一创造 UI 的代码,也是数据和用户之间的最后一道门。

📌 本节课对应代码:extension.ts 第 590–724 行(主函数)、第 183–290 行(emoji 和短名辅助函数)、第 581–588 行(token 格式化)。

1. 它在架构中的位置

          ~/.claude/projects/
                      │
                      ▼
           findActiveSessions()   ← Lesson 0004(数据)
                      │
                      ▼
           refreshAllSessions()   ← 本课(数据 → UI)
                      │
                      ▼
             StatusBarItem[]      ← 状态栏标签

refreshAllSessions() 是整个扩展的调度中心。它的触发机制是事件驱动 + 轮询兜底的混合策略,有五个触发入口:

2. 五种触发时机

触发源代码位置场景
初始化 activate() L56 扩展激活时立即执行首次扫描
文件监听 fs.watch L63 任何 .jsonl 文件变更——用户发了一条新消息
配置变更 onDidChangeConfiguration L49 用户修改了阈值、颜色、紧凑模式等设置
隐藏命令 hideSession L41 用户点击状态栏条目隐藏某个会话
定时轮询 setInterval L75 每 N 秒兜底刷新(默认 30s,可通过 refreshInterval 配置)
// 五种触发路径汇聚到同一个函数
export function activate(context) {
    // 1. 命令触发
    registerCommand('claudeContextBar.hideSession', sessionFile => {
        hiddenSessions.set(sessionFile, Date.now());
        refreshAllSessions();  // ←
    });

    // 2. 配置变更触发
    onDidChangeConfiguration(e => {
        if (e.affectsConfiguration('claudeContextBar'))
            refreshAllSessions();  // ←
    });

    // 3. 初始化触发
    refreshAllSessions();  // ←

    // 4. 文件变更触发
    fileWatcher = fs.watch(claudeProjectsDir, { recursive: true }, (event, filename) => {
        if (filename?.endsWith('.jsonl'))
            refreshAllSessions();  // ←
    });

    // 5. 定时轮询触发
    const intervalSeconds = config.get<number>('refreshInterval', 30);
    refreshInterval = setInterval(refreshAllSessions, intervalSeconds * 1000);  // ←
}
🎯 为什么事件驱动之外还要加轮询?前四种触发是事件驱动的——只在必要时执行,不会浪费 CPU。但 fs.watch 在某些平台(尤其是 Windows + 网络驱动器)上不可靠——可能漏报文件变更事件。第 5 种触发(默认 30 秒轮询)是一个兜底机制:即使文件事件丢了,状态栏最迟 30 秒内也会更新到正确状态。这不是"事件驱动 vs 轮询"的二选一,而是"事件优先 + 轮询兜底"的工程实践——正常路径走事件(即时响应),异常路径走轮询(最终一致性)。这也呼应了 Lesson 0002 中 fs.watch 适配器模式的已知局限。

3. 主流程概览

refreshAllSessions() 的 134 行代码可以分为六个阶段:

阶段 1: 获取数据     sessions = await findActiveSessions()
阶段 2: 读取配置     warningThreshold, dangerThreshold, autoColor, showEmoji, compactMode, …
阶段 3: 分配颜色     projectColorMap: projectName → 颜色(pastel 或 base variations)
阶段 4: 遍历创建/更新  for each session → 创建或复用 StatusBarItem,设 text/color/tooltip/command
阶段 5: 显示         item.show()
阶段 6: 清理消失的   标记-清除:dispose 不再活跃的 StatusBarItem

4. 颜色分配系统

每个项目获得一个固定颜色,同一个项目下的所有会话(webapp、webapp-2、webapp-3)共享该颜色。颜色分配有两套模式:

4.1 自动模式(autoColor: true,默认)

// 9 色 pastel 调色板——按项目首次出现的顺序循环分配
const pastelPalette = [
    '#a8d8ea',  // Soft blue
    '#d4a5a5',  // Dusty rose
    '#b5d8c7',  // Sage green
    '#e8d5b7',  // Warm beige
    '#c9b1ff',  // Lavender
    '#ffd6a5',  // Peach
    '#caffbf',  // Mint
    '#bdb2ff',  // Periwinkle
    '#ffc6ff',  // Pink
];

// 分配逻辑:首次遇到的项目获得下一个可用颜色
for (const session of sessions) {
    if (!projectColorMap.has(session.projectName)) {
        projectColorMap.set(session.projectName, pastelPalette[colorIndex++ % 9]);
    }
}

4.2 手动模式(autoColor: false)

用户选择一个基础色(White、Blue、Purple、Cyan、Green、Yellow、Orange、Pink),不同项目获得该颜色的5 级明度渐变

// 以 Blue 为例:5 个从亮到暗的蓝色
'Blue': ['#a8d8ea', '#9ecfe0', '#94c6d6', '#8abccc', '#80b2c2']

两种模式下,projectColorMap 的构建逻辑完全一致——差别只在于颜色来源。

🎯 一个值得审视的设计选择:projectColorMap每次 refreshAllSessions() 调用时重新计算,而非持久化。颜色按 session 数组中的出现顺序分配(数组按 lastUpdated 从新到老排列)。这意味着:

优点:零状态管理——不需要存储映射、处理过期清理、担心"死项目"占用调色板位置。

代价:颜色不保证跨刷新稳定。如果一个靠前的项目不再活跃(它的所有 session 过期),后面所有项目的颜色索引都会向前滑动一位——用户可能看到熟悉的项目突然变成另一种颜色。

这不是一个"已解决"的设计——它是一个权衡。对于大多数用户(同时活跃 2-3 个项目),颜色漂移很少发生;但对于重度用户(5+ 个项目频繁切换),这可能是一个真实的 UX 痛点。一个可能的改进方向:对 projectName 做确定性哈希取模,使颜色与出现顺序解耦。

5. StatusBarItem 的创建与复用

这是 UI 层的核心循环。对每个 session:

let entry = statusBarItems.get(session.sessionFile);

if (!entry) {
    // 首次出现 → 创建新的 StatusBarItem
    const priority = 900 + (sessions.length - i);
    const item = vscode.window.createStatusBarItem(
        vscode.StatusBarAlignment.Right,
        priority
    );
    entry = { item, sessionFile: session.sessionFile };
    statusBarItems.set(session.sessionFile, entry);
}

// 无论新建还是复用,都更新属性
entry.item.text = `${icon}${iconSpace}${displayName}: ${session.percentage}%`;
entry.item.backgroundColor = …;
entry.item.color = projectColorMap.get(session.projectName);
entry.item.tooltip = …;
entry.item.command = { command: 'claudeContextBar.hideSession', … };
entry.item.show();

5.1 Priority 计算

Priority 决定了条目在状态栏右端的排列顺序——数字越大越靠左。起始值 900(极高的优先级,确保扩展的条目出现在其他扩展左侧),加上一个偏移量使最后面的 session 最靠左:

// sessions 按 lastUpdated 从新到老排列
// session[0] = 最新的 → priority 最大 → 最靠左
// session[4] = 第5新的 → priority 最小 → 最靠右
const priority = 900 + (sessions.length - i);

// 5 个 sessions: 900+5=905, 900+4=904, 900+3=903, 900+2=902, 900+1=901

5.2 文本组装

// 最终显示的文字,例如:🐛 webapp: 72%
const icon = showEmoji ? getEmojiForProject(session.projectName) : '';
const iconSpace = showEmoji ? ' ' : '';
const displayName = compactMode
    ? getShortName(session.projectName, shortNames)
    : session.projectName;
entry.item.text = `${icon}${iconSpace}${displayName}: ${session.percentage}%`;

三个开关控制视觉呈现:showEmoji(图标开关)、compactMode(短名开关)、shortNames(用户自定义映射)。它们完全独立——可以同时启用或关闭。

6. Emoji 模糊匹配

getEmojiForProject() 是扩展中最"有性格"的函数。它根据项目名中的关键词匹配 emoji:

function getEmojiForProject(projectName) {
    const name = projectName.toLowerCase();

    const emojiMap = [
        [['music', 'audio', 'sound', 'song', …],  '🎵'],
        [['game', 'play', 'unity', …],            '🎮'],
        [['web', 'website', 'frontend', 'react', …], '🌐'],
        [['api', 'backend', 'server', …],         '⚙️'],
        // … 18 个分类,共 100+ 关键词
    ];

    for (const [keywords, emoji] of emojiMap) {
        for (const keyword of keywords) {
            if (name.includes(keyword)) return emoji;
        }
    }
    return '🧠';  // 默认
}

匹配是顺序优先的——`emojiMap` 中靠前的分类优先级更高。例如一个叫 web-game 的项目会匹配到 🎵(如果 "game" 相关关键词在 "web" 前面的话……但实际上 "game" 在 "web" 前面,所以是 🎮)。设计者接受了这种"可能不完美但可预测"的行为。

7. 紧凑模式:getShortName()

状态栏空间是稀缺资源。紧凑模式下,项目名被压缩——两种算法:

// 多词项目名:取首字母缩写
"my-cool-project"  →  "MCP"
"dev-tools-api"    →  "DTA"

// 单词项目名:首字母 + 最后音节
"typescript"       →  "Tscript"    (最后音节: "script")
"frontend"         →  "Ftend"      (最后音节: "tend")

// 音节提取:找到最后的"辅音+元音+辅音"群
const match = word.match(/[bcdfghjklmnpqrstvwxz]+[aeiou]+[bcdfghjklmnpqrstvwxz]*$/i);

用户还可以通过 claudeContextBar.shortNames 配置项手动指定映射,优先级最高:

// settings.json
"claudeContextBar.shortNames": {
    "dev-my-super-long-project-name": "DM"
}

8. 威胁阈值着色

背景色不是装饰——它是告警系统

// 三档着色逻辑
if (session.percentage >= dangerThreshold) {
    // ≥ 75%(默认)→ ThemeColor('statusBarItem.errorBackground')
    // 深色主题:暗红 / 浅色主题:浅红
    entry.item.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
}
else if (session.percentage >= warningThreshold) {
    // ≥ 50%(默认)→ ThemeColor('statusBarItem.warningBackground')
    entry.item.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
}
else {
    // < 50% → 无特殊背景(跟随 VS Code 主题默认)
    entry.item.backgroundColor = undefined;
}
阈值默认值背景色含义
dangerThreshold75%statusBarItem.errorBackground即将超出上下文窗口——需要 /clear 或开新会话
warningThreshold50%statusBarItem.warningBackground中度使用——注意控制
正常< 50%无(跟随主题)安全范围
⚙️ 可配置,但只接受百分比:两个阈值都可通过 VS Code 设置修改——claudeContextBar.warningThresholdclaudeContextBar.dangerThreshold。但注意:它们只能填百分比数值,不存在对应的"绝对 token 数"配置项。

代码中的比较逻辑是:
session.percentage = (totalTokens / contextLimit) * 100
if (session.percentage >= dangerThreshold) { … }
如果你想在剩余 5 万 token 时告警,需要手动换算:设定 contextLimit 为 200K → 剩余 5 万 = 用了 150K = 75%。所以设 dangerThreshold: 75。如果你改了 contextLimit,阈值的实际 token 含义会跟着变——这是一个容易被忽视的耦合。
🎯 这里再次出现了 Lesson 0003 的主题:ThemeColor。代码中使用 new vscode.ThemeColor('statusBarItem.errorBackground') 而非硬编码 #ff0000,确保无论在深色还是浅色主题下都能正常工作。这是 VS Code 扩展的 UI 铁律。

9. Tooltip:MarkdownString 的富文本

Tooltip 是用户获取详细信息的主要入口——一个结构化的 Markdown 卡片:

entry.item.tooltip = new vscode.MarkdownString(
    `**webapp** (abc12345)

💬 *"帮我写一个排序函数..."*

📁 \`C:\dev\webapp\`

🤖 Model: \`claude-sonnet-4-6\`

📊 **Context Usage: 72%**

| Type | Tokens |
|------|--------|
| Cache Read | 32K |
| Cache Creation | 18K |
| **Total** | **144K** / 200K |

🕐 Last updated: 14:30:25

*Click to hide*`
);

vscode.MarkdownString 支持 Markdown 语法:粗体、表格、代码块、斜体。工具链极简——不需要 React 或模板引擎,一个构造器调用完成。

🧠 Cache Read / Cache Creation 是什么?这是 Anthropic API 的提示缓存(Prompt Caching)机制。当你发 prompt 给 Claude 时,如果某部分内容(例如 system prompt、长文档)和上一次请求完全一样,API 可以跳过重复计算——直接从缓存读取。两类 token 分别对应:

Cache Readcache_read_input_tokens)——缓存命中,读取时按基础输入价格的 10% 计费(打一折)。这是你省钱的地方。

Cache Creationcache_creation_input_tokens)——缓存写入,首次将内容标记为可缓存时,按基础输入价格的 125% 计费(加收 25% 写入溢价)。这是你为之后的省钱付出的"投资"。

两项之和 + 普通 input_tokens(不走缓存的输入)= totalTokens。tooltip 中展示这两项,让用户直观看到缓存的利用效率——Cache Read 很大说明缓存利用得好,Cache Creation 很大说明正在大量写入新缓存。

📖 Anthropic 文档:Prompt Caching
⚠️ 一个容易被忽视的盲区:Sub-agent 的 token 用量无法统计。当你使用 Agent 工具或 Workflow 派出子代理时,子代理独立发起 API 调用,消耗自己的 token。但 JSONL 中的记录结构是:

// 父会话 JSONL 中的 Agent 调用
assistant → tool_use { name: "Agent", input: { prompt: "..." } }
user     → tool_result { content: [{ text: "子代理的回复..." }] }
//                      ↑ 只有文本回复,没有 usage 数据!

~/.claude/projects/ 下扫描所有含 Agent 调用的会话,0 个 tool_result 中包含 usage 字段。这意味着子代理的 token 消耗被沉默地丢弃了——状态栏显示的百分比和 tooltip 中的 token 总数,实际是父会话直接 API 调用的低估

~/.claude/stats-cache.json 中的 modelUsage 累计了全模型的总 token(很可能已包含子代理),但它只有全量累计、没有 session 粒度——无法按项目拆分。要真正解决这个盲区,需要 Claude Code 把子代理的 usage 写回父会话 JSONL,或暴露独立的聚合端点。这是一个架构层面的缺口,不是扩展偷懒。

10. 标记-清除:清理消失的会话

这是 Lesson 0003 中介绍过的标记-清除模式的完整应用。循环结束后:

// 标记阶段(循环内完成):seenPaths 记录所有活着的 session 文件
// 清除阶段(循环后):dispose 所有不在 seenPaths 中的 StatusBarItem

const seenPaths = new Set<string>();

for (const session of sessions) {
    seenPaths.add(session.sessionFile);  // ← 标记
    // … 创建或更新 StatusBarItem …
}

for (const [sessionFile, entry] of statusBarItems) {
    if (!seenPaths.has(sessionFile)) {
        entry.item.dispose();            // ← 清除
        statusBarItems.delete(sessionFile);
    }
}

这个模式保证了三个关键属性:(1) 不漏——每个不活跃的 session 都被清除;(2) 不误杀——每个活跃的 session 都被保留;(3) 幂等——重复调用不会重复 dispose。

11. 完整数据流

                          触发源
          ┌────────────┬──────┼──────────┬──────────┐
          │            │      │          │          │
    初始化      配置变更  文件监听(.jsonl) 隐藏命令  定时轮询(30s)
          │            │      │          │          │
          └────────────┴──────┼──────────┴──────────┘
                             │
                             ▼
                  findActiveSessions()
                  ┌──────────────────┐
                  │ 扫描 ~/.claude   │
                  │ 解码 项目路径     │
                  │ 解析 JSONL       │  ← 8 步管线
                  │ 定限 上下文窗口   │     ⚠️ 盲区:只能读到
                  │ 过滤 淘汰+隐藏    │        父会话的 usage,
                  │ 编号 稳定命名     │        子代理 token 不可见
                  │ 截断 前 5 条      │
                  └──────┬───────────┘
                      │
                      ▼  SessionInfo[]
                      │
               refreshAllSessions()
               ┌──────────────────┐
               │ 读取 用户配置     │
               │ 分配 项目颜色     │
               │ 遍历 创建/更新    │
               │   ├ text         │
               │   ├ backgroundColor │
               │   ├ color        │
               │   ├ tooltip      │
               │   ├ command      │
               │   └ show()       │
               │ 标记-清除 旧条目   │
               └──────┬───────────┘
                      │
                      ▼
              ┌───────────────────┐
              │ 🐛 webapp: 72%    │ ← 状态栏
              │ ⚙️ api: 45%      │
              │ 📱 mobile: 12%    │
              └───────────────────┘

12. 练习

  1. 打开 extension.ts,找到 refreshAllSessions()(第 590 行),通读一遍——你现在应该能理解每一行的意图
  2. 找到 getEmojiForProject()(第 183 行),看看你的项目名会匹配到哪个 emoji——如果不满意,思考:按什么顺序调整 emojiMap 能改变匹配结果?
  3. 找到 getShortName()(第 256 行),用几个你自己的项目名测试压缩效果。边界情况:单字母项目名、"a-b"两个短词——压缩结果合理吗?
  4. 在第 717–723 行找到标记-清除的清除阶段——如果把这个循环放到主循环之前会有什么问题?
  5. 修改 claudeContextBar.warningThreshold 为 30,修改 contextLimit 为 100000——观察:warning 在多少 token 时触发?验证阈值百分比和 contextLimit 的耦合关系。
  6. 打开 ~/.claude/projects/你自己用过 Agent 的某个项目的 JSONL 文件,搜索 "Agent",找到 tool_use 和紧随其后的 tool_result。确认:tool_result 中是否包含 usage 字段?子代理消耗的 token 去了哪里?

13. 知识检查

问题 1

refreshAllSessions() 的五种触发时机中,哪一种的设计目的是作为其他触发机制的"兜底"?

问题 2

项目颜色分配中,同一项目的多个会话(webapp、webapp-2、webapp-3)会获得什么颜色?

问题 3

Priority 值 900 + (sessions.length - i) 中,为什么最老的 session(i=4)获得最小的 priority?

问题 4

威胁阈值着色使用 new vscode.ThemeColor() 而非硬编码颜色值,原因是什么?

问题 5

标记-清除的清除阶段如果放到主循环之前执行会怎样?

问题 6

getShortName("typescript") 在紧凑模式下的输出是什么?

问题 7

为什么修改 contextLimit 后,warningThreshold 的实际 token 含义会跟着变?

问题 8

关于子代理(Agent/Workflow)的 token 用量,以下哪一项是正确的?

14. 推荐深入阅读

💡 提问提示:refreshAllSessions() 是数据管线的终点——但也是扩展功能迭代的起点。想加入右键菜单?调整排序策略?添加声音警告?想把那 5 条硬编码上限做成可配置的?想把子代理 token 也纳入统计?或者用确定性哈希解决颜色漂移问题?这些问题都可以在这个对话中继续追问。