跳到主内容
创建时间:最后更新:作者:白川

一次交互式会话的完整工作流

编辑器、@ 补全、消息队列、斜杠命令、快捷键与主题

跑起 pi 之后,界面上就三块东西:上面是转录区,中间是你打字的编辑器,底部是状态栏。 但真正决定一次会话顺不顺手的,是三个藏在界面背后的机制:

  1. 编辑器怎么知道你现在想输入一个文件路径、一个命令,还是一个参数;
  2. 消息队列怎么在 Agent 正在干活的时候接收你的插话——它有两条独立的投递路径,用途完全不同;
  3. 全屏 TUI 默认接管了整个视口,这和大多数人以为的「终端原生滚动」正好相反。

本文按一次会话的真实顺序讲:界面分区 → 编辑器 → ! 命令 → 消息队列 → 斜杠命令 → 快捷键 → 全屏与常规 → 主题。命令行层面的事(安装、子命令、运行模式)在上一篇 安装、命令行与运行模式 里。

3.1 界面分区

四个区域

text
┌──────────────────────────────────────────────┐
│  启动头部:版本、模型范围、已加载资源          │  ← header
├──────────────────────────────────────────────┤
│                                              │
│  转录区:你的提示、模型回复、工具调用与结果    │  ← transcript
│  (全屏模式下滚动发生在这一区内)              │
│                                              │
├──────────────────────────────────────────────┤
│  队列提示(有排队消息时才出现)               │  ← pending
├──────────────────────────────────────────────┤
│  编辑器:提示词、命令、补全下拉               │  ← editor
├──────────────────────────────────────────────┤
│  状态栏:目录、会话、模型、上下文用量、成本     │  ← footer
└──────────────────────────────────────────────┘

转录区显示你的提示、Pi 的响应、工具调用、工具结果和错误。你可以在这一区域自由拖选文本。

编辑器是你打提示词和命令的地方。它的边框颜色是状态指示器:输入以 ! 开头时边框变色(bash 模式),当前思考等级也会体现在边框上。源码里对 ! 的检测就是编辑器内容变化时的一行判断:

typescript
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,而是一份可审计的清单:

区块内容
Context
找到的上下文文件(AGENTS.md、CLAUDE.md 等)
Skills
加载的 skill
Prompts
加载的 prompt template
Extensions
加载的扩展

只有存在对应资源时才会出现这个区块。如果有资源冲突(重名、格式错误),头部会额外打出 [Skill conflicts]、[Prompt conflicts]、[Extension conflicts] 这样的警告段。

三条相关的开关:

动作效果
quietStartup: true
隐藏启动头部和已加载资源清单
quietStartup: "header"
保留头部(版本和快捷键提示),隐藏模型范围行和资源清单
/reload
重新加载并重新显示

这个头部值得你每次都扫一眼。 「我以为装了的 skill 根本没加载」是最常见的一类困惑,而答案就在这一屏里。

启动时有一个容易忽略的异步步骤

TUI 挂载之后、键盘处理函数启用之前,Pi 会确保两个外部二进制可用:

typescript
// 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 编辑器:补全、图片与外部编辑器

基础键位

你按效果
Enter
提交(发消息,或执行命令)
Shift+Enter、Ctrl+J
插入换行
Tab
补全
Esc
中止(见 3.4)
Ctrl+A / Ctrl+E
行首 / 行尾
Ctrl+W / Alt+Backspace
向前删一个词
Ctrl+U / Ctrl+K
删到行首 / 行尾
Ctrl+Y / Alt+Y
粘贴最近删除的内容 / 在删除历史里循环
Ctrl+-
撤销上次编辑(Windows 上是 Ctrl+Z,WSL 上是 Alt+Z)

Ctrl+Y / Alt+Y 这一对来自 pi-tui 的一个血环缓冲区(kill-ring),源码注释的原文是 "Ring buffer for Emacs-style kill/yank operations"。这是一个具体的数据结构能力,不是「整体采用某家编辑器的键位风格」——Pi 没有内置的 Vim 编辑模式,也没有整体照搬谁的键位方案;它提供的是命名动作 + 可配置键位的机制,3.6 节展开。

三种补全,一个入口

编辑器只有一个补全下拉,但背后是三种不同的匹配逻辑,由你输入的前缀决定走哪条:

你输入补全什么后端
/ 或 /命令名
斜杠命令,以及该命令的参数
内置列表 + 扩展注册的命令
@
附件:文件与图片,模糊匹配
fd
看起来像路径的词,或按 Tab
路径:普通路径补全
fd

