# real-vibe-coding

[English](README.md)

一个[Pi](https://pi.dev/)扩展，让对话里出现的一切都跟着音乐的节拍、一个 token 一个 token 或整行地浮现。
RealVibeCoding 用 MIDI 文件作为节奏基准，播放同名音频，并在编辑器上方的舞台面板里按拍揭示
Pi 会打印的内容——助手的正文、思考过程、每一次工具调用那一行。

## 效果

https://github.com/user-attachments/assets/d7bc2118-e3bb-4283-a7cf-eb09bee1271c

```
♪ He's a Pirate · 200 BPM · 0:42/1:17 · sequential
▁▃▅▇█▇▅▃▁▂▄▆█▇▅▃▁▂▄▆█▇▅▃▁▂▄▆█▇▅▃▁▂▄▆█▇▅▃▁
 1 │ Weighed the two parsers, then ran the suite.Running it.
 2 │ $ npm test
 3 │ ℹ tests 85
 4 │ ℹ pass 85
 5 │ ℹ fail 0
 6 │ Imports look fine.import { readFileSync } from "node:fs";
 7 │
 8 │ export function main(path: string): string {
```

正文、思考、日志、代码共用一个舞台，因此不按内容类型配色——唯一的骨架是行号，
以及鼓点。过长的行会换行，而不是被截断丢掉。

力度条是一排原地起伏的均衡器。形状由音乐决定——最近一个起音点的力度定波高，
音高定波纹的疏密（低音宽、高音细），时值定命中后涌动持续多久（长音荡得久、
短音一闪即收）；波纹随速度向前走，所以整行始终在动。它从第一个鼓点起就铺满
面板，不会从一端长出来：不滚动、不累积，只有柱子的高度在变。

队列吐空、演出还没结束时，舞台末尾多出一行活动提示——还在跑的工具取最早开始
的那个，带上已经等了多少秒；没有工具在跑就是在等模型：

```
⏳ $ npm test · 12s
⏳ waiting for the model…
```

不足约 0.7 秒的空档不出这行：那是分段的呼吸，不是等待。面板高度只够标题、
力度条和一行正文时，这行也让位给正文。

舞台上是**原样**的输出。当模型自己没在段落结尾换行时，两段会黏在同一行上，
例如上面的第 1 行和第 6 行——那是模型自己的格式，不是面板加的。唯一的例外是
工具调用那一行：它不是模型的文本，而是扩展自己的文本，所以像会话记录里一样
自成一行（先起行，再收行）。

会话记录里不再出现尚未演完的助手正文和思考——两者都换成一行占位提示，内容
改在舞台上按节奏出现；工具调用和它的结果也一样，块先收成一行提示，舞台演到
那一段再交还给原来的渲染器：

```
▶ performing · 18 lines · 412 chars
▶ performing · read src/engine.ts
```

提示分两种：文字还在流式输出、尚未进队列时是 `printing`；已经排队在演是 `performing`。
占位只在演出进行时顶替原文，每段演出完成的那一刻就当场换回原文——逐段补全，
而不是等任务结束。舞台也是：它有内容要演才出现，任务一结束——agent 已收尾、
队列已吐空——就撤下，把行数还给会话记录：完整输出最终落在会话记录里，而舞台
那点固定高度本来也只放得下它的尾巴。

**工具块先占位、演完恢复**

工具调用行入队时，它的块先收起成一行提示（`▶ performing · read src/engine.ts`）；
调用行演完恢复，工具结果接着占位（`▶ performing · 3 lines · 24 chars`），结果也演完就
回到工具自己的渲染器——该折叠的还是折叠，`ctrl+o` 照常可用。write 和 edit 的调用块
是例外：块上画着作品的预览（write 是正文开头几行，edit 是 diff），预览也是"作品"，
所以它多押一步——等作品印完才露面，不随调用行提前剧透。

## 环境要求

- Pi 0.81 或更高版本

## 安装

```bash
pi install npm:real-vibe-coding
```

或者只在本次会话里试用：

```bash
pi -e npm:real-vibe-coding
```

## 曲库
RealVibeCoding 有自带的曲库。

如需自定义曲库，则需把成对的文件放进 `<agent 目录>/real-vibe-coding/music/`，例如
`~/.pi/agent/real-vibe-coding/music/He's a Pirate.mid` 和同目录下的
`He's a Pirate.wav`，RealVibeCoding 会自动找到它们。

`.mid` 提供节奏，`.wav` 提供声音。两者应当是同一份演奏——打印跟随 MIDI 的
起音点，音频同步播放，所以如果两者对不上，就会看到代码逐渐脱离节拍。

## 配置

运行 `/real-vibe-coding` 打开设置列表，
或直接编辑`~/.pi/agent/real-vibe-coding.json`：

```json
{
  "enabled": true,
  "playlistMode": "sequential",
  "loopTrack": "",
  "stageHeight": 12,
  "showPlaceholder": true,
  "audioBackend": "wav-soundplayer",
  "audioCommand": "ffplay -nodisp -autoexit \"{file}\""
}
```

| 设置项 | 含义 |
|---|---|
| `enabled` | 关闭整个演出，代码恢复为正常渲染。 |
| `playlistMode` | `sequential`（顺序）、`shuffle`（随机）、`loop-one`（单曲循环）。 |
| `loopTrack` | 单曲循环模式下重复的曲目，用曲名（`.mid` 文件名去掉扩展名）；空表示曲库第一首。 |
| `stageHeight` | 舞台面板占用的行数，其中两行是标题和力度条；空闲时的活动提示高度够时再占一行。 |
| `showPlaceholder` | 是否在会话记录里用一行占位提示替换尚未演完的正文、思考和内建工具块。 |
| `audioBackend` | `wav-soundplayer` 或 `command`。 |
| `audioCommand` | `command` 后端的 argv 模板。 |

演出开启时，页脚里有一枚 `♪ performance mode` 标记；关闭时什么都不显示。

## 播放后端

| 后端 | 行为 |
|---|---|
| `wav-soundplayer` | Windows 上的默认值。通过 PowerShell 的 `System.Media.SoundPlayer` 播放 `.wav`。无需安装、不弹窗口，进程被杀时声音立刻停止。 |
| `command` | 执行 `audioCommand`：一个按空白切分的 argv 模板，其中 `{file}` 会替换成媒体路径。进程退出即视为一曲结束。非 Windows 系统或想用更好的播放器时使用。 |

`command` 示例：

```jsonc
"audioCommand": "ffplay -nodisp -autoexit \"{file}\""
"audioCommand": "afplay \"{file}\""
"audioCommand": "\"C:/Program Files/mpv/mpv.exe\" --no-video \"{file}\""
```

命令不经过 shell，且模板在替换 `{file}` **之前**就完成切分，因此带空格的
路径始终是单个参数。播放器自身的路径带空格时，用引号包起来。

## 快捷键

| 按键 | 作用 |
|---|---|
| `ctrl+alt+p` | 暂停 / 继续音乐；暂停期间打印照常。 |
| `ctrl+alt+n` | 切到下一曲；暂停期间按下时，继续后从下一曲开始。 |
| `ctrl+alt+d` | 立刻吐完队列里剩下的代码并停止音乐。 |

`ctrl+alt+p` 把演出按在原地：音乐停下、节拍不再揭示、时钟读数冻结，舞台头部
标出 `⏸ paused`，力度条下那行写清按什么继续。**打印不会跟着冻结**——节拍没了，
队列就没有要等的东西：按下时积压一次印完，按住期间新到的文本随到随印，也就是
pi 默认的速度；模型的背压闸门也因此无事可做。再按一次继续：当前曲目从头重放
（播放器无法从中间接着放），音乐回来，重新按拍指挥之后的打印。演出未在进行时
按键什么都不做；若按下时任务已经收尾、积压又恰好印空，演出就照常收场，舞台撤下。

`ctrl+alt+n` 切到下一曲：当前曲目的播放器被切掉，播放列表前进一首，正在排队的
打印不中断，改由新曲目的节拍接着揭示。暂停期间按下不会立刻换曲——演出还按在原地，
这一按被记住，`ctrl+alt+p` 继续时直接从下一曲开始，而不是重放被按停的那首。演出
未在进行时按键什么都不做。

`ctrl+alt+d` 在积压很多时很有用：Esc 能中止 agent，但停不掉已经排好队的打印。
它也是让正在等待打印跟上来的那次请求立刻放行的最快办法。

## 实现方式

Pi 没有替换助手消息渲染器的 API，也没有延迟 token 流的手段，因此 RealVibeCoding
使用仅有的三个杠杆：

1. **markdown 转换器把它拦得到的东西换成占位提示。** 也就是助手的正文和思考
   ——每个文本片段，以及每一段连续的思考块（后者正是 Pi 交给转换器的单位）。
   片段还在流式输出时显示 `printing`；消息定稿后，只有真正进入过队列的片段才会
   被替换。这份记录只存在于本次进程，绝不落盘——因此恢复旧会话时，旧内容会
   照常显示，不会留下 "内容消失"的历史。它也只覆盖正在进行的那个任务：任务一
   结束就清空，并让会话记录失效重绘，于是记录里渲染出真正的正文。

2. **内建工具带着包了一层的渲染器重新注册**（`toolhold.ts`）。Pi 从工具调用
   流进消息的那一刻就画出它的块——早在工具开始执行之前——没有 "先别画" 的
   接口。但块是由工具自己的渲染器画的，而一个定义可以用同名重新注册：于是每个
   内建工具都包一层渲染器注册回去，调用行或结果文字还在路上时由包装层用同样的
   一行提示作答，打印一走过这段文字，绘制就交还给真正的渲染器。队列坐标沿用
   markdown 侧的那一套：调用行结束在哪、结果文字结束在哪、已经印到哪，`crossed`
   一发现某个被挡住的块整段越过去了，就触发一次重绘。

3. **编辑器上方的部件负责演出。** 正文和思考的 delta 随流式输出入队，所以队列是
   **跑在音乐前面**的，而不是跟在模型后面。工具调用那一行由
   `tool_execution_start` 入队，按工具名和参数重建——转换器看不到调用。工具
   结果则由 `tool_result` 事件入队，该处理器只做观察——在那里返回任何值都会
   替换掉工具结果本身，包括喂给模型的那一份。上舞台的不总是结果自己的文字：
   `read` 的结果是例外，读进来的文件内容不是产出，不入队；`write` 和 `edit`
   则是演出活儿而不是回执——write 把它写下的全文交上来，edit 把它算出的 diff
   交上来，取代"Successfully …"那类一行回执。回执里没有活儿可演时（出错、或
   结果没带载荷），才按原文入队。这两种工具的调用块自带作品预览，它也押在作品
   后面：工具运行期间不露面，等作品印完才露面（出错时，等错误信息印完）；结果
   什么都没演时——没有打印步可越过——由 `close` 当场揭示，并请一次重绘。

   每个 MIDI 起音点揭示多少由音符自己定价：速度定每秒钟的基准，力度是重音
   ——最强拍打印的 token 数是最弱拍的十六倍；时值是要买的时长——长音一次多
   印、短音一次少印，文件没给长度的按一拍算，再长的音也按一小节封顶；音高反向
   倾斜——同样长度与力度，低音的价码乘 2、高音的乘 0.5；和弦厚度再加一道砝码
   ——单音按原价，和弦越厚越重、趋近两倍，每个音加得都比上一个少。预算以
   整 token 为单位花掉：一个 delta 就是一个 token，整块浮现，绝不会看到半个
   token 慢慢长出来；工具调用行和工具结果没有 token 可言，按每 token 四个字符
   折算后逐字符切。这一拍装不下开头那个 token 时也会先把它整块印出来，所以
   每一拍都有动静。

   预算攒到一整行的价码（24 个 token，约当面板里的一行文字）时，这一拍不再数
   token，直接买下**整行**。光标停在行首，印出的就是一整行；停在行内，则先把
   被打断的这行补完，再连下一行一起印出来——最强的拍落地时，读者看到的是一条
   完整的行。行还没从模型到齐的，按队列里现有的内容印。

   模型也会被同步拉住：一旦缓存里的内容多到音乐要花大约一分钟才印得完，下一次
   向 LLM 的请求就会先停住，等打印把积压消化到一定程度再放行。没有这道闸，长
   任务攒下的输出会比演出快得多——队列会一直涨下去。等待中的请求会自行恢复；
   `ctrl+alt+d` 可以让它立刻放行。

时钟就是 MIDI 文件自带的变速表，因此速度变化被精确跟随。起音点调度在每次
命中后都按**绝对目标时刻**重新计算下一次的等待时长，所以掉帧不会让后面的拍
点整体偏移。另有一个独立的 30fps 定时器，只负责视觉脉冲、时钟读数和活动行。

音乐在一个任务里**首次有输出**时才开始，而不是任务一开始——舞台也在同一时刻
出现。思考期间音乐继续播放；一首放完自动续下一首；只有任务已经收尾**且**队列
已吐空时才停止。这一次停止同时也是把已打印的文本交还会话记录、并撤下舞台的
时刻：面板只存在于演出期间，空闲时一行也不占。队列吐空而演出未终时，活动行
报出在等什么：一个还在跑的工具（取最早开始的）或模型；空档不足约 0.7 秒、
或面板腾不出那一行时，它不显示。

`ctrl+alt+p` 的暂停是引擎自己的状态，不是打印的：播放器被切掉、节拍定时器
取消、时钟帧冻结，曲目循环停在一个恢复点上等。打印不归它管——节拍没了，队列
就没有要等的东西：暂停瞬间把积压一次吐完，按住期间每次入队后立即揭示，随到随印
即 pi 默认的节奏；背压闸门自然也无事可做。继续时同一个循环接着跑，把当前曲目
从头打开，音乐重新指挥之后新文本的揭示。若任务已经收尾、暂停把最后一截印完，
演出照常按"收尾且队列已空"收场。

`ctrl+alt+n` 的切歌只动曲目循环：播放器切掉、播放列表前进，队列原地不动，由
下一首的节拍接着揭示；按住期间按下时记成标记，循环在恢复点上读到它就直接换到
下一首，而不是把当前这首从头打开。

播放列表按会话记住位置：顺序和随机模式下，同一个会话里的下一次任务接着上一首
停下的地方继续，而不是从曲库开头重来；单曲循环重复的那一首在设置面板里选择。

## 故障排查

**pi 中途退出，终端随后不停刷出乱码。** 原因通常在模型自己执行的一条命令：pi
就运行在 `node.exe` 上，而按映像名通杀的命令——`taskkill /F /IM node.exe`、
`Stop-Process -Name node`——会把 pi 连同它本想清掉的那个进程一起终结。`/F` 是
硬杀：没有任何清理来收起终端，原始模式、隐藏光标、鼠标跟踪就此留下；之后 shell
会把每次按键的转义序列原样回显，看起来就是终端在连续打印乱码。

RealVibeCoding 用 pi 的 `tool_call` 事件挡住了这类命令（与演出是否开启无关）：命中的
调用不会执行，模型会收到一条说明，要求它改用按 PID 的杀法——先
`netstat -ano | findstr :<端口>` 找到 PID，再 `taskkill //F //PID <PID>`。拦截面
很窄：只有 shell 工具里按名字通杀 node 的写法会被拦，按 PID、或对别的映像名的
kill，都不受影响。

终端已经花了的话，在 shell 里执行 `reset`（或重开一个终端）即可恢复；乱码只是
被回显的转义序列，没有破坏任何东西。若还有音乐在响，那是被硬杀时没来得及退出的
播放器进程，这一首放完就会停。

## 已知限制

- **不做音频/MIDI 合成。** 内置后端播放预渲染好的 `.wav`。要合成 `.mid` 就得
  依赖 SoundFont 音色库，而用户未必有。
- **不做节拍检测。** MIDI 文件本身就带精确的变速表，分析音频只会引入误差。
- **不获取真实播放位置。** 默认播放器不回报播放到哪儿了，进度读数由 RealVibeCoding
  自己的时钟推算。播放器的启动延迟是恒定偏移，由 `musicSyncOffsetMs` 吸收。
- **一曲的节奏随它的 MIDI 结束。** 如果 `.wav` 比 `.mid` 长（通常只多几秒的
  尾部余音），打印会暂停到下一首开始。
- **继续演出会把当前曲目从头重放。** 后端的契约只有"放一个文件、停下、结束"：
  不回报位置，也不接受从中间起播。暂停切掉的是播放器进程，恢复就只能重放整首。
  打印不受影响：暂停时积压一次印完，按住期间随到随印，音乐回来后才重新按拍。
  不想重放这首，就先在按住期间按 `ctrl+alt+n` 切歌，继续时从下一首开始。
- **很长的输出会让 agent 停一下。** 一旦积压到音乐要花大约一分钟才能印完，下一次
  请求就会等打印跟上——否则队列会在整个任务期间一直涨下去。`ctrl+alt+d`
  可以跳过这次等待。
- **只有内建工具的工具块会先占位。** 块由工具自己的渲染器绘制，而扩展只能
  重建内建工具的定义；自定义工具和 MCP 工具的渲染器没有接口可以读取，它们的
  块在演出期间照常显示。入队演出不受影响：调用行和结果文字照常上舞台。
- **图片不参与演出。** 结果为图片的工具调用在舞台上什么都不打印——没有文字可
  揭示。Pi 仍会在会话记录里画出它。
- **读取的文件内容不参与演出。** read 的结果不上舞台——读进来的输入不是 agent
  的产出，工具块随到随画（如同图片），文件内容照常留在会话记录里。顺带的好处：
  读大文件不再让队列涨起来、让 agent 在背压闸门前等打印。
- **你自己发的话不参与演出。** 演出的是 agent 的产出，你输入的内容不在其中。
- **Pi 隐藏思考时，舞台仍会演出思考。** `hideThinkingBlock` 只隐藏会话记录里的
  思考块。若两者都不想要，请关闭整个演出。

## 许可

MIT
