← tmux-next EN · 中文 · 使用手册

2.2 更新了什么

最近这几个版本改变的是这东西是干什么用的,不只是它能做什么。它本来是在手机上看一眼 某个 tmux 会话的工具,现在它还追踪那个会话对应的 Jira 工单——一路追到 PR 和检查结果, 还会自己把工单的进度往前推。下面是这整条线,加上今天围着它重做的那份列表。

一个 tmux 会话不知道自己是为了什么

在这一切之前,「tmux 里正在干的活」和「Jira 里追踪的工单」唯一的连接点是会话自己的 名字——会话被叫成 DL-ENG-45138,跟着工单号。这不是嫌它难看,是真实的代价: tmux 会话名必须唯一,所以一张工单一辈子只能有一个会话——没法一个跑测试、一个改代码分开开, 名字也读不出别的意思,因为工单号已经把整个名字占满了。

插件是一道缝,不是挖给 Jira 的一个洞

在 Jira 成为一个功能之前,另外两个页面级功能——制品画廊、通知历史——是直接焊进内核里的: 各自一条存储路径、三处路由、一个页面、一条写死的导航项。删掉任何一个都要在五个文件里做减法, 而且从没删干净过。所以修法不是「加一个 Jira」,是一道缝:一个页面级功能就是一个目录, 有内核通用读取的清单文件、自己的服务端处理、自己的样式表。

路上明确否掉了两件事。运行时扫描 ~/.tmux-next/plugins/——那是在一个自己没有 登录的服务里跑任意代码,完全是另一种威胁模型,不是值得冒的便利。以及一串 fetch(req) 中间件链——一条读得懂的 if 路由链,胜过运行时才知道 是哪个插件截了这个请求。有一处拆分从浏览器那一侧也在守着这道边界:清单文件是同构的, 浏览器直接加载,所以有一条测试断言它的 import 图里一个 .ts 文件都没有—— 它一旦 import 了服务端处理函数,服务端代码就跟着混进了浏览器打包产物。

一张工单,好几个会话

Jira 成了第一个插件,它的立身决定是:一张工单可以挂着不止一个会话。所以会话和工单的 绑定按会话名作键,同时存下名字和 tmux 自己的 #{session_id}: id 扛得住改名,名字扛得住服务重启,所以解析一条绑定先试 id、再退回名字,不需要一个 「会话被改名了」的事件也能一直对得上。

这一步故意留白了两件事。往 Jira 写回任何东西——「这个写操作会来自一个自己没有登录的服务」 足够把它推到以后专门的设计里去。以及一个数据库——这里的状态封顶就是「机器上有多少个 tmux 会话」,也就是几十个的量级,纯文本 JSON 已经够用。

工单成了内核自己的概念

工单得活得比挂在它下面的某个会话更久,所以「单」从 Jira 自己的页面里被提拔成内核也认识的 概念:一个持久的、承载意图的东西,会话只是通向它的、可以随时扔掉的手段之一。一张单的来源 provider 是内核从不去校验的一个普通字符串——即便产生它的插件后来被关掉,这张单还是这张单, 只是不再有人给它添彩了。

工单的状态从来不存盘

状态、史诗、PR 数、检查是不是全绿——全都在每次请求时现算,从不落盘缓存。「内核存单本身, 外部系统的说法叠在上面」这条原则,只有在那一层始终是纯叠加时才成立,而一个过期的状态比 没有状态更糟,因为它看起来是真的。唯一故意破例的一条留到了后面:进度状态机自己的阶段 (见下文)确实会存盘——等到有了真正的理由再破例。

检查能告诉你什么,以及它告诉不了你的那一件事

Jira 自己的 dev-status 接口能白拿地回答「这张单挂着哪些 PR」——用的是工单 数据本来就配好的那个 token,不用新凭据。但它答不了这些 PR 过没过 CI:CircleCI 从来不往 Jira 回报,所以 Jira 数据里的构建计数永远是零。于是第二跳去问 Bitbucket,而不是直接问 CircleCI——选它是因为它按 PR 一个个问、跟哪家 CI 无关,同一份回应里 CircleCI 和 Codacy 的结果都在,而且哪怕一个 PR 在另一个仓库里,它照样答得出来,这是 CircleCI 的项目标识 做不到的。

合并冲突状态:认真查过,决定不做