把它们放在一起的原因是:它们共用一个触发判定、一份候选列表、一次 applyCompletion。差异只在前缀识别那一步。

@ 附件补全

text
@src/core/sett<Tab>

@ 之后是模糊匹配,不要求路径前缀连续。getFuzzyFileSuggestions 会把 fd 的结果和你的片段做模糊打分,所以 @agents 能命中 packages/coding-agent/src/core/agent-session.ts。解析时还有一个便利:~ 会被展开成 home 目录,带引号的 @"path with space" 也能正确处理,因为正则表达式里专门为引号内的路径留了分支。

补全后的行为有个细节值得知道:目录后面不自动补空格,普通文件后面才补空格。

typescript
const isDirectory = item.label.endsWith("/");
const suffix = isDirectory ? "" : " ";

这样你选中一个目录之后可以接着往下打,不会因为多出来的空格把光标弹出上下文。

斜杠命令与参数补全

/ 打开命令列表。命令名本身是模糊匹配的,但有两个例外处理:

  • skill 命令同时按带 skill: 前缀和不带前缀两种方式匹配。你打 /refactor 能命中 /skill:refactor,打 /skill:ref 也能。
  • 带参数提示的命令在列表里把 argumentHint 拼在描述前面显示。

命令名后面有空格时走参数补全。目前有参数补全的内置命令有三个:

命令参数补全的内容
/model
可用模型,标签是 provider/id
/thinking
当前模型支持的思考等级
/login
支持登录的供应商

/model 的参数补全用的是会话的 scoped models(/scoped-models 配的那一组);没配就退回全部可用模型快照。所以按 Ctrl+P 能循环到的模型,和 /model 补全列出来的模型,是同一组。

扩展注册的命令可以带 getArgumentCompletions,所以它们也能有参数补全。

路径补全

不写 @ 也能补路径。判定规则是:前缀包含 /、以 . 开头、或以 ~/ 开头。都不满足时,空前缀在「看起来正要开始一个词」的位置也会触发。

另外,显式按 Tab 会强制走路径补全,跳过所有自然触发的条件判定——这就是 forceExtract 参数的作用:

typescript
// For forced extraction (Tab key), always return something
if (forceExtract) return pathPrefix;

所以「Tab 总是能给我点东西出来」,而打字触发的补全更克制。

触发字符与去抖

编辑器维护两个正则:一个判断该不该弹补全,一个判断要不要先去抖。默认触发字符是:

typescript
const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"];
const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
概念值作用
默认触发字符
@、#
在 token 边界上打这些字符会自然触发一次补全请求
触发模式
buildTriggerPattern
匹配 @ 加任意非空白后缀,或其它触发字符加任意非空白后缀
去抖模式
buildDebouncePattern
匹配同样的形状,但排除裸 @
去抖时长
20 ms
仅在去抖模式命中时生效

去抖模式排除裸 @ 的逻辑值得解释一下:裸 @ 后面还没跟东西,此时去抖只会白等 20 毫秒;一旦跟上了内容,就可以立刻发请求。去抖只加在「已经确定有东西可补」的情形上。

两个例外直接跳过去抖,返回 0 ms:显式按 Tab,以及 force(即 applyCompletion 这类需要立刻拿到候选的路径)。

# 是内置的默认触发字符,但内置的补全源没有为它提供任何候选项。它是一个留给扩展的钩子:扩展通过 addAutocompleteProvider 注册补全器时,可以声明自己的 triggerCharacters,编辑器的触发字符集合会在默认值基础上合并它们。

typescript
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:

json
{ "autocompleteMaxVisible": 8 }

图片:粘贴与拖拽

三种把图片带进来的方式:

方式键说明
粘贴
Ctrl+V
macOS 上粘贴文件、图片或文本(Windows/WSL 上是 Alt+V)
拖拽
直接拖进终端
取决于终端是否支持
路径
@图片路径
和普通文件走同一套附件机制

粘贴的处理有优先级顺序,源码里的注释写得很清楚:复制的是文件就用原始路径,图片通过临时文件附加,纯文本是最后的兜底。

typescript
// 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 引用。

图片相关的四个默认值:

设置默认作用
terminal.showImages
true
终端支持时内联渲染图片
terminal.imageWidthCells
60
内联图片的宽度(终端列数)
images.autoResize
true
发给模型前把图片缩到最长边 2000 像素
images.blockImages
false
设为 true 则完全不发图片给模型

