一次交互式会话的完整工作流
编辑器、@ 补全、消息队列、斜杠命令、快捷键与主题
跑起 pi 之后,界面上就三块东西:上面是转录区,中间是你打字的编辑器,底部是状态栏。 但真正决定一次会话顺不顺手的,是三个藏在界面背后的机制:
- 编辑器怎么知道你现在想输入一个文件路径、一个命令,还是一个参数;
- 消息队列怎么在 Agent 正在干活的时候接收你的插话——它有两条独立的投递路径,用途完全不同;
- 全屏 TUI 默认接管了整个视口,这和大多数人以为的「终端原生滚动」正好相反。
本文按一次会话的真实顺序讲:界面分区 → 编辑器 → ! 命令 → 消息队列 → 斜杠命令 → 快捷键 → 全屏与常规 → 主题。命令行层面的事(安装、子命令、运行模式)在上一篇 安装、命令行与运行模式 里。
3.1 界面分区
四个区域
┌──────────────────────────────────────────────┐
│ 启动头部:版本、模型范围、已加载资源 │ ← header
├──────────────────────────────────────────────┤
│ │
│ 转录区:你的提示、模型回复、工具调用与结果 │ ← transcript
│ (全屏模式下滚动发生在这一区内) │
│ │
├──────────────────────────────────────────────┤
│ 队列提示(有排队消息时才出现) │ ← pending
├──────────────────────────────────────────────┤
│ 编辑器:提示词、命令、补全下拉 │ ← editor
├──────────────────────────────────────────────┤
│ 状态栏:目录、会话、模型、上下文用量、成本 │ ← footer
└──────────────────────────────────────────────┘转录区显示你的提示、Pi 的响应、工具调用、工具结果和错误。你可以在这一区域自由拖选文本。
编辑器是你打提示词和命令的地方。它的边框颜色是状态指示器:输入以 ! 开头时边框变色(bash 模式),当前思考等级也会体现在边框上。源码里对 ! 的检测就是编辑器内容变化时的一行判断:
this.defaultEditor.onChange = (text: string) => {
const wasBashMode = this.isBashMode;
this.isBashMode = text.trimStart().startsWith("!");
if (wasBashMode !== this.isBashMode) this.updateEditorBorderColor();
};状态栏显示当前目录、会话、模型、上下文占用、以及累计用量和成本。它是每帧重绘的,所以计算被刻意缓存了:源码的注释写着,用量和上下文统计要扫整棵会话树,而 footer 每帧都渲染,因此只有在会话、session id、叶子节点、条目数或「决定上下文窗口的模型」变化时才重算。
目录在 $HOME 之内时会缩短成 ~/… 形式。状态栏还带 git 分支信息,分支变化会被检测并触发重绘;不在仓库里或处于 detached HEAD 时这一项不显示。
启动头部在告诉你什么
Pi 启动时会把它加载了哪些指令和资源列出来。这不是一个装饰性的 banner,而是一份可审计的清单:
只有存在对应资源时才会出现这个区块。如果有资源冲突(重名、格式错误),头部会额外打出 [Skill conflicts]、[Prompt conflicts]、[Extension conflicts] 这样的警告段。
三条相关的开关:
这个头部值得你每次都扫一眼。 「我以为装了的 skill 根本没加载」是最常见的一类困惑,而答案就在这一屏里。
启动时有一个容易忽略的异步步骤
TUI 挂载之后、键盘处理函数启用之前,Pi 会确保两个外部二进制可用:
// Ensure fd and rg are available after mounting the TUI (downloads if missing, adds to PATH via getBinDir)
// so slow downloads do not make startup appear frozen.
// Both are needed: fd for autocomplete, rg for grep tool and bash commands.
const [fdPath] = await Promise.all([
ensureTool("fd", (status) => this.showManagedToolStatus(status)),
ensureTool("rg", (status) => this.showManagedToolStatus(status)),
]);
this.fdPath = fdPath;
this.setupKeyHandlers();两个设计决定在这里:
第一,顺序是先画界面再下载。 注释写得很明白——避免慢下载让启动看起来卡死了。所以你会看到界面先出来,然后底部可能冒出一条「正在获取 fd」的进度。
第二,键盘处理是在下载完成之后才注册的。 这意味着在启动最初的那一小段时间里,界面已经可见但按键还不生效。这个窗口通常很短,但如果你的机器网络慢、fd 要下载,它就会变得可感知。
fd 是 @ 补全的后端,rg 是 grep 工具和 bash 命令的后端——和 工具系统 里讲的是同一套 ensureTool 机制:先查 ~/.pi/agent/bin/,再查系统 PATH,都没有才下载。
3.2 编辑器:补全、图片与外部编辑器
基础键位
Ctrl+Y / Alt+Y 这一对来自 pi-tui 的一个血环缓冲区(kill-ring),源码注释的原文是 "Ring buffer for Emacs-style kill/yank operations"。这是一个具体的数据结构能力,不是「整体采用某家编辑器的键位风格」——Pi 没有内置的 Vim 编辑模式,也没有整体照搬谁的键位方案;它提供的是命名动作 + 可配置键位的机制,3.6 节展开。
三种补全,一个入口
编辑器只有一个补全下拉,但背后是三种不同的匹配逻辑,由你输入的前缀决定走哪条:
把它们放在一起的原因是:它们共用一个触发判定、一份候选列表、一次 applyCompletion。差异只在前缀识别那一步。
@ 附件补全
@src/core/sett<Tab>@ 之后是模糊匹配,不要求路径前缀连续。getFuzzyFileSuggestions 会把 fd 的结果和你的片段做模糊打分,所以 @agents 能命中 packages/coding-agent/src/core/agent-session.ts。解析时还有一个便利:~ 会被展开成 home 目录,带引号的 @"path with space" 也能正确处理,因为正则表达式里专门为引号内的路径留了分支。
补全后的行为有个细节值得知道:目录后面不自动补空格,普通文件后面才补空格。
const isDirectory = item.label.endsWith("/");
const suffix = isDirectory ? "" : " ";这样你选中一个目录之后可以接着往下打,不会因为多出来的空格把光标弹出上下文。
斜杠命令与参数补全
/ 打开命令列表。命令名本身是模糊匹配的,但有两个例外处理:
- skill 命令同时按带
skill:前缀和不带前缀两种方式匹配。你打/refactor能命中/skill:refactor,打/skill:ref也能。 - 带参数提示的命令在列表里把
argumentHint拼在描述前面显示。
命令名后面有空格时走参数补全。目前有参数补全的内置命令有三个:
/model 的参数补全用的是会话的 scoped models(/scoped-models 配的那一组);没配就退回全部可用模型快照。所以按 Ctrl+P 能循环到的模型,和 /model 补全列出来的模型,是同一组。
扩展注册的命令可以带 getArgumentCompletions,所以它们也能有参数补全。
路径补全
不写 @ 也能补路径。判定规则是:前缀包含 /、以 . 开头、或以 ~/ 开头。都不满足时,空前缀在「看起来正要开始一个词」的位置也会触发。
另外,显式按 Tab 会强制走路径补全,跳过所有自然触发的条件判定——这就是 forceExtract 参数的作用:
// For forced extraction (Tab key), always return something
if (forceExtract) return pathPrefix;所以「Tab 总是能给我点东西出来」,而打字触发的补全更克制。
触发字符与去抖
编辑器维护两个正则:一个判断该不该弹补全,一个判断要不要先去抖。默认触发字符是:
const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"];
const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;去抖模式排除裸 @ 的逻辑值得解释一下:裸 @ 后面还没跟东西,此时去抖只会白等 20 毫秒;一旦跟上了内容,就可以立刻发请求。去抖只加在「已经确定有东西可补」的情形上。
两个例外直接跳过去抖,返回 0 ms:显式按 Tab,以及 force(即 applyCompletion 这类需要立刻拿到候选的路径)。
# 是内置的默认触发字符,但内置的补全源没有为它提供任何候选项。它是一个留给扩展的钩子:扩展通过 addAutocompleteProvider 注册补全器时,可以声明自己的 triggerCharacters,编辑器的触发字符集合会在默认值基础上合并它们。
const next = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
for (const character of triggerCharacters) {
if (character.length !== 1 || character === "/" || isWhitespaceChar(character) || next.includes(character)) continue;
next.push(character);
}注意过滤器:/ 被明确排除(斜杠命令走自己那条路),空白字符被排除,重复的不加。
下拉列表的可见条数默认是 5,可以配到 3 到 20:
{ "autocompleteMaxVisible": 8 }图片:粘贴与拖拽
三种把图片带进来的方式:
粘贴的处理有优先级顺序,源码里的注释写得很清楚:复制的是文件就用原始路径,图片通过临时文件附加,纯文本是最后的兜底。
// Handle clipboard paste (triggered on Ctrl+V). Copied files use their original paths,
// images are attached via temporary files, and plain text is the final fallback.走「原始路径」这一支时还有两个安全处理:路径里出现控制字符直接报错拒绝;在 bash 模式(! 开头)下每个路径都会做 shell 引用。
图片相关的四个默认值:
autoResize 这一条解决的是 token 问题。一张未经缩放的手机截图会吃掉大量视觉 token,2000×2000 是一个成本和辨识度的折中。如果你主要用图片做 OCR 或读图表,这个尺寸通常够用;如果要看代码截图,可能要自己调小。
外部编辑器:Ctrl+G
长提示词在单行编辑器里写很难受。Ctrl+G 把你扔进外部编辑器,回来之后内容被读回编辑器。它是一个快捷键,不是斜杠命令——不存在 /editor 这条命令。
编辑器的选择顺序是一条四级的回退链:
getExternalEditorCommand(): string {
const configuredEditor = this.settings.externalEditor;
if (typeof configuredEditor === "string" && configuredEditor.trim() !== "") return configuredEditor;
const environmentEditor = process.env.VISUAL || process.env.EDITOR;
if (environmentEditor) return environmentEditor;
return process.platform === "win32" ? "notepad" : "nano";
}机制上有两个细节:
临时文件固定叫 prompt.md。 Pi 在系统临时目录下建一个 pi-editor-* 目录,把编辑器当前内容写进去,编辑器退出后读回来、去掉 BOM、去掉结尾一个换行,然后删掉整个目录。
退出码不为 0 就放弃这次编辑。 编辑器里 :cq 退出或者直接杀掉进程,Pi 不会覆盖编辑器里的内容。
还有一个刻意的实现选择值得知道:源码里明确注释了为什么用异步 spawn 而不是 spawnSync——在 Windows 上,同步子进程调用会让 Node/libuv 的控制台输入读取在父进程暂停 stdin 之后保持活跃,和 vim/nvim 抢控制台输入缓冲区,直到 Ctrl+C 才把挂起的读取取消掉。
3.3 用 ! 执行 shell
编辑器里以 ! 开头的内容不是提示词,是 shell 命令。
!git status
!!pnpm build模型看到的是什么
! 的输出会以一条 bashExecution 角色的消息进入会话。转成模型能读的文本时格式是固定的:
Ran `git status`On branch main nothing to commit, working tree clean
Command exited with code 1具体地:
- 命令后面接一个围栏代码块装输出;没有输出时写
(no output)。 - 被取消时追加
(command cancelled)。 - 退出码非 0 时追加
Command exited with code N。 - 输出被截断且有完整输出文件时追加
[Output truncated. Full output: <路径>]。
!! 的区别发生在上下文转换那一步,而不是执行那一步。两条消息都会写进会话文件,区别只在于 excludeFromContext 标记:
case "bashExecution":
// Skip messages excluded from context (!! prefix)
if (m.excludeFromContext) return undefined;
return { role: "user", content: [{ type: "text", text: bashExecutionToText(m) }], ... };所以 !! 的语义准确地说是「执行并记录,但这一轮和后续轮次都不发给模型」。它仍然出现在转录里、仍然占用会话文件的空间,只是不进上下文。
! 和模型调用的 bash 工具不是一回事
这是本节最需要分清的一点:
「有没有超时」这一行的差别最实际:executeBash 的签名里压根没有 timeout 参数。你敲的 ! 会一直跑到自己结束,想停就按 Esc。
PI_* 那一行也常被误解。文档里的原话是:这些变量注入给 LLM 可调用的 bash 和 powershell 工具,不注入给你自己用 ! 或 !! 敲的命令。所以模型能看到 $PI_MODEL 而你看不到,是设计如此。
输出上限
! 的输出和工具输出共享同一套截断常量:超过 50 KB 触发截断,保留尾部(报错和最终结果在末尾),并在需要时把完整输出写进临时文件。滚动缓冲是字节上限的两倍。
三条实际行为
并发保护。 已经有 bash 命令在跑时,再敲一条会被拒绝:
A bash command is already running. Press Esc to cancel it first.
原命令会退回编辑器,不会丢。
流式期间的显示位置。 Agent 正在流式输出时,! 的执行组件挂在上方的「待处理消息」区而不是转录区,等 Agent 这一轮结束后才被刷进转录区。源码里对应的是 _pendingBashMessages 队列加 _flushPendingBashMessages,注释解释了原因:直接追加会破坏 tool_use / tool_result 的顺序。
扩展可以接管。 ! 会先发一个 user_bash 事件,扩展可以返回一个完整结果(Pi 直接用它,不本地执行)、一批 operations(Pi 用自己的执行路径但按扩展给的 operations 跑)、或者什么都不做。这条通道是官方 interactive-shell.ts 之类示例的落点。
shellPath 和 shellCommandPrefix 两个设置影响执行方式:前者指定用哪个 shell,后者给每条命令加一个前缀(官方给的例子是 shopt -s expand_aliases 用来支持别名)。
3.4 消息队列:steering 与 follow-up
Agent 干活的时候你还能不能说话?能。而且有两条独立的投递路径。
两条路径的差别是时机
源码里的文档注释把这四个动作列成了一张表:
以及两个队列的语义:
steering:Delivered after the current assistant turn finishes executing its tool calls, before the next LLM call. follow-up:Delivered only when agent has no more tool calls or steering messages.
怎么选:一条判断题
「等下别做 X,改成做 Y」→ Enter。 steering 消息赶在下一轮 LLM 调用之前进入,所以模型看到它时还能改变计划。Agent 正在跑一个 30 秒的构建,你敲 Enter 说「改成 pnpm」,它会在下一次调用时看到。
「先把这个做完,然后接着做那个」→ Alt+Enter。 follow-up 消息要等到 Agent 手上完全没事了才进入。原因是顺序保证:follow-up 排在所有还没执行的工具调用之后,所以「先跑完测试再改代码」这种依赖关系不会被打乱。
反过来用就糟了:
- 用
Alt+Enter说「等等,别删那个文件」——消息要等所有工具调用跑完才到,而那个文件已经被删了。 - 用
Enter排一堆独立的活——它们会挤进同一个轮次,模型会以为要一起处理。
非流式时的行为
Alt+Enter 只在 Agent 正在跑的时候才有意义。空闲时它等价于普通 Enter:
// If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit)
else if (this.editor.onSubmit) { this.editor.setText(""); this.editor.onSubmit(text); }一次能放几条进去
两个队列各自有一个投递模式设置:
语义:
all:到达取队列的点时,把排着的消息全部注入。one-at-a-time:只注入最老的那一条,剩下的留到下一个取队列点。
默认是 one-at-a-time,这是有意的。你排了五条消息,它们会变成五个独立轮次,模型每条都能认真回应;all 会把五条一次性塞进去,模型倾向于把它们当成同一个任务的一部分,草草带过。
想改成 all 的场景很明确:你在批量投递互不依赖的信息(比如「记一下:数据库是 Postgres 15」重复五条),每条单独一轮纯属浪费。
两个设置是分别的,所以你可以让 steering 一次放行(想赶紧纠正方向),同时让 follow-up 一条一条来(想逐个确认任务)。
压缩期间的队列
自动压缩正在跑时,普通消息会被塞进一个独立的压缩队列,同样区分 steer 和 follow-up。而扩展命令是例外——它们立即执行,不排队。
if (this.session.isCompacting) {
if (this.isExtensionCommand(text)) { await this.session.prompt(text); }
else { this.queueCompactionMessage(text, "steer"); }
return;
}这个例外很重要:当你怀疑某个扩展命令没生效时,先检查是不是正好在压缩。 一个 /xxx 扩展命令在压缩期间是立即跑的,而普通消息在排队。
中止时会发生什么
Esc 的处理是有优先级的,源码里是一条 if-else 链:
双击的阈值是 500 毫秒内两次。触发的动作由 doubleEscapeAction 决定:
排队消息退回编辑器时会合并。 steering 和 follow-up 先拼在一起,然后和你当前正在输入的内容合并,用空行分隔:
const queuedText = allQueued.join("\n\n");
const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n");
this.editor.setText(combinedText);注意两个队列被混在一起退回,没有保留原顺序信息。所以中止之后你可能需要自己重新区分哪条是纠正、哪条是排队。
Windows 和 WSL 上的替代键
Windows Terminal 默认把 Alt+Enter 绑成全屏切换,所以 Pi 在 Windows 和 WSL 上换了键:
/hotkeys 显示的是当前会话实际生效的键,所以在那两个平台上你会直接看到替代键。
3.5 斜杠命令
声明数是 24,可达数是 27
BUILTIN_SLASH_COMMANDS 是一个字面量数组,声明了 24 个命令。但你在编辑器里能用的不止这些——命令菜单里还有三个不在这份数组里的:
/debug 的处理方式能说明为什么它不在数组里——它是一条字符串相等判断,插在提交处理器的最前面:
if (text === "/debug") { this.handleDebugCommand(); this.editor.setText(""); return; }/mcp 和 /llama 则是扩展注册的正常命令。这两个内置扩展都可替换(replaceable: true),所以你写一个同名扩展就能顶掉它们,届时 /mcp 的行为会变成你的实现。
所以正确的说法是:可达命令 27 个(24 + 3)。官方文档 docs/slash-commands.md 并不是这份权威清单——它列了 25 条(24 个声明命令加上 /llama),漏掉的是 /debug 和 /mcp。以 BUILTIN_SLASH_COMMANDS 加上运行时注册的三个为准。
另外还有两个彩蛋命令,不在上述任何一份清单里,但真的能用:/arminsayshi 和 /dementedelves。
24 个声明命令
按用途分组:
模型与设置
会话与上下文
导出与分享
运行时与项目
资源提供的命令
命令菜单的准确内容取决于当前会话加载了什么资源:
扩展命令和内置命令同名会被跳过,不会报冲突错误——源码里只有一条 notice 级提示:
? `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.`新增或修改了命令资源之后要 /reload 才生效——这和扩展、skill、模板、主题、键位、上下文文件是同一批需要 reload 的东西。
几个必须澄清的命令名
旧资料里的这些命令不存在,正确的是右列:
会话相关命令的完整用法在 会话、分支与文件格式。
3.6 键盘快捷键
机制:命名动作 + 映射表
Pi 的快捷键不是硬编码的按键判断,而是命名动作。编辑器暴露 onAction("action.id", handler),键位由一张表映射到动作 id。默认键位来自 packages/tui/src/keybindings.ts,你可以覆盖。
这带来两个直接后果:
第一,/hotkeys 显示的是当前会话实际生效的键,包括你改过的。
第二,扩展注册的动作也有 id,会出现在同一个 /hotkeys 界面里。
配置文件是 <agent-dir>/keybindings.json:
{
"$schema": "https://pi.dev/schemas/keybindings.schema.json",
"app.session.new": "ctrl+shift+n",
"app.session.tree": ["ctrl+shift+t", "alt+shift+t"]
}改完跑 /reload 应用到当前会话。
应用级默认键位
三个退出路径值得单独说:
Ctrl+D 的双重身份来自编辑器本身——Ctrl+D 在编辑器里默认绑定的是「向前删一个字符」,只有内容为空时才升级成退出动作。
Ctrl+S 也同时出现在三个上下文里:app.thinking.save(编辑器)、app.models.save(模型选择器)、app.session.toggleSort(会话列表)。Ctrl+P 同样是两义的:app.model.cycleForward(主编辑器)和 app.session.togglePath(会话列表)。动作 id 决定语义,上下文决定当前生效的那一个。
app.suspend 在原生 Windows 上没有默认键位,因为 Windows 终端不支持 Unix 作业控制。手动绑定了的话 Pi 会显示一条状态消息而不是真的挂起。WSL 上则是正常的 Ctrl+Z + fg。
全屏专用键位
这些只在全屏模式下生效,而且优先级高于编辑器里用同一个键的动作:
另外三个默认没有绑定但可以绑的:halfPageUp、halfPageDown、lineUp、lineDown。
转录搜索是全屏模式的一个实打实的优势——搜索的是渲染后的文本,不是原始 JSONL。
编辑器键位
编辑器键位也可以逐个覆盖,常改的几个:
键语法
一个键写作 modifier+key。修饰符是 ctrl、shift、alt、super,可以组合。合法的主键:
super 绑定要求终端单独上报这个修饰符,通常需要 Kitty 键盘协议。没有这个协议的终端上 super 绑定不会生效。
没有的东西
需要明确的是:pi-tui 里没有内置的 Vim 编辑模式。 你可以自己用扩展接一个 Vim 风格的编辑器组件,但那是扩展提供的功能,不是内置的。
同样,键位方案不是照搬某家编辑器——虽然 Ctrl+Y / Alt+Y 那一对背后确实是一个语义上等价于 kill-ring 的数据结构,但「有 kill-ring」和「整体是某家风格的键位」是两件事。
终端问题排查
改了键没反应,八成是终端的问题而不是 Pi 的问题。Pi 用扩展键协议来区分 Shift+Enter、Alt+Enter 和普通 Enter,而终端代理、复用器和 IDE 内置终端都可能丢掉这些信息。
几个高频的具体坑:
Ghostty 的 Alt+Backspace。 如果不工作,加:
keybind = alt+backspace=text:\x1b\x7fWezTerm 在 macOS 上把 Option+Enter 默认绑成全屏。 需要显式转发:
{
key = 'Enter',
mods = 'ALT',
action = wezterm.action.SendString('\x1b[13;3u'),
}一个假修复的陷阱。 某些终端配置里会出现:
keybind = shift+enter=text:\n这发的是一个裸换行符,Pi 无法把它和 Ctrl+J 区分开。删掉它——Pi 已经把 Ctrl+J 绑定为换行的替代键,所以这条配置看起来能用,但它让 Pi 和 tmux 都收不到真正的 Shift+Enter 事件。
Apple Terminal 的兜底。 Pi 在可用时启用增强键上报;如果 Terminal.app 仍然发普通 Return,Pi 会用一个本地 macOS 修饰键兜底。这个兜底只在 Pi 和 Terminal.app 跑在同一台 Mac 上时有效——通过 SSH 连到另一台机器时它读不到本地修饰键状态。
3.7 全屏与常规模式
默认是全屏
{ "tuiMode": "fullscreen" }源码里的默认值在 settings-defaults.ts:tuiMode: "fullscreen"。
这条很容易被记反。很多介绍 Pi 的资料说它「保留终端原生滚动」——那描述的是 regular 模式,而且需要你显式设置。默认行为正好相反:Pi 拥有整个视口。
命令行上可以覆盖:
pi --tui-mode regular两种模式的差别
源码里对常规模式的描述只有一句,但信息量很大:
Regular mode leaves mouse input to the terminal because the terminal owns scrollback. Design every interaction with a keyboard path even when fullscreen mouse input is available.
即使写了扩展,也应该给每个交互准备一条键盘路径。 这是官方对扩展作者的建议,在全屏鼠标可用的情况下也一样。
鼠标的分配规则:
什么时候切到常规模式
全屏的四个设置
最后一项的 "auto" 有实际含义,值得展开:在本地 macOS 终端上一次滚一行(因为终端本身已经对滚轮和触控板做了加速),在其它地方和 SSH 上则把快速滚轮加速到每次最多 6 行。Alt+滚轮 是五倍距离。
fullscreenCopyOnSelect 为 true 时,Ctrl+X 的行为是复制当前选区;把它设成 false,Ctrl+X 回落到复制最后一条 assistant 消息。
一个具体的终端坑
iTerm2 上全屏滚动会异常慢。 原因是全屏模式下 Pi 拥有视口,iTerm2 于是发鼠标滚轮报告而不是滚动原生历史,快速触控板手势就变成一次一行。
修法是 iTerm2 全局设置:Settings → Advanced → "Trackpad scrolls fast?" 设为 No。注意这是 iTerm2 级别的设置,也会影响原生触控板滚动。
程序状态上报
Pi 用 OSC 7501(Program Status Protocol)报告自己的状态,让终端和 Agent 面板能显示它是在干活、在等你、完成了还是失败了:
三条保证:报告里永远不含提示词和模型输出;只有终端确认支持协议之后才发送(tmux 和 screen 不转发);PI_PROGRAM_STATUS=1 可以不询问直接发,=0 关掉。
3.8 主题
三个内置主题,默认用 system
system 不是一张固定调色板,而是一段查询逻辑:
- Pi 向终端询问默认前景色、背景色和 16 个 ANSI 颜色。
- 每个 Pi 颜色从一个 ANSI 颜色取色相,比如错误色取红、链接色取蓝。
- 每个颜色再单独设定亮度,保证在背景上有足够对比度——正文在背景和每个面板上保持至少 4.5:1 的 WCAG 对比度。
- 终端在明暗之间切换时,Pi 重新查询并重建主题。
启动时 Pi 最多等 100 毫秒拿终端的回答,超时就先显示启动头部,之后颜色到了还会补上(比如慢速 SSH)。system 是保留名——同名自定义主题会被忽略。
选主题
/settings 里选 Theme。可以统一选一个,也可以按终端明暗分别配。存进设置的是:
{ "theme": "dark" }自动模式的写法是浅色在前、深色在后:
{ "theme": "light/dark" }Pi 判定终端明暗的顺序是:终端报告的背景色和前景色 → 终端的明暗通知 → COLORFGBG 环境变量 → dark。自动模式下终端报告外观变化会触发换主题。
主题名不能包含 /,因为这个字符被保留用来表达「浅色/深色」这一对。
单次运行换主题不改设置:
pi --use-theme light
pi --use-theme light/dark自定义主题
主题文件的结构:
内置主题用 OKHSL 色彩空间,并为多个角色共用的颜色提供变量,所以你可以直接调一个色相、饱和度或明度来影响一整组角色。
热重载只有一个特例:Pi 只对 <agent-dir>/themes/<name>.json 做用户主题热重载。从其它来源添加或修改主题后要跑 /reload。主题文件名必须和主题名一致。
主题管到哪里
主题同时管交互模式和 HTML 导出两处的配色。所以导出出来的会话 HTML 和你在终端里看到的是同一套视觉。
写扩展时用传给回调的那个主题对象,不要自己造一套颜色:主题助手会产生带 ANSI 样式的字符串,语义色覆盖 accent、muted text、success、warning、error、工具输出和 Markdown 几个角色。
两个容易踩的点:
不要把带主题颜色的字符串永久存起来。 主题切换会清渲染缓存,但清不掉已经嵌在应用状态里的旧 ANSI 颜色。所以除非 invalidate() 会重建它们,否则每次渲染时重新算。
前景色 token 和背景色 token 不能混用位置。 要把一个 token 的具体颜色用在另一个位置,传它的具体颜色,例如 { fg: theme.colors.userMessageBg }。具体颜色通过 theme.colors 取,颜色运算用 @earendil-works/pi-tui 导出的 mixColors() 之类的工具。
自定义主题、prompt template、skill 和扩展的关系在 定制阶梯 里。
3.9 事实来源
本文所有数字、键位、默认值和路径都可在仓库里复算:
完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。