一个 PR 能不能干净合并,看起来是下一个该露出来的信息,所以它被认真查过,不是随手假设的。 Bitbucket Cloud 的公开接口,把它有的字段全要一遍,答回来三十个——跟「能不能合并」沾边的 只有 merge_commitmerge_in_progress 两个,测过的每一个 打开着的 PR 上都是 null。

diff 接口也顶不上:它是三点 diff,不管两条分支实际能不能合并,都会正常返回 200——这是 拿十九个真实打开的 PR 测出来的,不是猜的。网页版确实知道答案,走的是一个私有接口,一个 配置规范的 app password 去问它,得到的是一个干脆的 403。

要够到它,就得在一个自己没有登录的服务里存一份完整登录态的 Bitbucket 浏览器会话—— 为了一个字段,换来一个量级完全不同、大得多的凭据。所以:不提供。以后真要一个粗略信号, 「落后目标分支多少个提交」是用已经在拉取的数据就能算出来的,而且它老实承认自己只是个 代理信号,不是真答案。

一个只会往前走的状态机

最新的一块:现在每张单都带着一个五段的进度状态机——未认领 → 进行中 → 评审中 → 合并中 → 完成——全部从手头已有的信号推出来(有活着的绑定会话、PR 列表非空、检查全绿、 有个 PR 显示已合并),从不接受外部直接灌进来的输入。它只会往前走:一个没合并就关掉的 PR, 会让这张单停在「评审中」,而不是把它退回去——因为「这张单曾经挂出过一个 PR」是个值得留着的 事实,不该被抹掉。

有一处近似直接继承自上一节:评审中 → 合并中说的是「PR 还开着,而且检查全绿」, 不是「可证明能合并」——因为「可证明能合并」根本拿不到(见上面 Bitbucket 那条死路)。 连检查状态都问不到的时候,这一步会被搁置,而不是显示一个可能是假的「可以合并了」。

写回去,但是绕着走

一次状态迁移触发之后,会走跟同步、刷新同一道缝出去——内核查的是哪个启用的插件认领了 这张单的来源,从不写死「就是 Jira」。Jira 这一侧只做两件事:走它真正的迁移接口把工单 往前推,每一段配一个可以留空的映射——留给那些凑不齐五个匹配状态的工作流;以及发一条 PR 评论,只在从「进行中」迈进「评审中」这一步发,因为那是唯一真正需要拉个人来看一眼的 时刻——其余几步都是内部记账。这两步各自可能失败,都是静默地失败、只记日志不抛出来: 写回失败从不撤销本地已经发生的那次状态变化。

最近几天一个不起眼的收尾:单的详情里那份检查列表现在按 PR 分组显示,不再是摊平的一长条; 每组的项目名读的是仓库自己的真名字,不是 PR 地址里那串 UUID;分组标题上还带一个链回原始 PR 的入口。

同一个东西,换了件外套

今天这次列表视图重做,在这条线里不算一个新功能——它是「单变成了你要在很多行里一起扫、 每行都带着进度和一堆 facet」(前面两节)迟早会向展示它的那块屏幕提的要求。这个视图曾经是 一张真正的 <table>:一个字段一列,窗口一变窄就出现横向滚动条——手机上 还是桌面半宽窗口都一样,很容易根本没意识到自己得往右拉。现在状态待在左边,挤不下就换行, 不再横滚;操作固定在右边;你自己加进来要看的字段,会在 chip 上把名字写出来,因为已经没有 表格列头替它说话了。

再往前:0.9 到 0.12

0.12 加上了对着 agent 说话。再往前的 0.9 到 0.11,改的是这东西本身的定位:它不再是一个 「碰巧跑在 tmux 上的 Claude Code 查看器」。

语音输入,但不能用输入法的语音键

在手机上敲提示词是用这东西最慢的一环。最直觉的答案是系统键盘上那个麦克风键,而它在这里 用不了——不是「效果差」,是根本不行。iOS 听写会随着它修正猜测反复重写整段文本,而 xterm.js 把每一次重写都当成新按键。说「语音输入」,终端收到的是 语语音语音输入语音

我试了三种绕法,各自因为不同的原因失败:捕获阶段的监听器赢不过 xterm 在 open() 里注册的那个;听写的 beforeinput 不可取消;而在发送路径里 过滤会毁掉所有走同一条路的东西——粘贴、图片路径、鼠标序列。所以 tmux-next 自己录音, 送去识别。