autoResize 这一条解决的是 token 问题。一张未经缩放的手机截图会吃掉大量视觉 token,2000×2000 是一个成本和辨识度的折中。如果你主要用图片做 OCR 或读图表,这个尺寸通常够用;如果要看代码截图,可能要自己调小。

外部编辑器:Ctrl+G

长提示词在单行编辑器里写很难受。Ctrl+G 把你扔进外部编辑器,回来之后内容被读回编辑器。它是一个快捷键,不是斜杠命令——不存在 /editor 这条命令。

编辑器的选择顺序是一条四级的回退链:

typescript
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";
}
优先级来源
1
设置里的 externalEditor
2
环境变量 VISUAL
3
环境变量 EDITOR
4
Windows 上 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 命令。

前缀执行输出进对话(模型可见)
!
是
是
!!
是
否
text
!git status
!!pnpm build

模型看到的是什么

! 的输出会以一条 bashExecution 角色的消息进入会话。转成模型能读的文本时格式是固定的:

text
Ran `git status`

On branch main nothing to commit, working tree clean

plaintext

Command exited with code 1

具体地:

  • 命令后面接一个围栏代码块装输出;没有输出时写 (no output)。
  • 被取消时追加 (command cancelled)。
  • 退出码非 0 时追加 Command exited with code N。
  • 输出被截断且有完整输出文件时追加 [Output truncated. Full output: <路径>]。

!! 的区别发生在上下文转换那一步,而不是执行那一步。两条消息都会写进会话文件,区别只在于 excludeFromContext 标记:

typescript
case "bashExecution":
  // Skip messages excluded from context (!! prefix)
  if (m.excludeFromContext) return undefined;
  return { role: "user", content: [{ type: "text", text: bashExecutionToText(m) }], ... };

所以 !! 的语义准确地说是「执行并记录,但这一轮和后续轮次都不发给模型」。它仍然出现在转录里、仍然占用会话文件的空间,只是不进上下文。

! 和模型调用的 bash 工具不是一回事

这是本节最需要分清的一点:

你敲的 !模型调用的 bash 工具
谁决定跑什么
你
模型
有超时
没有
模型可以传 timeout(秒)
PI_* 会话环境变量
不注入
注入
输出进上下文
! 进,!! 不进
进
结果落在哪
会话里的 bashExecution 消息
标准的 tool result
扩展能不能插手
能,user_bash 事件
能,tool_call / tool_result 事件

「有没有超时」这一行的差别最实际: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 干活的时候你还能不能说话?能。而且有两条独立的投递路径。

两条路径的差别是时机

你要的按内部行为投递时机
调整当前任务
Enter
streamingBehavior: "steer"
当前 assistant 轮次执行完它的工具调用之后、下一次 LLM 调用之前
排上后续任务
Alt+Enter
streamingBehavior: "followUp"
Agent 没有更多工具调用、也没有 steering 消息之后
把排队消息取回编辑
Alt+Up
清空两个队列
立刻
停掉当前任务
Esc
中止
立刻,且排队消息退回编辑器

源码里的文档注释把这四个动作列成了一张表:

What you wantAction
Adjust the current task
Type a message and press Enter
Add work after the current task
Type a message and press Alt+Enter
Return queued messages to the editor
Press Alt+Up
Stop the current task
Press Escape

以及两个队列的语义:

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:

typescript
// If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit)
else if (this.editor.onSubmit) { this.editor.setText(""); this.editor.onSubmit(text); }

一次能放几条进去

两个队列各自有一个投递模式设置:

设置取值默认
steeringMode
all、one-at-a-time
one-at-a-time
followUpMode
all、one-at-a-time
one-at-a-time

语义:

  • all:到达取队列的点时,把排着的消息全部注入。
  • one-at-a-time:只注入最老的那一条,剩下的留到下一个取队列点。

默认是 one-at-a-time,这是有意的。你排了五条消息,它们会变成五个独立轮次,模型每条都能认真回应;all 会把五条一次性塞进去,模型倾向于把它们当成同一个任务的一部分,草草带过。

想改成 all 的场景很明确:你在批量投递互不依赖的信息(比如「记一下:数据库是 Postgres 15」重复五条),每条单独一轮纯属浪费。

两个设置是分别的,所以你可以让 steering 一次放行(想赶紧纠正方向),同时让 follow-up 一条一条来(想逐个确认任务)。

压缩期间的队列

自动压缩正在跑时,普通消息会被塞进一个独立的压缩队列,同样区分 steer 和 follow-up。而扩展命令是例外——它们立即执行,不排队。

