Lesson 0004 的终点是 SessionInfo[]——一个干净的结构化数据数组。本课的起点正是这个数组:refreshAllSessions() 把它变成你每天在状态栏看到的彩色标签。这是整个扩展中唯一创造 UI 的代码,也是数据和用户之间的最后一道门。
extension.ts 第 590–724 行(主函数)、第 183–290 行(emoji 和短名辅助函数)、第 581–588 行(token 格式化)。
~/.claude/projects/
│
▼
findActiveSessions() ← Lesson 0004(数据)
│
▼
refreshAllSessions() ← 本课(数据 → UI)
│
▼
StatusBarItem[] ← 状态栏标签
refreshAllSessions() 是整个扩展的调度中心。它的触发机制是事件驱动 + 轮询兜底的混合策略,有五个触发入口:
| 触发源 | 代码位置 | 场景 |
|---|---|---|
| 初始化 | 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); // ← }
fs.watch 在某些平台(尤其是 Windows + 网络驱动器)上不可靠——可能漏报文件变更事件。第 5 种触发(默认 30 秒轮询)是一个兜底机制:即使文件事件丢了,状态栏最迟 30 秒内也会更新到正确状态。这不是"事件驱动 vs 轮询"的二选一,而是"事件优先 + 轮询兜底"的工程实践——正常路径走事件(即时响应),异常路径走轮询(最终一致性)。这也呼应了 Lesson 0002 中 fs.watch 适配器模式的已知局限。
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
每个项目获得一个固定颜色,同一个项目下的所有会话(webapp、webapp-2、webapp-3)共享该颜色。颜色分配有两套模式:
// 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]); } }
用户选择一个基础色(White、Blue、Purple、Cyan、Green、Yellow、Orange、Pink),不同项目获得该颜色的5 级明度渐变:
// 以 Blue 为例:5 个从亮到暗的蓝色 'Blue': ['#a8d8ea', '#9ecfe0', '#94c6d6', '#8abccc', '#80b2c2']
两种模式下,projectColorMap 的构建逻辑完全一致——差别只在于颜色来源。
projectColorMap 在每次 refreshAllSessions() 调用时重新计算,而非持久化。颜色按 session 数组中的出现顺序分配(数组按 lastUpdated 从新到老排列)。这意味着:
这是 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();
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
// 最终显示的文字,例如:🐛 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(用户自定义映射)。它们完全独立——可以同时启用或关闭。
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" 前面,所以是 🎮)。设计者接受了这种"可能不完美但可预测"的行为。
状态栏空间是稀缺资源。紧凑模式下,项目名被压缩——两种算法:
// 多词项目名:取首字母缩写 "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" }
背景色不是装饰——它是告警系统:
// 三档着色逻辑 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; }
| 阈值 | 默认值 | 背景色 | 含义 |
|---|---|---|---|
dangerThreshold | 75% | statusBarItem.errorBackground | 即将超出上下文窗口——需要 /clear 或开新会话 |
warningThreshold | 50% | statusBarItem.warningBackground | 中度使用——注意控制 |
| 正常 | < 50% | 无(跟随主题) | 安全范围 |
claudeContextBar.warningThreshold 和 claudeContextBar.dangerThreshold。但注意:它们只能填百分比数值,不存在对应的"绝对 token 数"配置项。
session.percentage = (totalTokens / contextLimit) * 100
if (session.percentage >= dangerThreshold) { … }
如果你想在剩余 5 万 token 时告警,需要手动换算:设定 contextLimit 为 200K → 剩余 5 万 = 用了 150K = 75%。所以设 dangerThreshold: 75。如果你改了 contextLimit,阈值的实际 token 含义会跟着变——这是一个容易被忽视的耦合。
new vscode.ThemeColor('statusBarItem.errorBackground') 而非硬编码 #ff0000,确保无论在深色还是浅色主题下都能正常工作。这是 VS Code 扩展的 UI 铁律。
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_input_tokens)——缓存命中,读取时按基础输入价格的 10% 计费(打一折)。这是你省钱的地方。
cache_creation_input_tokens)——缓存写入,首次将内容标记为可缓存时,按基础输入价格的 125% 计费(加收 25% 写入溢价)。这是你为之后的省钱付出的"投资"。
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,或暴露独立的聚合端点。这是一个架构层面的缺口,不是扩展偷懒。
这是 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。
触发源
┌────────────┬──────┼──────────┬──────────┐
│ │ │ │ │
初始化 配置变更 文件监听(.jsonl) 隐藏命令 定时轮询(30s)
│ │ │ │ │
└────────────┴──────┼──────────┴──────────┘
│
▼
findActiveSessions()
┌──────────────────┐
│ 扫描 ~/.claude │
│ 解码 项目路径 │
│ 解析 JSONL │ ← 8 步管线
│ 定限 上下文窗口 │ ⚠️ 盲区:只能读到
│ 过滤 淘汰+隐藏 │ 父会话的 usage,
│ 编号 稳定命名 │ 子代理 token 不可见
│ 截断 前 5 条 │
└──────┬───────────┘
│
▼ SessionInfo[]
│
refreshAllSessions()
┌──────────────────┐
│ 读取 用户配置 │
│ 分配 项目颜色 │
│ 遍历 创建/更新 │
│ ├ text │
│ ├ backgroundColor │
│ ├ color │
│ ├ tooltip │
│ ├ command │
│ └ show() │
│ 标记-清除 旧条目 │
└──────┬───────────┘
│
▼
┌───────────────────┐
│ 🐛 webapp: 72% │ ← 状态栏
│ ⚙️ api: 45% │
│ 📱 mobile: 12% │
└───────────────────┘
refreshAllSessions()(第 590 行),通读一遍——你现在应该能理解每一行的意图getEmojiForProject()(第 183 行),看看你的项目名会匹配到哪个 emoji——如果不满意,思考:按什么顺序调整 emojiMap 能改变匹配结果?getShortName()(第 256 行),用几个你自己的项目名测试压缩效果。边界情况:单字母项目名、"a-b"两个短词——压缩结果合理吗?claudeContextBar.warningThreshold 为 30,修改 contextLimit 为 100000——观察:warning 在多少 token 时触发?验证阈值百分比和 contextLimit 的耦合关系。~/.claude/projects/ 下你自己用过 Agent 的某个项目的 JSONL 文件,搜索 "Agent",找到 tool_use 和紧随其后的 tool_result。确认:tool_result 中是否包含 usage 字段?子代理消耗的 token 去了哪里?refreshAllSessions() 的五种触发时机中,哪一种的设计目的是作为其他触发机制的"兜底"?
项目颜色分配中,同一项目的多个会话(webapp、webapp-2、webapp-3)会获得什么颜色?
Priority 值 900 + (sessions.length - i) 中,为什么最老的 session(i=4)获得最小的 priority?
威胁阈值着色使用 new vscode.ThemeColor() 而非硬编码颜色值,原因是什么?
标记-清除的清除阶段如果放到主循环之前执行会怎样?
getShortName("typescript") 在紧凑模式下的输出是什么?
为什么修改 contextLimit 后,warningThreshold 的实际 token 含义会跟着变?
关于子代理(Agent/Workflow)的 token 用量,以下哪一项是正确的?
refreshAllSessions() 是数据管线的终点——但也是扩展功能迭代的起点。想加入右键菜单?调整排序策略?添加声音警告?想把那 5 条硬编码上限做成可配置的?想把子代理 token 也纳入统计?或者用确定性哈希解决颜色漂移问题?这些问题都可以在这个对话中继续追问。