从编辑器到整机:context-mode 自动化工作流设计范式与实践
最早接触 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、ftpluginNeovim 打开一个文件时会通过文件名后缀、文件内容特征去推断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 EndCR 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, cmdGoDocCR, { 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 dark3.4 最小实现30行Python跑通闭环下面这个文件是我能写出来的最小可运行版本它把检测、匹配、执行、冷却串在一起。实际要落地还需要把detect()里的具体信号读出来但框架上足够跑通闭环了。#!/usr/bin/env python3 import json import time import logging from pathlib import Path logging.basicConfig(levellogging.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(keylambda r: r.get(priority, 0), reverseTrue) 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最后保留的只有五个编码、写作、开会、评审、休息。规则少了以后维护成本直线下降我反而更清楚每一套规则在做什么。如果你也想做这么一套东西我的建议是先花一天时间只搭检测层和日志把你想感知的信号全部打印出来观察两三天再开始写规则和动作。不要一上来就自动化先让系统“看见”再让它“行动”。