typescript
if (this.session.isCompacting) {
  if (this.isExtensionCommand(text)) { await this.session.prompt(text); }
  else { this.queueCompactionMessage(text, "steer"); }
  return;
}

这个例外很重要:当你怀疑某个扩展命令没生效时,先检查是不是正好在压缩。 一个 /xxx 扩展命令在压缩期间是立即跑的,而普通消息在排队。

中止时会发生什么

Esc 的处理是有优先级的,源码里是一条 if-else 链:

优先级状态Esc 的效果
1
Agent 正在流式
排队消息全部退回编辑器 并中止
2
bash 命令正在跑
中止 bash
3
编辑器处于 bash 模式(! 开头)
清空编辑器
4
编辑器为空
双击触发动作

双击的阈值是 500 毫秒内两次。触发的动作由 doubleEscapeAction 决定:

取值行为
tree(默认)
打开会话树导航器
fork
打开从某条用户消息分叉的选择器
none
什么都不做

排队消息退回编辑器时会合并。 steering 和 follow-up 先拼在一起,然后和你当前正在输入的内容合并,用空行分隔:

typescript
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 上换了键:

动作默认Windows/WSL
排 follow-up
Alt+Enter
Ctrl+Q
队列退回编辑器
Alt+Up
Alt+Q

/hotkeys 显示的是当前会话实际生效的键,所以在那两个平台上你会直接看到替代键。

3.5 斜杠命令

声明数是 24,可达数是 27

BUILTIN_SLASH_COMMANDS 是一个字面量数组,声明了 24 个命令。但你在编辑器里能用的不止这些——命令菜单里还有三个不在这份数组里的:

来源命令说明
内联处理
/debug
直接在交互模式的提交处理器里比较字符串,不走命令表
内置扩展 mcp
/mcp
pi.registerCommand("mcp", …)
内置扩展 llama.cpp
/llama
pi.registerCommand("llama", …)

/debug 的处理方式能说明为什么它不在数组里——它是一条字符串相等判断,插在提交处理器的最前面:

typescript
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 个声明命令

按用途分组:

模型与设置

命令作用参数提示
/settings
打开设置
/model
选模型(打开选择器 UI)
<provider/model>
/thinking
设置思考等级
<level>
/scoped-models
配置 Ctrl+P 循环用的模型
/login
配置供应商认证
<provider>
/logout
移除供应商认证
/llama
管理 llama.cpp 路由上的模型

会话与上下文

命令作用参数提示
/new
开新会话
/resume
切到另一个已保存的会话
/name
设置或显示会话名
/session
显示会话信息和统计
/tree
会话树导航(切分支)
/fork
从更早的用户消息创建分叉
/clone
在当前位置复制当前会话
/compact
手动压缩上下文
/import
从 JSONL 导入并恢复会话
<path>

导出与分享

命令作用
/copy
复制最后一条 assistant 消息
/export
导出为 HTML 或 JSONL
/share
上传会话并返回查看链接
/bug
准备一份私有 bug 报告

运行时与项目

命令作用
/trust
为后续 Pi 进程保存项目信任决定
/reload
重新加载键位、扩展、skill、模板、主题和上下文文件
/hotkeys
显示当前生效的快捷键
/changelog
显示变更日志条目
/quit
退出 Pi

资源提供的命令

命令菜单的准确内容取决于当前会话加载了什么资源:

来源命名规则
扩展
扩展自己决定,可以带参数和参数补全
Prompt template
每个 template 以它自己的名字出现
Skill
/skill:<名称>,受 enableSkillCommands 开关控制(默认开)

扩展命令和内置命令同名会被跳过,不会报冲突错误——源码里只有一条 notice 级提示:

typescript
? `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.`

新增或修改了命令资源之后要 /reload 才生效——这和扩展、skill、模板、主题、键位、上下文文件是同一批需要 reload 的东西。

几个必须澄清的命令名

旧资料里的这些命令不存在,正确的是右列:

不存在的命令正确的做法
/retry
不存在,用 /reload
/editor
不存在,用 Ctrl+G 快捷键
/exit
不存在,用 /quit,或 Ctrl+D(编辑器为空时)
/branch
不存在,用 /fork
/model 是选模型的唯一入口
它也是,但 Ctrl+L 打开同一个选择器
/thinking 是设置思考的唯一入口
它也是,但 Shift+Tab 循环等级

会话相关命令的完整用法在 会话、分支与文件格式。

3.6 键盘快捷键

