最早接触 context-mode 这个词,是折腾编辑器键位映射的时候。当时的痛点很直接:同一个快捷键,在写 Go、改 CSS、记 Markdown 三种场景下,我希望它做三件完全不同的事,但大多数工具只允许绑定一个固定动作。后来我发现,不止编辑器,凡是我每天高频使用的工具链里,都能看到 context-mode 的影子:输入法在代码编辑器里自动切回英文,终端根据当前目录切换主题和别名,浏览器扩展根据站点决定是否启用,甚至智能家居的“回家模式”“睡眠模式”也是同一套逻辑。说白了,context-mode 就是“系统感知当前所处环境,然后自动切换自己的行为模式”。
这篇文章我想把它拆开讲清楚:它不单指某个软件里的开关,而是一种可以落地、可以自建的设计范式。我会从编辑器里最小的配置开始,一路讲到怎么在整台机器上搭一个通用的 context 调度器,最后把我踩过的坑一并说出来。适合正在折腾开发环境、想提升工具链体验、或者对“自动化”感兴趣的朋友参考。
1. context-mode到底是什么:一个被误以为是插件专属的设计范式
很多人第一次看到 context-mode,会以为它是某个编辑器插件或者终端工具的功能。其实它是一个更底层的概念:一个程序通过读取“当前上下文信号”,来决定自己响应哪套规则。你可以把它理解成开车时的驾驶模式——同样的方向盘和油门,在运动模式和雪地模式下,车辆的响应逻辑完全不同,但驾驶员不需要手动切换悬挂、换挡逻辑、牵引力控制这一大堆参数,只需要转一下模式旋钮。
1.1 从一次切键位冲突说起
我自己最初的场景是这样的:在 Neovim 里写 Markdown 时,我习惯用K打开一个悬浮窗口预览;但在写 Go 时,K更希望是跳转 doc 或者查看函数签名。同一个键,两种含义。传统做法是分别记两套键位,或者干脆放弃一对多。后来搜到 Neovim 的b:keymap、ftplugin,才发现编辑器早就有这种“识别文件类型,自动套用不同行为”的能力——这就是一个标准的 context-mode。
这种“同一个操作入口,根据上下文做不同响应”的设计,其实遍布日常软件:
- 输入法在代码编辑器里自动禁用中文候选,切到聊天软件又自动恢复;
- IDE 在 Python 文件里默认 4 空格缩进,在 Go 文件里默认 tab;
- 通知中心在屏幕共享时自动开启勿扰;
- 手机在连接特定蓝牙设备后自动播放对应播放器的音乐。
它们的数据来源可能是文件类型、窗口标题、当前 App、地理位置、时间、网络状态。只要信号可靠,就可以作为 context 的判断依据。
1.2 三种最典型的形态
我习惯把 context-mode 的实现分成三个层次,理解这个分层之后,后面自己搭系统才不会乱。
第一层是内置单点模式。比如 VSCode 的[language]配置、Vim 的filetype机制、Windows 的电源计划。它的特点是:信号单一、目标单一,几乎不需要用户干预,开箱即用。这一层的优点是稳定,缺点是你只能用它预设好的维度来切,想“按 App 切输入法”这种跨维度需求,它做不到。
第二层是工具链组合模式。你通过几个工具配合,把多个信号拼起来。比如用 Hammerspoon 监控当前前台 App,再用 osascript 切换系统输入法;用 tmux 的 hook 监听目录变化,然后重新加载不同的 shell 别名。这一层有很强的灵活度,但规则散落在各个配置里,维护成本高,且很容易出现“两个工具同时改了同一个设置,互相覆盖”的问题。
第三层是统一调度模式。这就是我这篇文章重点要讲的东西:把你需要感知的信号统一采样,把行为规则集中在一个文件里描述,由一个调度器统一裁决、统一执行。它的好处是规则可审计、可测试、可回滚,代价是需要自己写一点代码。
如果你只是想在编辑器里舒服一点,第一层完全够用。但如果你跟我一样,希望整台电脑在不同工作场景下自动“变脸”,那就要往第三层走了。
2. 第一次真正落地:在Neovim里配置按文件类型切换的context
无需任何插件,原生 Neovim 就带了一套非常完整的 context-mode 体系,只是很多人一直没当一回事。这套体系由三部分组成:filetype 检测、autocmd 事件、ftplugin 目录。理解它,等于用最小的成本感受“上下文驱动行为”是怎么回事。
2.1 Neovim天然就有的context体系:filetype、autocmd、ftplugin
Neovim 打开一个文件时,会通过文件名后缀、文件内容特征去推断filetype。这个filetype就是最核心的 context 信号。基于这个信号,有三种地方可以定义“当前文件类型下应该做什么”:
- 在
~/.config/nvim/ftplugin/<filetype>.lua里写仅对该类型生效的本地选项和键位; - 在
after/ftplugin/<filetype>.lua里做优先级更高的修改; - 在任意地方用
autocmd FileType <filetype>定义一个一次性回调。
三者的区别在于加载时机和优先级。我自己最常用的是ftplugin目录,因为它天然按文件类型隔离,不会出现一堆autocmd挤在 init 文件里的情况。
2.2 一份可以直接抄的ftplugin配置
给你看我实际在用的三份配置,每一份都只影响对应的文件类型。
ftplugin/markdown.lua:
-- 写文档时最需要的是“看起来舒服” vim.wo.wrap = true vim.wo.linebreak = true vim.wo.spell = true vim.wo.number = false vim.bo.textwidth = 80 -- 行首是列表符号时,回车自动延续列表 vim.keymap.set("n", "<CR>", function() if vim.fn.getline("."):match("^%s*[-+*] ") then return "<End><CR>" end return "<CR>" end, { expr = true, buffer = true })ftplugin/go.lua:
-- Go 的官方风格是 tab 缩进,全局默认可能是空格缩进,必须在这里改回来 vim.bo.expandtab = false vim.bo.shiftwidth = 4 vim.bo.tabstop = 4 -- 在 Go Buffer 里,K 不再表示文档预览,而是查看库文档 vim.keymap.set("n", "K", "<cmd>GoDoc<CR>", { buffer = true })after/ftplugin/python.lua:
-- 我放在 after 目录,确保能覆盖一些插件写入的默认值 vim.bo.expandtab = true vim.bo.shiftwidth = 4 vim.bo.tabstop = 4 -- Python 需要看到行号,方便排查缩进问题 vim.wo.number = true这种配置方式的好处非常明显:你打开任何文件类型,相关配置自动生效,切走之后又自动恢复,所有状态管理都由编辑器兜底。你不需要自己写“如果文件是Go就设置A,退出时再恢复B”这类逻辑,ftplugin 的关键思想就是“进入 context 时应用,离开 context 时自动清理”。
2.3 比Neovim更简单的VSCode方案:language-specific settings
如果你不用 Neovim,用 VSCode,也有同样的机制,而且比你想的更简单:在settings.json里直接写带语言作用域的设置项即可。
{ "[markdown]": { "editor.wordWrap": "on", "editor.quickSuggestions": { "comments": "off", "strings": "off", "other": "off" }, "editor.renderWhitespace": "none" }, "[go]": { "editor.insertSpaces": false, "editor.tabSize": 4, "files.trimTrailingWhitespace": true }, "[python]": { "editor.insertSpaces": true, "editor.tabSize": 4, "editor.rulers": [88] } }这段配置的含义就是:VSCode 检测到当前文件属于哪种 language context,就自动用对应那组设置。很多团队项目中的.vscode/settings.json也会这样覆盖,它的优先级高于用户级设置,等于把你的个人 context 和项目 context 做了一个叠加。
这里我第一次踩到的一个教训是:VSCode 的语言作用域配置虽然好用,但它只能按“编辑器当前语言”这个单维信号切,没法感知“我是不是在项目根目录下”“当前 git 分支是不是 release 分支”。单文件场景没问题,一旦需求涉及多个上下文信号,还是得回到统一调度的思路。
3. 把context模式扩展到整台机器:自建调度器的三段式设计
编辑器内的 context-mode 用起来很爽,但作用范围仅限于编辑器。我真正觉得“这个东西该自己搭一套”的瞬间,是在我发现自己要同时维护好几套互不相关的自动化脚本:有的看前台 App 切输入法,有的看目录切终端配色,有的看时间开勿扰。它们各自为政,经常打架,最后我只能把需求收敛成一个统一的问题:给我一个能感知“当前处于什么工作场景”的调度器,让场景决定一切。
3.1 检测层:如何拿到可靠的“当前上下文”信号
调度器的第一步是检测信号。信号能不能拿到、拿得稳不稳,直接决定整个系统的可信度。我把常用的信号分成几类,每一类的获取方式都不同。
| 信号 | macOS 可行方案 | 说明 |
|---|---|---|
| 当前前台应用 | osascript + System Events | 最稳定,几乎无延迟 |
| 当前终端目录 | zsh precmd 写缓存文件 | 配合 ANSI 转义序列更新终端标题 |
| 当前输入法 | im-select 命令行工具 | macOS 上相对可控 |
| 系统勿扰状态 | do-not-disturb 相关 API | 新版 macOS 可能变化 |
| 网络状态 | scutil 读取 | 适合判断是否在公司网络 |
| 时间/星期 | 系统时间 | 最没有歧义的信号 |
比较麻烦的是“当前终端目录”。前台 App 是终端时,你拿不到它内部处于哪个目录。我的做法是在 zsh 的 prompt 钩子里把目录写到一个固定文件,调度器再读这个文件。
# ~/.zshrc 中加入 precmd() { echo "$PWD" > "$HOME/.cache/cwd.txt" # 同时把当前目录写到终端标题栏 print -Pn "\e]0;%~\a" }这个方案的优点是改动小,任何终端都能用;缺点是调度器必须轮询读取文件,有一定延迟。后来我切到 tmux,可以直接用 tmux 的pane_current_path变量,但那属于另一个层面的绑定了。
3.2 匹配层:规则引擎的选型与取舍
信号拿到之后,就是“规则匹配”的问题。规则引擎有三个选择:自己写 if-else、用通用脚本语言实现解释器、用现成的事件总线工具。
我的建议是:如果你能接受写一点代码,自写一个轻量的匹配函数比直接铺 if-else 要好得多。原因很简单:if-else 写到第 20 条分支时,你根本看不出来哪个规则会先命中,也测不了“如果同时满足多个条件”的情况。而一个基于规则的匹配器,字段结构清晰,行为可预期。
我用的规则数据结构是这样设计的:
[ { "name": "coding-go", "priority": 90, "when": { "app": "Code", "file_type": "go", "git_branch": "feature/*" }, "actions": [ {"type": "shell", "command": "tmux set status-left 'GO | feature'"}, {"type": "input_source", "value": "ABC"} ], "actions_on_exit": [ {"type": "shell", "command": "tmux set status-left 'DEFAULT'"} ] }, { "name": "terminal-focus", "priority": 50, "when": { "app": "iTerm", "dir_match": "project*" }, "actions": [ {"type": "shell", "command": "bash ~/.context-mode/enter_terminal.sh"} ] } ]匹配规则时,几条规则如果同时命中,按priority降序取第一条。这样最具体的规则优先,比如“写 Go 时打开项目专属状态栏”就比“任何终端下都加载某个主题”的优先级高。
3.3 执行层:动作必须幂等,且能回滚
比较多人忽略的是执行层。他们觉得规则命中了,执行脚本就完事了。但实际运行起来,最大的问题不是“执行不了”,而是“执行错”和“重复执行”。
动作要做到两件事:一是幂等,二是可回滚。幂等的意思是:同一个 context 重复进入多次,执行一次和执行一百次的结果应该是一样的。所以动作脚本里要加判断,比如“如果输入法已经是 ABC,就不再切换”“如果勿扰模式已经打开,就不再设置”。可回滚则是说,离开这个 context 时,要恢复成进入之前的状态。我用actions_on_exit字段专门放离开动作。
我会为每个动作脚本统一做一次日志记录,至少记录触发时间和执行结果:
#!/bin/bash # ~/.context-mode/actions/switch_theme.sh log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$HOME/.cache/context-mode.log" } if [[ $(tmux show -gv @theme) == "dark" ]]; then log "skip theme switch, already dark" exit 0 fi tmux set -g @theme dark tmux source ~/.tmux.conf log "theme switched to dark"3.4 最小实现:30行Python跑通闭环
下面这个文件是我能写出来的最小可运行版本,它把检测、匹配、执行、冷却串在一起。实际要落地还需要把detect()里的具体信号读出来,但框架上足够跑通闭环了。
#!/usr/bin/env python3 import json import time import logging from pathlib import Path logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") log = logging.getLogger("context-mode") RULES_PATH = Path.home() / ".context-mode/rules.json" COOLDOWN = 5 # 防抖秒数,避免连续窗口切换导致动作频繁触发 def load_rules(): with open(RULES_PATH) as fp: return json.load(fp) def detect_signal(): # 具体实现因系统而异,这里只返回一个示意结构 return { "app": get_front_app(), # 需要按 OS 实现 "cwd": read_cwd_cache(), # 读上文的 cwd.txt "file_type": guess_file_type(), "hour": time.localtime().tm_hour, } def evaluate(condition, signal): for key, expected in condition.items(): actual = signal.get(key) if isinstance(expected, str) and expected.endswith("*"): if not actual or not actual.startswith(expected[:-1]): return False elif actual != expected: return False return True def match_context(signal, rules): hit = [r for r in rules if evaluate(r["when"], signal)] if not hit: return None hit.sort(key=lambda r: r.get("priority", 0), reverse=True) return hit[0] def execute_actions(actions): # 实际应转换为具体动作分发器 for act in actions: log.info("execute %s", act) def main(): current = None last_change = 0 while True: signal = detect_signal() ctx = match_context(signal, load_rules()) now = time.time() if ctx and ctx["name"] != current: if now - last_change < COOLDOWN: time.sleep(1) continue if current_rules := find_rule_by_name(current): execute_actions(current_rules.get("actions_on_exit", [])) execute_actions(ctx.get("actions", [])) current = ctx["name"] last_change = now log.info("context changed to %s", current) time.sleep(2) if __name__ == "__main__": main()这段代码虽然初级,但我建议你把它当成一个骨架,先跑通一次“检测到窗口 App 变化 -> 打印一条日志”,再逐步实现动作。一上来就写太多的结果是规则报错时根本不知道去哪排查。
4. 规则文件应该怎么组织,才不会三个月后看不懂
写完调度器,你大概会兴奋地加十几二十条规则。我当初也是这样,结果一个月后打开rules.json,发现很多规则连我自己都忘了是干嘛的。规则文件的结构设计,其实和写代码一样,需要讲优先级、讲复用、讲边界。
4.1 从特殊到一般的优先级
规则匹配最常见的坑是“通用规则太靠前,把特殊规则吞了”。比如你有一条“终端下加载深色主题”的通用规则,又有一条“在 projectA 目录下加载红底主题”的特殊规则。如果通用规则优先级更高,那你在 projectA 里永远看不到红底主题。
我的建议是把规则按“从特殊到一般”排序:目录级最特殊,其次是 App + 文件类型组合,再其次是纯 App 级,最后才是时间、网络这类弱信号规则。如果你不想手动维护序号,就用priority字段显示指定,匹配器按分数从高到低排列。
可以给每条规则打一个“特殊性分数”作为参考:
- 包含
git_branch加 30 分; - 包含
file_type加 20 分; - 包含
cwd加 20 分; - 包含
app加 10 分; - 只有时间或网络等弱信号,不加分。
规则匹配必选高优先级第一条,就算通用规则分数不高,但它是唯一命中的,那就正常执行它。
4.2 用通配与组合条件减少重复
没有通配的规则文件会变得很长。比如你想为feature/login、feature/pay、feature/order三个分支写三条一模一样的规则吗?肯定不要。我在when里支持了两类简写:
{ "when": { "git_branch": "feature/*", "app": ["Code", "Neovim", "iTerm"] } }一个字段值如果是数组,表示“任一匹配即可”;如果以*结尾,就是前缀匹配。这样写出来的规则数量大幅减少,而且一眼就能看懂“这个模式下要覆盖哪些分支”。
多个字段之间默认是 AND 关系。我很少用 OR,因为一旦用了 OR,规则之间的边界就会模糊。我宁愿把 OR 的场景拆成两条规则,也不要在一条规则里写复杂逻辑。
4.3 嵌套上下文与继承:避免规则爆炸
跑到第三周,你会发现有很多规则都共享同一组动作,比如“进入办公场景”要切输入法、切勿扰、切 Wake 主题;“进入编码场景”要在办公场景基础上再改终端配色、加载项目别名。如果每条规则都重新写完整动作列表,很快就会变成一场灾难。
我引入了一个extends字段,允许一条规则继承另一条规则的actions,再额外加自己的动作:
{ "name": "office", "actions": [ {"type": "input_source", "value": "ABC"}, {"type": "dnd", "value": "on"} ] }, { "name": "coding-go", "extends": "office", "priority": 90, "when": { "file_type": "go" }, "actions": [ {"type": "shell", "command": "tmux set status-left 'GO'"} ] }执行coding-go时,调度器会先合并office的所有动作,再追加自己的。这样做之后,同一个动作就不用复制三份了。但我也会提醒自己:继承最多只做两层,超过两层之后,“这条规则最终会执行哪些动作”变得非常难心算,排查问题的时候会疯。
5. 我在这套方案上踩过的坑,以及最后保留的设计原则
这套东西写出来容易,真正稳定的跑起来很难。有些坑只有用了一段时间才会暴露,我这里集中说一下,希望你能少走点弯路。
5.1 误判、抖动和“自动做坏事”
第一个坑是误判。你从 Code 切到浏览器查资料,调度器检测到前台 App 变了,可能立刻把你的 context 从“编码”切成了“浏览”,然后执行了退出编码场景的脚本——结果你查完资料一秒切回 Code,又触发一次重新进入。这个抖动对大多数设置没什么影响,但如果你的“进入/退出”动作涉及重载终端配置、切换输入法,就会让人明显感觉到卡顿。
我后来加了两个机制才解决:一是冷却时间,context 切换之后至少 5 秒内不响应新的切换;二是“稳态确认”,同一个新 context 要连续稳定出现 2~3 个检测周期,才真的切过去。说到底,context-mode 要的是“稳定状态”,不是“每一帧的瞬时状态”。
第二个坑是动作不可逆或误伤。我最初写过一个规则:退出会议软件时自动把系统音量调回 70%。结果有一次我没开会、只是误开了下会议软件,音量突然从 10 跳到 70,差点炸耳朵。从那以后,我把规则分成了两类:可逆动作(主题、输入法、环境变量)可以自动执行;不可逆或影响体验较大的动作(音量、勿扰、退出应用)必须带手动确认,或者干脆不做。
5.2 用户永远需要知道自己处于哪个context
这一点我想单独拎出来说:context-mode 做得再聪明,如果用户不知道当前处于哪个 context,它就是一个黑盒。黑盒会让你在“行为突然不对”的时候完全不知道怎么定位问题。
我的解决办法是给每个 context 一个“可见状态位”。最简单的是改终端 Prompt 的颜色和前缀。进入编码场景时 Prompt 显示[GO],开会议时显示[MTG],避免模式悬浮。另外在rules.json里加一个comment字段,写上这条规则为什么要存在,因为三个月后的你一定会需要这篇“注释”。
5.3 什么场景不值得做context-mode:一张判断清单
不是所有东西都值得自动化。我整理了一个简单的判断逻辑,每次想加新规则前,先对照一遍。
| 场景 | 上下文信号足够强? | 切换频率高? | 动作可逆且可预测? | 结论 |
|---|---|---|---|---|
| 编辑器内按文件类型切换格式 | 强(文件类型) | 中 | 完全可逆 | 强烈建议做 |
| 按前台 App 切输入法 | 强 | 高 | 可逆 | 值得做 |
| 按当前目录切换终端主题和别名 | 中(依赖缓存) | 中 | 可逆 | 可以做 |
| 按日历判断是否开勿扰 | 强(日历) | 低 | 部分可逆 | 可做,但保留手动开关 |
| 按时间自动切换整套应用 | 弱 | 不定 | 不可逆 | 非常不建议 |
| 自动帮你发消息、提交代码 | 任何 | 任意 | 不可逆 | 绝对不要做 |
你会发现,值得做的场景都有几个共性:信号来源单一且稳定、半天内最多切换几次、动作简单且能撤销。信号弱、动作重、切换频繁的,基本都翻车。
现在回头看我自己的配置,当时想做的十几个 context,最后保留的只有五个:编码、写作、开会、评审、休息。规则少了以后,维护成本直线下降,我反而更清楚每一套规则在做什么。如果你也想做这么一套东西,我的建议是:先花一天时间只搭检测层和日志,把你想感知的信号全部打印出来,观察两三天,再开始写规则和动作。不要一上来就自动化,先让系统“看见”,再让它“行动”。