@bachi/pi-coder 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/config/AGENTS.md +100 -0
- package/config/pi-statusline.json +140 -0
- package/config/settings.json +38 -0
- package/config/web-search.json +5 -0
- package/docs/README.md +14 -0
- package/docs/configuration.md +123 -0
- package/docs/development.md +177 -0
- package/docs/extensions.md +292 -0
- package/docs/handbook.zh.md +432 -0
- package/docs/installation.md +124 -0
- package/docs/themes.md +107 -0
- package/extensions/ask-user-question/answers.test.ts +104 -0
- package/extensions/ask-user-question/answers.ts +72 -0
- package/extensions/ask-user-question/dialog.test.ts +180 -0
- package/extensions/ask-user-question/dialog.ts +102 -0
- package/extensions/ask-user-question/index.ts +253 -0
- package/extensions/ask-user-question/model.test.ts +275 -0
- package/extensions/ask-user-question/model.ts +259 -0
- package/extensions/ask-user-question/schema.ts +49 -0
- package/extensions/ask-user-question/types.ts +86 -0
- package/extensions/ask-user-question/validate.test.ts +183 -0
- package/extensions/ask-user-question/validate.ts +110 -0
- package/extensions/ask-user-question/view.ts +262 -0
- package/extensions/auto-default-model/default-model.test.ts +268 -0
- package/extensions/auto-default-model/index.ts +87 -0
- package/extensions/bash-command-collapse.ts +1476 -0
- package/extensions/below-editor-after-statusline.ts +118 -0
- package/extensions/clear-command.ts +29 -0
- package/extensions/cwd-statusline.ts +39 -0
- package/extensions/exit-command.ts +59 -0
- package/extensions/fenceless-code-block/index.test.ts +208 -0
- package/extensions/fenceless-code-block/index.ts +28 -0
- package/extensions/fenceless-code-block/render.test.ts +177 -0
- package/extensions/fenceless-code-block/render.ts +142 -0
- package/extensions/folder-history.ts +197 -0
- package/extensions/init-command.ts +163 -0
- package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
- package/extensions/prompt-editor/bash-prompt.ts +59 -0
- package/extensions/prompt-editor/render.test.ts +283 -0
- package/extensions/prompt-editor.ts +212 -0
- package/extensions/read-path-collapse.ts +474 -0
- package/extensions/recap/index.test.ts +348 -0
- package/extensions/recap/index.ts +462 -0
- package/extensions/recap/subagents.test.ts +144 -0
- package/extensions/recap/subagents.ts +128 -0
- package/extensions/rewind/README.md +229 -0
- package/extensions/rewind/checkpoints.test.ts +560 -0
- package/extensions/rewind/checkpoints.ts +820 -0
- package/extensions/rewind/flow.test.ts +756 -0
- package/extensions/rewind/flow.ts +362 -0
- package/extensions/rewind/index.ts +400 -0
- package/extensions/rewind/picker.ts +135 -0
- package/extensions/rewind/viewport.test.ts +76 -0
- package/extensions/rewind/viewport.ts +48 -0
- package/extensions/simple-task/gap.test.ts +147 -0
- package/extensions/simple-task/gap.ts +122 -0
- package/extensions/simple-task/index.ts +439 -0
- package/extensions/simple-task/types.ts +53 -0
- package/extensions/simple-task/widget.ts +86 -0
- package/extensions/startup-logo/header-guard.test.ts +274 -0
- package/extensions/startup-logo/header-guard.ts +166 -0
- package/extensions/startup-logo/index.test.ts +305 -0
- package/extensions/startup-logo/index.ts +194 -0
- package/extensions/startup-logo/loaded-sections.test.ts +257 -0
- package/extensions/startup-logo/loaded-sections.ts +267 -0
- package/extensions/startup-logo/logo.test.ts +124 -0
- package/extensions/startup-logo/logo.ts +124 -0
- package/extensions/statusline/footer-guard.test.ts +273 -0
- package/extensions/statusline/footer-guard.ts +171 -0
- package/extensions/statusline/git.test.ts +174 -0
- package/extensions/statusline/git.ts +142 -0
- package/extensions/statusline/index.ts +294 -0
- package/extensions/statusline/line.test.ts +316 -0
- package/extensions/statusline/line.ts +201 -0
- package/extensions/subagent-log-guard/filter.test.ts +85 -0
- package/extensions/subagent-log-guard/filter.ts +32 -0
- package/extensions/subagent-log-guard/index.ts +112 -0
- package/extensions/theme-command.ts +263 -0
- package/extensions/thinking-collapse/window.test.ts +321 -0
- package/extensions/thinking-collapse/window.ts +354 -0
- package/extensions/thinking-collapse.ts +60 -0
- package/extensions/tool-diff/title-row.test.ts +254 -0
- package/extensions/tool-diff/title-row.ts +191 -0
- package/extensions/tool-diff.ts +1276 -0
- package/extensions/working-indicator/bash-spinner.test.ts +135 -0
- package/extensions/working-indicator/bash-spinner.ts +114 -0
- package/extensions/working-indicator/index.test.ts +579 -0
- package/extensions/working-indicator/index.ts +940 -0
- package/extensions/working-indicator/spinner-frames.test.ts +219 -0
- package/extensions/working-indicator/spinner-frames.ts +156 -0
- package/extensions/working-indicator/summary-request.test.ts +195 -0
- package/extensions/working-indicator/summary-request.ts +207 -0
- package/extensions/working-indicator/working-summary.test.ts +499 -0
- package/extensions/working-indicator/working-summary.ts +375 -0
- package/package.json +71 -0
- package/themes/ayu.json +97 -0
- package/themes/catppuccin.json +103 -0
- package/themes/summer-night.json +87 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagents.ts — 探测「此刻有没有子代理在跑」。
|
|
3
|
+
*
|
|
4
|
+
* recap 是**闲置制**触发的:回合结束(`agent_settled`)后静止 30s 就生成摘要。
|
|
5
|
+
* 但异步子代理是**脱离回合**的:`subagent({ async: true })` 一发出去主回合就结束,
|
|
6
|
+
* 子代理还在后台跑几十秒甚至几分钟,结果稍后由被唤醒的回合带回来。这时 30s 到点,
|
|
7
|
+
* 摘要会把「刚把任务发出去、还在等」总结成「这轮干完了」。所以生成摘要前必须问一句
|
|
8
|
+
* 「还有子代理在跑吗」,有就只重查、不生成 —— 等它们都结束了再重新起 30s 定时。
|
|
9
|
+
*
|
|
10
|
+
* 为什么走 pi-subagents 的进程内 RPC,而不是自己数 `subagent` 工具调用:
|
|
11
|
+
* - **数不出来**。异步调用立刻返回(`details.asyncId`),结果由后续回合带回,工具调用
|
|
12
|
+
* 本身看不出"还在跑";状态只活在 pi-subagents 的进程内 state 里(`asyncJobs` /
|
|
13
|
+
* `foregroundControls`),没有 session 条目、没有落盘文件可以读。
|
|
14
|
+
* - **它的文档就给了这个口子**。`pi-subagents/docs/extension-api.md` 的
|
|
15
|
+
* "In-process event-bus RPC" 一节是给「其他 Pi 扩展」用的公开接口:发
|
|
16
|
+
* `subagents:rpc:v1:request`、在 `subagents:rpc:v1:reply:<requestId>` 上收回复;
|
|
17
|
+
* `status` 方法(不带任何 target)的回包里带 `data.fleet`(Fleet status DTO v1),
|
|
18
|
+
* `totalActive` 就是当前会话的活跃子代理数(前台 + 异步 + 排队都算)。
|
|
19
|
+
* - **不 import pi-subagents 的任何文件**。它是独立安装的 npm 包,不是本扩展可解析的
|
|
20
|
+
* 依赖;而且它也可能没装(换台机器就没装)。协议是版本化的字符串频道 + JSON 回包,
|
|
21
|
+
* 按结构化最小接口对接即可。同理本文件**也不 import pi**(总线按下面的接口注入),
|
|
22
|
+
* 所以 `node --test` 能拿假总线直接跑:
|
|
23
|
+
*
|
|
24
|
+
* node --test clients/pi/extensions/recap/subagents.test.ts
|
|
25
|
+
*
|
|
26
|
+
* 失败一律当「没有」:没装 pi-subagents、协议版本变了、回包格式不认识、超时/抛异常 ——
|
|
27
|
+
* 全部返回 false(fail-open)。宁可偶尔在子代理运行中生成一条摘要,也不能因为探测环节
|
|
28
|
+
* 把 recap 整个弄停摆。探测超时 1s:没有子代理时对端回的是内存快照,毫秒级就到。
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** pi 事件总线的子集(真实的 pi `EventBus` 结构兼容)。 */
|
|
32
|
+
export interface SubagentEventBus {
|
|
33
|
+
on(channel: string, handler: (data: unknown) => void): (() => void) | void;
|
|
34
|
+
emit(channel: string, data: unknown): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** pi-subagents 的 RPC 频道(见其 docs/extension-api.md,协议版本 1)。 */
|
|
38
|
+
export const SUBAGENT_RPC_REQUEST = "subagents:rpc:v1:request";
|
|
39
|
+
export const SUBAGENT_RPC_REPLY_PREFIX = "subagents:rpc:v1:reply:";
|
|
40
|
+
export const SUBAGENT_RPC_VERSION = 1;
|
|
41
|
+
|
|
42
|
+
/** 探测超时:没有子代理时对端秒回,超时就当对端不在。 */
|
|
43
|
+
export const SUBAGENT_RPC_TIMEOUT_MS = 1_000;
|
|
44
|
+
|
|
45
|
+
let requestSeq = 0;
|
|
46
|
+
|
|
47
|
+
/** 每个请求一个唯一 id —— 只认自己那条回复频道,不会吃到别人的回复。 */
|
|
48
|
+
export function nextSubagentRequestId(now = Date.now()): string {
|
|
49
|
+
requestSeq += 1;
|
|
50
|
+
return `recap-${requestSeq}-${now.toString(36)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
54
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 从 `status` 的回复里判断「有子代理在跑」。
|
|
59
|
+
*
|
|
60
|
+
* 主判据是 `data.fleet.totalActive`(有界 entries 之外的**全量**活跃数,所以超过
|
|
61
|
+
* entries 窗口的也不会漏)。`asyncSnapshot.runs` 里的 `queued` / `running` 是兜底:
|
|
62
|
+
* fleet 是 v1 才有的能力(`ping.capabilities.fleetStatus` 没有就整个不出现),
|
|
63
|
+
* 快照在旧版本里也可能单独存在。两条都不认识就返回 false(fail-open)。
|
|
64
|
+
*/
|
|
65
|
+
export function isSubagentWorkActive(reply: unknown): boolean {
|
|
66
|
+
if (!isRecord(reply) || reply.success !== true) return false;
|
|
67
|
+
const data = reply.data;
|
|
68
|
+
if (!isRecord(data)) return false;
|
|
69
|
+
|
|
70
|
+
const fleet = data.fleet;
|
|
71
|
+
if (isRecord(fleet) && typeof fleet.totalActive === "number" && fleet.totalActive > 0) return true;
|
|
72
|
+
|
|
73
|
+
const snapshot = data.asyncSnapshot;
|
|
74
|
+
const runs = isRecord(snapshot) ? snapshot.runs : undefined;
|
|
75
|
+
if (!Array.isArray(runs)) return false;
|
|
76
|
+
return runs.some((run) => isRecord(run) && (run.state === "queued" || run.state === "running"));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 问一次 pi-subagents:现在有子代理在跑吗。
|
|
81
|
+
*
|
|
82
|
+
* 正常路径:先订阅回复频道 → 发 request → 收到回复(或超时)→ 退订 → 解析。
|
|
83
|
+
* 任何一步出错都当「没有」,本函数**不抛异常**。
|
|
84
|
+
*/
|
|
85
|
+
export async function hasActiveSubagentWork(
|
|
86
|
+
events: SubagentEventBus,
|
|
87
|
+
options: { timeoutMs?: number; requestId?: string } = {},
|
|
88
|
+
): Promise<boolean> {
|
|
89
|
+
const timeoutMs = options.timeoutMs ?? SUBAGENT_RPC_TIMEOUT_MS;
|
|
90
|
+
const requestId = options.requestId ?? nextSubagentRequestId();
|
|
91
|
+
|
|
92
|
+
const reply = await new Promise<unknown>((resolve) => {
|
|
93
|
+
let settled = false;
|
|
94
|
+
let unsubscribe: (() => void) | undefined;
|
|
95
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
96
|
+
|
|
97
|
+
const finish = (value: unknown) => {
|
|
98
|
+
if (settled) return;
|
|
99
|
+
settled = true;
|
|
100
|
+
if (timer) clearTimeout(timer);
|
|
101
|
+
try {
|
|
102
|
+
unsubscribe?.();
|
|
103
|
+
} catch {
|
|
104
|
+
// 退订失败无所谓:handler 已经 settled,再来回复也直接忽略。
|
|
105
|
+
}
|
|
106
|
+
resolve(value);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const off = events.on(`${SUBAGENT_RPC_REPLY_PREFIX}${requestId}`, finish);
|
|
111
|
+
if (typeof off === "function") unsubscribe = off;
|
|
112
|
+
timer = setTimeout(() => finish(undefined), timeoutMs);
|
|
113
|
+
timer.unref?.();
|
|
114
|
+
events.emit(SUBAGENT_RPC_REQUEST, {
|
|
115
|
+
version: SUBAGENT_RPC_VERSION,
|
|
116
|
+
requestId,
|
|
117
|
+
method: "status",
|
|
118
|
+
// 不带 target = 只要当前会话的舰队概要(对端走内存快照,秒回)。
|
|
119
|
+
params: {},
|
|
120
|
+
source: { extension: "recap" },
|
|
121
|
+
});
|
|
122
|
+
} catch {
|
|
123
|
+
finish(undefined);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return isSubagentWorkActive(reply);
|
|
128
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# rewind — Claude Code style checkpointing and `/rewind` for pi
|
|
2
|
+
|
|
3
|
+
Automatic working-tree checkpoints plus a Claude Code style rewind menu, and
|
|
4
|
+
`Esc` + `Esc` bound to `/rewind` instead of pi's built-in tree navigator.
|
|
5
|
+
|
|
6
|
+
## What it does
|
|
7
|
+
|
|
8
|
+
- **Snapshots the working tree before every user prompt that starts a turn**
|
|
9
|
+
(Claude Code's checkpoint model), plus one at session start.
|
|
10
|
+
- **Catches the files that snapshot cannot see.** A checkpoint only covers
|
|
11
|
+
tracked paths under the project root, so edits to `~/.pi/agent/themes/…`, an
|
|
12
|
+
ignored `.env` or anything else outside the worktree would leave no trace —
|
|
13
|
+
and a checkpoint with no visible change used to hide the code options
|
|
14
|
+
entirely. Right before an `edit`/`write` tool runs, the file's bytes are
|
|
15
|
+
copied into a pre-image blob, so those files come back too. See
|
|
16
|
+
[Files outside the project](#files-outside-the-project).
|
|
17
|
+
- **`/rewind`** (or **`Esc` twice at an empty prompt**) opens the rewind menu:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
────────────────────────────────────────────────────────────────────
|
|
21
|
+
Rewind to a checkpoint
|
|
22
|
+
#48 16:52 probe prompt 048 — …
|
|
23
|
+
→ #41 16:45 probe prompt 041 — …
|
|
24
|
+
#40 16:44 probe prompt 040 — …
|
|
25
|
+
(21/62)
|
|
26
|
+
↑↓ navigate pageUp/pageDown page enter select escape/ctrl+c cancel
|
|
27
|
+
────────────────────────────────────────────────────────────────────
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The list is a **window sized to the terminal** and scrolls with the cursor:
|
|
31
|
+
pi's built-in `ctx.ui.select()` renders one row per option and never scrolls,
|
|
32
|
+
so a session with enough checkpoints produced a dialog taller than the
|
|
33
|
+
viewport — and since pi-tui paints only the last `terminal.rows` lines of the
|
|
34
|
+
document, the title and the `→` cursor row were the first things to disappear.
|
|
35
|
+
The picker (`picker.ts` + `viewport.ts`) keeps the whole dialog inside the
|
|
36
|
+
viewport, centres the window on the selection and shows a `(12/37)` position
|
|
37
|
+
line, so the arrow is always visible. Rows still show the same
|
|
38
|
+
`#N HH:MM prompt` labels, numbered oldest → newest.
|
|
39
|
+
|
|
40
|
+
Keys: `↑`/`↓` (or `j`/`k`) move one row and clamp at both ends,
|
|
41
|
+
`PageUp`/`PageDown` move a window, `Home`/`End` jump to the ends,
|
|
42
|
+
`Enter` selects, `Esc` cancels (they follow the `tui.select.*` keybindings,
|
|
43
|
+
and the paging hint only appears when the list is actually longer than the
|
|
44
|
+
window). Hosts that cannot render extension components (RPC, print) fall back
|
|
45
|
+
to the plain `ctx.ui.select()` list.
|
|
46
|
+
|
|
47
|
+
then offers, for the selected checkpoint:
|
|
48
|
+
|
|
49
|
+
| Action | Effect |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| Restore code and conversation | files + session tree back to that point |
|
|
52
|
+
| Restore conversation (keep current files) | session tree only |
|
|
53
|
+
| Restore code (keep conversation) | files only |
|
|
54
|
+
| Summarize from here (frees context) | pi's native branch summary of everything after that point |
|
|
55
|
+
| Never mind | back out |
|
|
56
|
+
|
|
57
|
+
The two code options appear **only** when the checkpoint has file changes to
|
|
58
|
+
revert (Claude Code parity) — counted over the worktree *and* the pre-images —
|
|
59
|
+
and a confirmation dialog lists exactly which files will change before
|
|
60
|
+
anything is touched.
|
|
61
|
+
- Conversation restore uses pi's native `navigateTree`, which drops the
|
|
62
|
+
selected user message and **puts its text back into the editor** — the same
|
|
63
|
+
behavior Claude Code documents.
|
|
64
|
+
- Footer status: `◆ N checkpoints`.
|
|
65
|
+
- An `↩ Undo last rewind` entry appears after a code restore, backed by an
|
|
66
|
+
automatic pre-restore snapshot, so a rewind can itself be undone.
|
|
67
|
+
|
|
68
|
+
## Storage: a shadow git repository
|
|
69
|
+
|
|
70
|
+
Snapshots live in a **separate git dir outside the project**, not in the
|
|
71
|
+
project's own repository:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
~/.pi/agent/rewind/<sha1(project path)[:16]>/
|
|
75
|
+
├── git/ # shadow GIT_DIR (commits + refs/pi-rewind/<id>)
|
|
76
|
+
├── files/ # pre-image blobs (content addressed) for outside files
|
|
77
|
+
└── checkpoints.json # per-session checkpoint metadata
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Consequences worth knowing:
|
|
81
|
+
|
|
82
|
+
- The project's **git history, index, HEAD, refs and `git status` are never
|
|
83
|
+
touched**. A restore cannot move HEAD or discard staged work.
|
|
84
|
+
- Checkpointing also works when the directory is **not a git repo**.
|
|
85
|
+
- `.gitignore` is respected, and the real repo's `.git/info/exclude` is copied
|
|
86
|
+
into the shadow repo, so ignored paths are never snapshotted and therefore
|
|
87
|
+
never deleted by a restore's `git clean` (which runs without `-x`).
|
|
88
|
+
- Checkpoints are reachable through `refs/pi-rewind/<id>`, so `git gc --auto`
|
|
89
|
+
can never prune a live checkpoint.
|
|
90
|
+
- Per session: 100 checkpoints max (Claude Code's number); oldest are pruned.
|
|
91
|
+
Sessions whose newest checkpoint is older than 30 days are swept at startup
|
|
92
|
+
(Claude Code's retention window), followed by a background `git gc`.
|
|
93
|
+
- A snapshot of an unchanged worktree reuses the previous commit, so read-only
|
|
94
|
+
turns cost nothing but a metadata row.
|
|
95
|
+
|
|
96
|
+
Measured on this repo (105 files): cold snapshot 137 ms, warm 48 ms.
|
|
97
|
+
|
|
98
|
+
## Files outside the project
|
|
99
|
+
|
|
100
|
+
The worktree snapshot cannot see two kinds of files, and both are exactly the
|
|
101
|
+
kind the agent is asked to edit:
|
|
102
|
+
|
|
103
|
+
- anything **outside the project root** — `~/.pi/agent/themes/*.json`,
|
|
104
|
+
`~/.claude/settings.json`, `~/.zshrc`, another checkout on the same machine;
|
|
105
|
+
- anything inside the root that **`.gitignore` hides from `git add -A`** —
|
|
106
|
+
`.env`, `local.settings.json`, a scratch directory.
|
|
107
|
+
|
|
108
|
+
The extension therefore also captures **pre-images**. The `tool_call` hook fires
|
|
109
|
+
*after* `tool_execution_start` and *before* the tool runs — the last moment the
|
|
110
|
+
old bytes still exist — and, for `edit` / `write` targets the worktree snapshot
|
|
111
|
+
does not cover, copies the file into the project's blob store and records it on
|
|
112
|
+
the session's newest checkpoint:
|
|
113
|
+
|
|
114
|
+
- coverage is decided by path prefix plus `git check-ignore`, which applies
|
|
115
|
+
exactly the rules `git add -A` uses (the project's `.gitignore` files and the
|
|
116
|
+
shadow repo's `info/exclude`), so a path is captured only when the snapshot
|
|
117
|
+
really cannot restore it;
|
|
118
|
+
- blobs are content addressed (`files/<sha1>`), so the same content is stored
|
|
119
|
+
once no matter how many checkpoints reference it;
|
|
120
|
+
- the **first touch inside a turn wins**, so the record is the file as it was
|
|
121
|
+
before that turn changed it; a path touched in later turns resolves to the
|
|
122
|
+
earliest checkpoint at or after the one you rewind to;
|
|
123
|
+
- a file that did not exist yet is recorded as *missing*, so a restore deletes
|
|
124
|
+
it (the same thing `git clean -fd` does for new files inside the root);
|
|
125
|
+
- blobs no live checkpoint references are dropped when checkpoints are pruned,
|
|
126
|
+
so the store stays bounded;
|
|
127
|
+
- the `↩ Undo last rewind` safety checkpoint carries the current bytes of every
|
|
128
|
+
file the restore is about to touch, so an undo covers them too.
|
|
129
|
+
|
|
130
|
+
Limits worth knowing: only `edit` and `write` calls are tracked — a `bash`
|
|
131
|
+
command (`sed -i`, `cat >`) that writes **outside** the root is not parseable and
|
|
132
|
+
therefore not captured (inside the root it is still covered by the worktree
|
|
133
|
+
snapshot); files larger than 8 MB are skipped rather than copied.
|
|
134
|
+
|
|
135
|
+
## Esc + Esc
|
|
136
|
+
|
|
137
|
+
pi's own double-escape action is `doubleEscapeAction` in
|
|
138
|
+
`~/.pi/agent/settings.json` (default `"tree"`). This extension replaces it:
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{ "doubleEscapeAction": "none" }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The extension counts two raw `Esc` presses within 500 ms while the agent is
|
|
145
|
+
idle, the editor is empty and no dialog is open, then dispatches `/rewind`
|
|
146
|
+
through pi's command path (so the handler gets a command context with
|
|
147
|
+
`navigateTree`, which a raw-input handler never has).
|
|
148
|
+
|
|
149
|
+
Details that matter:
|
|
150
|
+
|
|
151
|
+
- The **second** press is consumed (`{ consume: true }`). Without that, the
|
|
152
|
+
escape would land on the selector that `/rewind` just focused synchronously
|
|
153
|
+
and instantly cancel it. Consuming also keeps the press from pi's built-in
|
|
154
|
+
double-escape action, so `/rewind` takes over even if `doubleEscapeAction`
|
|
155
|
+
is still `"tree"` or `"fork"`.
|
|
156
|
+
- The **first** press is passed through untouched, so a lone `Esc` still aborts
|
|
157
|
+
streaming, aborts a running `!` bash command and cancels dialogs.
|
|
158
|
+
- Presses are ignored while streaming (`isIdle()`), while the editor has text,
|
|
159
|
+
and while any extension dialog is open (tracked via `ui_prompt_start` /
|
|
160
|
+
`ui_prompt_end`), matching Claude Code's "double `Esc` at an empty prompt".
|
|
161
|
+
|
|
162
|
+
To get pi's tree navigator back on `Esc` + `Esc`, remove this extension and
|
|
163
|
+
set `doubleEscapeAction` back to `"tree"`; `/tree` always works regardless.
|
|
164
|
+
|
|
165
|
+
## Differences from Claude Code (all deliberate)
|
|
166
|
+
|
|
167
|
+
| Claude Code | Here |
|
|
168
|
+
| --- | --- |
|
|
169
|
+
| Tracks only its file-editing tools; **bash-made changes are not tracked** | Whole-worktree snapshots, so bash edits, subagent edits and any other edit inside the root are captured too; edits outside the root (or in ignored paths) are captured as pre-images by the same `edit`/`write` hook |
|
|
170
|
+
| Snapshots the edited files themselves | Worktree snapshot for the project, pre-image blobs only for what it cannot see: no duplicate copies of tracked files |
|
|
171
|
+
| No redo of a rewind | `↩ Undo last rewind`, backed by a pre-restore snapshot |
|
|
172
|
+
| No confirmation before a code restore | One confirmation listing the changed files |
|
|
173
|
+
| `Summarize up to here` | Not offered — pi can summarize an abandoned branch or compact the whole context, but not "everything before an entry while keeping later messages" |
|
|
174
|
+
| Skips symlinked / hard-linked paths on restore | git materializes symlinks as symlinks, so no skip list is needed |
|
|
175
|
+
| Checkpoints survive resume | Same — metadata is keyed by session id |
|
|
176
|
+
|
|
177
|
+
Known gap: a follow-up message that pi delivers inside a running turn still
|
|
178
|
+
gets its own checkpoint (it is a user message), unlike Claude Code's
|
|
179
|
+
"messages sent mid-turn are not checkpointed".
|
|
180
|
+
|
|
181
|
+
## Files
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
rewind/
|
|
185
|
+
├── index.ts # entry: session lifecycle, checkpoint + pre-image hooks, /rewind, Esc+Esc
|
|
186
|
+
├── checkpoints.ts # pure shadow-git core (no pi imports)
|
|
187
|
+
├── flow.ts # the /rewind menu flow
|
|
188
|
+
├── picker.ts # the scrollable checkpoint picker (pi-tui component)
|
|
189
|
+
├── viewport.ts # pure layout budget: how many list rows fit the terminal
|
|
190
|
+
├── checkpoints.test.ts # 23 tests for the core
|
|
191
|
+
├── flow.test.ts # 26 tests for the flow (scripted mock dialogs)
|
|
192
|
+
└── viewport.test.ts # 8 tests for the picker layout budget
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
pi auto-loads only `index.ts` from a subdirectory (`extensions/*/index.ts`);
|
|
196
|
+
the other files are plain modules and tests.
|
|
197
|
+
|
|
198
|
+
## Tests
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
node --test ~/.pi/agent/extensions/rewind/checkpoints.test.ts
|
|
202
|
+
node --test ~/.pi/agent/extensions/rewind/flow.test.ts
|
|
203
|
+
node --test ~/.pi/agent/extensions/rewind/viewport.test.ts
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
57 tests, no npm dependencies. The core tests create throwaway projects and
|
|
207
|
+
agent dirs under the OS temp dir and assert the real repo's index/HEAD/status
|
|
208
|
+
stay untouched; the flow tests drive the menu with a scripted mock command
|
|
209
|
+
context, since TUI dialogs cannot be exercised headlessly (the picker is
|
|
210
|
+
reached through the injectable `deps.pick`, with the `ctx.ui.select()` fallback
|
|
211
|
+
covered separately); the viewport tests assert that the dialog — list, chrome
|
|
212
|
+
and the rows pi paints below the editor — fits the terminal at every height.
|
|
213
|
+
|
|
214
|
+
The picker itself was verified live in a tmux-driven TUI with 62 checkpoints:
|
|
215
|
+
at 24 rows it shows 14 rows plus `(1/62)`, at 14 rows it shrinks to 4, in both
|
|
216
|
+
cases with the title, the borders and the arrow on screen; `PageDown` jumps a
|
|
217
|
+
window (`(1/62)` → `(29/63)`), `End` lands on `Never mind` with the arrow still
|
|
218
|
+
visible, and picking `#4` of five checkpoints holding distinct file contents
|
|
219
|
+
restored exactly that version (`v5` → `v4`), with `↩ Undo last rewind` putting
|
|
220
|
+
`v5` back. `Esc` cancelling the picker and `Esc` + `Esc` opening it (a single
|
|
221
|
+
`Esc` not) were re-checked there too.
|
|
222
|
+
|
|
223
|
+
Earlier live checks (unchanged by the picker): `/rewind` restore, a lone `Esc`
|
|
224
|
+
still aborting streaming, and `Esc` + `Esc` correctly doing nothing while the
|
|
225
|
+
editor holds text. The outside-project path was verified the same way: a
|
|
226
|
+
`write` to a file outside the root was captured as a pre-image, the menu then
|
|
227
|
+
read `1 file: ~1` for a project whose worktree was untouched and offered the
|
|
228
|
+
code options, the restore put the old bytes back, and `↩ Undo last rewind` put
|
|
229
|
+
the new ones back again.
|