机制:命名动作 + 映射表

Pi 的快捷键不是硬编码的按键判断,而是命名动作。编辑器暴露 onAction("action.id", handler),键位由一张表映射到动作 id。默认键位来自 packages/tui/src/keybindings.ts,你可以覆盖。

这带来两个直接后果:

第一,/hotkeys 显示的是当前会话实际生效的键,包括你改过的。

第二,扩展注册的动作也有 id,会出现在同一个 /hotkeys 界面里。

配置文件是 <agent-dir>/keybindings.json:

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 应用到当前会话。

应用级默认键位

动作 id默认键作用
app.interrupt
Esc
取消 / 中止
app.clear
Ctrl+C
第一次清空编辑器,第二次退出
app.exit
Ctrl+D
编辑器为空时退出
app.suspend
Ctrl+Z(Windows 上无默认)
挂到后台
app.editor.external
Ctrl+G
打开外部编辑器
app.clipboard.pasteImage
Ctrl+V(Windows/WSL 上 Alt+V)
粘贴文件、图片或文本
app.message.followUp
Alt+Enter(Windows/WSL 上 Ctrl+Q)
排一条 follow-up
app.message.dequeue
Alt+Up(Windows/WSL 上 Alt+Q)
队列退回编辑器
app.tools.expand
Ctrl+O
折叠 / 展开工具输出
app.message.copy
Ctrl+X
复制
app.model.select
Ctrl+L
打开模型选择器
app.model.cycleForward
Ctrl+P
下一个模型
app.model.cycleBackward
Ctrl+Shift+P(Windows/WSL 上 Alt+P)
上一个模型
app.thinking.cycle
Shift+Tab
循环思考等级
app.thinking.save
Ctrl+S
把当前等级存进设置
app.thinking.toggle
Ctrl+T
折叠 / 展开思考块

三个退出路径值得单独说:

按法行为
Ctrl+C 一次
清空编辑器
Ctrl+C 两次(500 ms 内)
退出
Ctrl+D
编辑器为空时退出;不空时是删除字符向前

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。

全屏专用键位

这些只在全屏模式下生效,而且优先级高于编辑器里用同一个键的动作:

动作 id默认键作用
tui.altScreen.pageUp / pageDown
PageUp / PageDown
转录翻页
tui.altScreen.previousPrompt / nextPrompt
Ctrl+Shift+Up / Down
跳到上一/下一条标记消息
tui.altScreen.search
Ctrl+Shift+F
搜索渲染后的转录
tui.altScreen.searchNext / searchPrevious
Enter / Shift+Enter
下一个 / 上一个匹配
tui.altScreen.searchClose
Esc
关闭搜索
tui.altScreen.top / bottom
Ctrl+Home / Ctrl+End
跳到开头 / 末尾并跟随新输出

另外三个默认没有绑定但可以绑的:halfPageUp、halfPageDown、lineUp、lineDown。

转录搜索是全屏模式的一个实打实的优势——搜索的是渲染后的文本,不是原始 JSONL。

编辑器键位

编辑器键位也可以逐个覆盖,常改的几个:

动作 id默认键
tui.input.newLine
Shift+Enter、Ctrl+J
tui.input.submit
Enter
tui.editor.cursorWordLeft
Alt+Left、Ctrl+Left、Alt+B
tui.editor.deleteWordBackward
Ctrl+W、Alt+Backspace
tui.editor.yank / yankPop
Ctrl+Y / Alt+Y
tui.editor.undo
Ctrl+-(Windows 上 Ctrl+Z,WSL 上 Alt+Z)

键语法

一个键写作 modifier+key。修饰符是 ctrl、shift、alt、super,可以组合。合法的主键:

类别取值
字母
a–z
数字
0–9
特殊
escape、esc、enter、return、tab、space、backspace、delete、insert、clear、home、end、pageUp、pageDown、up、down、left、right
功能键
f1–f12
符号
`、-、=、[、]、\、;、'、,、.、/、!、@、#、$、%、^、&、*、(、)、_、+、`

super 绑定要求终端单独上报这个修饰符,通常需要 Kitty 键盘协议。没有这个协议的终端上 super 绑定不会生效。

没有的东西

需要明确的是:pi-tui 里没有内置的 Vim 编辑模式。 你可以自己用扩展接一个 Vim 风格的编辑器组件,但那是扩展提供的功能,不是内置的。

同样,键位方案不是照搬某家编辑器——虽然 Ctrl+Y / Alt+Y 那一对背后确实是一个语义上等价于 kill-ring 的数据结构,但「有 kill-ring」和「整体是某家风格的键位」是两件事。