bunx tmux-next asr <key>   # 火山引擎;不配就是关着的

麦克风出现在终端工具条上、键盘按钮的旁边——它俩本来就是同一类东西:决定屏幕底部那块地 归谁,开一个就关掉另一个。

多段识别攒成一份草稿

第一版是每段识别直接打到提示符上。那对「人是怎么说话的」判断错了:你会说一句、看看落下来 的对不对、再说下一句、顺手改掉识别错的人名。所以现在每段追加到一个可编辑的草稿里, 发送一次把整段打过去并回车。

先点一下草稿,下一段就落在那里而不是末尾;选中一段再说话就替换它,和打字一样。 这能成立只是因为草稿框在面板整个生命周期里是同一个元素——第一版每次渲染都重建它,光标 随之丢失,「插到我刚才说的那句前面」根本做不到。

识别失败或没听清,绝不会把已经攒下的句子一起带走。这是整个设计里唯一不能出错的地方: 攒了三句结果第四句没听清就全没了,那比没有这个功能还糟。

决定架构的那次实测

本来的方案是 key 存服务端、页面直连识别接口——少一跳,音频也不经过这台机器。CORS 预检 看起来是支持的:access-control-allow-origin: *,厂商的鉴权头明确在白名单里。

然后发现实际拿到的凭据只能通过 X-Api-Key 鉴权,而这个头不在那个白名单 上,查询参数的写法则直接被拒。所以音频还是走服务端——而这反而是更好的形状:key 不会到达 浏览器,devtools、截图、扩展这三条暴露面就都不存在了,要配的凭据也从两个减成一个。录音 转发完即丢,不落盘。

我得出过一个错误结论

口述的一行进了提示符却不提交。我猜是 agent 的输入把足够大的一块当成粘贴,而粘贴里的回车 是换行不是提交,于是发布了一个「回车稍后单独发」的修复。

然后我去测了——真的 Claude Code TUI,走手机用的同一条 websocket——两种发法都提交了。 于是我撤销了修复,还写了一段注释说明那个猜测是错的。

测试用的句子有 30 个字。80 个字会失败,121 个字也会失败:粘贴检测有长度阈值,而我的样本 正好在阈值以下。口述的句子都在阈值之上——这也是为什么它只在语音上出现,工具条的 ⏎ 从来 没事,那个发的是前面没有文本的裸回车。拆分改回来了,注释里写上了实测数据,并且把这次 走错的弯路也记了进去,免得下一个人用一句短句再验一遍、得出同样的错误结论。

给那些什么都不说的路径加一份日志

Claude 的钩子被设计成绝不打扰 agent,所以它每一条「决定不做事」的路径都是静默的——而那 正是之前两个 bug 难找的原因。现在每次调用都会记进 ~/.tmux-next/hook-events.jsonl:来的是哪类事件、是不是来自子 agent、归到了 哪个会话、有没有真的推送。

只记这些形状,不记消息内容。为了查一个事件名,就把你和 agent 说的话默认 写到磁盘上,不划算。TMUX_NEXT_HOOK_LOG=off 可以完全不记。

现在能跑三种 agent

终端那条路本来就和里面跑什么无关。capture-panesend-keys 不关心谁在里面;真正与 agent 绑定的只有四件事:怎么启动、怎么恢复、transcript 在哪、 界面在屏幕上长什么样。这四件事收进了 src/agents/opencodepi 就此和 Claude Code 平起平坐——新建、 恢复、任务行、锁屏通知,三家都能用。

选择器只在装了不止一种时才出现,所以只有 Claude Code 的机器,界面和以前完全一样。

不那么显然的地方

检测 agent 是否可用,走的是 login shell——和启动时解析命令的方式一致——而不是查服务自己的 PATH。这比听上去要紧:tmux new-session 对一条根本不存在的命令 照样会建出会话,所以没有这层检测,你会得到一个刚出现就消失的会话,而且什么提示都没有。 这不是假想:第一次把 pi 装到 login shell 看不见的地方时,就是这个现象。

配色主题

四套预设——Tokyo Night、Catppuccin Mocha、One Dark、Nord——从齿轮里切换,不用刷新。 在此之前终端一直用的是 xterm.js 自带的默认值,也就是 Tango 调色板:它是为中灰底的终端设计的, 配上这个应用近乎全黑的背景就发闷。