终端问题排查

改了键没反应,八成是终端的问题而不是 Pi 的问题。Pi 用扩展键协议来区分 Shift+Enter、Alt+Enter 和普通 Enter,而终端代理、复用器和 IDE 内置终端都可能丢掉这些信息。

症状从哪查起
Shift+Enter 变成提交
终端配置;对 tmux 看专门的 tmux 文档
Alt+Enter 排不上 follow-up
WezTerm、Alacritty 或 Windows Terminal
只有在 tmux 里修饰键失效
tmux
输入法候选窗位置不对
WezTerm 或 IntelliJ IDEA 内置终端

几个高频的具体坑:

Ghostty 的 Alt+Backspace。 如果不工作,加:

text
keybind = alt+backspace=text:\x1b\x7f

WezTerm 在 macOS 上把 Option+Enter 默认绑成全屏。 需要显式转发:

lua
{
  key = 'Enter',
  mods = 'ALT',
  action = wezterm.action.SendString('\x1b[13;3u'),
}

一个假修复的陷阱。 某些终端配置里会出现:

text
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 全屏与常规模式

默认是全屏

json
{ "tuiMode": "fullscreen" }

源码里的默认值在 settings-defaults.ts:tuiMode: "fullscreen"。

这条很容易被记反。很多介绍 Pi 的资料说它「保留终端原生滚动」——那描述的是 regular 模式,而且需要你显式设置。默认行为正好相反:Pi 拥有整个视口。

命令行上可以覆盖:

bash
pi --tui-mode regular

两种模式的差别

fullscreen(默认)regular
谁拥有视口
Pi
终端
转录怎么滚
在终端窗口内滚动,PageUp/PageDown/Ctrl+Home/Ctrl+End 有效
终端原生滚动条
滚轮事件
Pi 接收并自己处理
交给终端
鼠标输入
归一化后路由给组件
留给终端(因为滚动缓冲归终端)
转录搜索
有(Ctrl+Shift+F)
没有
编辑器与状态区
固定在底部
随终端滚动

源码里对常规模式的描述只有一句,但信息量很大:

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.

即使写了扩展,也应该给每个交互准备一条键盘路径。 这是官方对扩展作者的建议,在全屏鼠标可用的情况下也一样。

鼠标的分配规则:

事件全屏模式下的归属
未处理的滚轮
滚动最近的 ScrollView
未处理的主键拖拽
保留给转录文本选择
OSC 8 链接
优先于外层点击区域

什么时候切到常规模式

场景建议
需要把终端里的内容往上翻很远
regular
依赖终端自己的滚动条或 scrollback
regular
在 tmux / screen 里需要和别的程序共存
两者都试,取决于你的鼠标习惯
要用转录搜索
保持 fullscreen
频繁拖选转录文本
两者都试;Ghostty 这类终端在全屏下不显示链接悬停预览

全屏的四个设置

设置默认说明
fullscreenExitOutput
"transcript"
退出全屏时打印什么;另一个取值是 "resume-hint"
fullscreenScrollbar
"auto"
转录滚动条行为;可设 always / hidden
fullscreenCopyOnSelect
true
选中即复制
fullscreenWheelScrollLines
"auto"
每个滚轮事件滚几行,取值 1 到 100

最后一项的 "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 面板能显示它是在干活、在等你、完成了还是失败了:

状态什么时候
working
一次 agent 运行或压缩进行中。消息是会话名。
blocked
一个扩展对话框或登录在等你。消息是对话框标题。
done
一次运行结束。消息是会话名。
error
一次运行以未被重试的错误结束。消息是错误第一行。
idle
Pi 启动了,或者你取消了运行。

三条保证:报告里永远不含提示词和模型输出;只有终端确认支持协议之后才发送(tmux 和 screen 不转发);PI_PROGRAM_STATUS=1 可以不询问直接发,=0 关掉。

3.8 主题

三个内置主题,默认用 system

主题是什么
system
从你的终端主题推导出来的配色(默认)
dark
内置的深色主题
light
内置的浅色主题

system 不是一张固定调色板,而是一段查询逻辑:

  • Pi 向终端询问默认前景色、背景色和 16 个 ANSI 颜色。
  • 每个 Pi 颜色从一个 ANSI 颜色取色相,比如错误色取红、链接色取蓝。
  • 每个颜色再单独设定亮度,保证在背景上有足够对比度——正文在背景和每个面板上保持至少 4.5:1 的 WCAG 对比度。
  • 终端在明暗之间切换时,Pi 重新查询并重建主题。