每套配色都由测试按 WCAG 对比度下限校验。其中两条结果值得单独说,而且都不是我事先以为的:

四套里没有一套的 brightBlack 是能用的。相对各自背景,它们落在 1.7:1 到 2.5:1 之间——而那正是 Claude Code 用来画次要信息的颜色:状态行、 前缀、时间戳。手机拿到户外根本看不清。各自换成了同一官方色板里更亮的一档,色调不动, 只动亮度。

标准只能定 AA,不能定 AAA。我一开始定的是 AAA,测试当场就红了:One Dark 的正文只有 6.6:1,Nord 有两个颜色低于 4.5:1。当四套成熟配色都过不了某条线时, 是那条线错了,不是配色错了。

英文界面

界面是中文,而 README 和 npm 页面是英文——意味着从 npm 找过来的人,打开就是一屏看不懂的字。 现在两种语言都有。从没选过的机器上,第一次请求会按浏览器的 Accept-Language 猜一次并存下来,所以默认就是对的,不用先去翻设置。

锁屏通知跟随同一个设置——推送是唯一会脱离界面出现的文本,界面英文而锁屏中文最突兀。 agent 自己发来的消息则原样透传、不翻译:它比任何预设文案都具体,而且已经是 agent 选择的语言。

让这件事没翻车的那条检查

大约 200 处文案要挪进字典,而它们所在的文件不做类型检查——所以键名打错不会有任何报错, 界面上只是一片空白。有一条测试扫描所有调用点、比对两本字典,缺键、多余的键、 以及只在一种语言里有的键,都会让它失败。最后这条才是最值钱的:英文字典少一个键, 对一个读中文的作者是完全隐形的。第一次跑就抓到了三个真错误。

在页面之间走动

会话列表、制品、通知本是平级的三页,但标记结构把列表当成了枢纽:图标全在列表页, 另两页只有一个返回链接。从制品去通知得先绕回列表,屏幕上也没有任何东西告诉你在哪一页。 现在每页顶栏都一样——左边是切换用的分段控件,右边是操作,当前页的计数就在它自己那一段里。

新建会话则从弹层改成了独立页面。底部弹层是向上生长的,目录一多就会把自己的顶部顶出屏幕; 原先目录列表被限制在视口的 34%,手机上大概就是五六个目录——而这一屏的全部意义就是浏览目录。 它还有三个输入框,而弹层正是 iOS 软键盘最容易出问题的地方。

真正的收益是目录进了 URL。后退键沿着你钻下去的路径一层层退回,而不是把它整个丢掉; new.html?dir=/some/project 也能当链接存下来。设置则保留了弹层: 它的内容是有界的——两种语言、四套配色——而且没有输入框。

两个值得点名的 bug

hook 在悄悄把自己关掉。两个 shell hook 开头都要问 tmux:当前 pane 属于哪个会话。 但一个 pane 属于所有 grouped 到它那个 window 上的会话,而这个查询必须把集合坍缩成 一个答案——它返回最近创建的那个,也就是只要你正在网页上看着,就一定是 tmux-next 自己的挂载点。 于是一个本意为「这是我们自己的挂载点,跳过」的守卫,实际语义变成了「用户正在看这个会话, 所以什么都别做」:不写绑定记录、不发推送,而且哪里都不报错。一次 tmux server 挂掉时, 它让三个会话找不回来。修法是换一个不坍缩的问法——列出全部归属,再自己筛。

发出去过一个渲染不出来的页面。某个模块调用翻译函数 25 次却没有 import 它。 这是合法的 JavaScript——只在那一行真正执行时才失败——所以打包器很满意、整套测试全绿, 而页面一打开就死。现在有一条测试专门查这个;另外还有一条会把页面挂进 DOM 真的渲染出来、 再对结果断言。证明一个文件能解析,不等于证明它能画出东西。

怎么装

bunx tmux-next@latest

如果你之前装过 Claude 的 hook,升级后请重跑一次 bunx tmux-next hook。 那些脚本是复制~/.claude/hooks/ 的,npm 没法就地更新它们—— 而过期的 hook 会静默失效,正是上面描述的那种故障。现在服务启动时会比对一次, 发现已装副本与自带的不一致就打一行提示。

GitHub · npm · 使用手册 · 首页