终端报告了什么结果
背景色和 ANSI 颜色
用终端调色板的颜色,按实际背景排布
只有背景色
用 Pi 自己的色相,按实际背景排布
什么都没报告
用 ANSI 颜色索引和终端默认色,让终端自己渲染;次要文字变淡,面板没有背景色

启动时 Pi 最多等 100 毫秒拿终端的回答,超时就先显示启动头部,之后颜色到了还会补上(比如慢速 SSH)。system 是保留名——同名自定义主题会被忽略。

选主题

/settings 里选 Theme。可以统一选一个,也可以按终端明暗分别配。存进设置的是:

json
{ "theme": "dark" }

自动模式的写法是浅色在前、深色在后:

json
{ "theme": "light/dark" }

Pi 判定终端明暗的顺序是:终端报告的背景色和前景色 → 终端的明暗通知 → COLORFGBG 环境变量 → dark。自动模式下终端报告外观变化会触发换主题。

主题名不能包含 /,因为这个字符被保留用来表达「浅色/深色」这一对。

单次运行换主题不改设置:

bash
pi --use-theme light
pi --use-theme light/dark

自定义主题

步骤做什么
1
复制一份内置主题,或按 schema 新建 JSON
2
存成 <agent-dir>/themes/my-theme.json
3
把 name 设为 my-theme
4
改 vars 和 colors
5
在 /settings 里选它

主题文件的结构:

属性必需职责
$schema
否
编辑器里对着 Pi 发布的 schema 校验和补全
name
是
在选择器和设置里标识主题。必须唯一、不能含 /、不能叫 system
appearance
否
"dark" 或 "light";省略时 Pi 从主题颜色推断
vars
否
可复用的颜色变量,变量可以引用变量
colors
是
把颜色分配给终端 UI 角色
export
否
覆盖 HTML 导出里的页面和面板背景

内置主题用 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 事实来源

本文所有数字、键位、默认值和路径都可在仓库里复算:

断言来源
界面分区与各区域职责
docs/usage.md;src/modes/interactive/interactive-mode.ts
启动头部列出 Context/Skills/Prompts/Extensions 与冲突警告
src/modes/interactive/interactive-mode.ts:1772-1935 showLoadedResources
quietStartup 的 true / "header" 两种含义
docs/settings.md Terminal and display 段
启动时下载 fd 与 rg,之后才注册键盘处理
src/modes/interactive/interactive-mode.ts:1085-1096 及其注释
! 开头时编辑器边框变色
src/modes/interactive/interactive-mode.ts:3125-3131
状态栏缓存计算、git 分支检测
src/modes/interactive/components/footer.ts:59-121;src/core/footer-data-provider.ts:50-77
默认补全触发字符 @ 与 #,去抖 20 ms
packages/tui/src/components/editor.ts:253-254、2341-2362
触发/去抖正则的构造;Tab 走 forceExtract
同上 :264-275;packages/tui/src/autocomplete.ts:527-546
目录补全后不补空格;skill 命令的双重匹配
packages/tui/src/autocomplete.ts:447-450、:349-361
/model、/thinking、/login 有参数补全
src/modes/interactive/interactive-mode.ts:734-782
扩展可注册补全器与触发字符
src/core/extensions/types.ts:141-142、:242-243;interactive-mode.ts:825-840
autocompleteMaxVisible 默认 5、范围 3–20
src/core/settings-defaults.ts:49;src/core/settings-manager.ts:1409-1414
粘贴优先级:文件路径 → 临时文件图片 → 纯文本
src/modes/interactive/interactive-mode.ts:3133-3137、:3154-3180
图片四个默认设置与 2000 像素上限
docs/settings.md Terminal and display 段
外部编辑器四级回退链
src/core/settings-manager.ts:961-971
临时文件 prompt.md、非零退出码放弃、不用 spawnSync 的原因
src/modes/interactive/external-editor.ts:14-46 及 :22-24 注释
! 与 !! 的分支、命令提取、并发拒绝
src/modes/interactive/interactive-mode.ts:3349-3365
!! 的 excludeFromContext 在上下文转换时被跳过
src/core/messages.ts:154;src/core/agent-session.ts:3867
bashExecution 转模型的文本格式
src/core/messages.ts:82-98
user_bash 事件可返回结果或 operations
src/modes/interactive/interactive-mode.ts:7005-7050
executeBash 无 timeout 参数;流式期间延后追加
src/core/agent-session.ts:3871-3901、:3909-3930
! 输出用 50 KB 上限、truncateTail、滚动缓冲两倍
src/core/bash-executor.ts:14,56,84,132-143
PI_* 会话变量不注入用户 ! 命令
docs/environment-variables.md
steering 与 follow-up 的投递时机(文档注释)
src/core/agent-session.ts:2231-2262
Enter 走 steer、Alt+Enter 走 followUp、空闲时等价
src/modes/interactive/interactive-mode.ts:3383、:4510-4517
QueueMode 的 all / one-at-a-time 语义
packages/agent/src/types.ts:50-55
两个队列默认 one-at-a-time 且独立配置
src/core/settings-defaults.ts:7-8;packages/agent/src/agent.ts:247-248;src/core/sdk.ts:441-442
Esc 的四级优先级与 500 ms 双击阈值
src/modes/interactive/interactive-mode.ts:3072-3098
doubleEscapeAction 默认 tree
src/core/settings-defaults.ts:45;settings-manager.ts:1356-1357
中止时队列合并退回编辑器
src/modes/interactive/interactive-mode.ts:4756-4772
压缩期间的队列与扩展命令例外
同上 :3367-3375、:4493-4503
Windows/WSL 的 followUp 与 dequeue 替代键
docs/keybindings.md Display and Message Queue 段
BUILTIN_SLASH_COMMANDS 声明 24 个
src/core/slash-commands.ts:19-44
/debug 是内联字符串判断
src/modes/interactive/interactive-mode.ts:3322-3326
/mcp 与 /llama 由内置扩展注册,且扩展可替换
src/extensions/mcp/index.ts:1260;src/extensions/llama/index.ts:183;src/extensions/index.ts:8-13
docs/slash-commands.md 列 25 条,漏 /debug 与 /mcp
docs/slash-commands.md 四个小节逐条数
扩展命令与内置命令同名则跳过
src/modes/interactive/interactive-mode.ts:719
skill 命令受 enableSkillCommands 控制
同上 :809-820;settings-defaults.ts:24
/hotkeys、keybindings.json、键语法、super 限制
docs/keybindings.md 开头 / "Assign keybindings" / "Key syntax" / :43
应用级 / 编辑器 / 全屏键位默认值
同上各表(源自 packages/tui/src/keybindings.ts)
Ctrl+C 双击退出、Ctrl+D 空编辑器退出
src/modes/interactive/interactive-mode.ts:4283-4296
Ctrl+P、Ctrl+S 的上下文相关双重绑定
docs/keybindings.md Sessions 与 Models and Thinking 段
Ctrl+D 在编辑器里是删除字符向前
同上 Text editing 段
app.suspend 在 Windows 上无默认键
同上 :131
kill-ring 的语义与 ctrl+y / alt+y
packages/tui/src/kill-ring.ts:2;packages/tui/src/keybindings.ts:140-141
pi-tui 没有内置的 Vim 编辑模式
grep -ril vim packages/tui/src packages/coding-agent/src(见 FACTS F-084)
常规模式鼠标归终端、交互要有键盘路径、全屏鼠标分配
docs/tui.md "Handle mouse input"
tuiMode 默认 fullscreen 及全屏四个设置
src/core/settings-defaults.ts:62;docs/settings.md Terminal and display 段
iTerm2 全屏滚动慢的原因与修法
docs/terminal-setup.md iTerm2 段
Ghostty Alt+Backspace、裸 \n 映射的陷阱
同上 Ghostty 段
WezTerm Option+Enter、Alacritty Alt+Enter
同上 WezTerm / Alacritty 段
Apple Terminal 兜底只在同机有效
同上 Apple Terminal 段
IntelliJ IDEA 无法区分 Shift+Enter
同上 IntelliJ 段
OSC 7501 程序状态五种取值与三条保证
同上 "Program status" 段;packages/tui/src/program-status.ts
三个内置主题与 system 的推导逻辑、100 ms 等待、system 是保留名
docs/themes.md 开头与 "Use your terminal's colors"
主题名不能含 /、明暗判定顺序
同上 "Choose a theme"
主题文件六个属性
同上 "Understand the theme file"
只有 <agent-dir>/themes/<name>.json 热重载
同上 "Create a custom theme" 末句
主题同时管交互模式与 HTML 导出
docs/themes.md 第 3 行
主题色字符串不能永久存储、token 位置规则
docs/tui.md "Apply themes correctly"

完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。