@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,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for git.ts — the (+added,-deleted) working-tree counter.
|
|
3
|
+
*
|
|
4
|
+
* Run with: node --test clients/pi/extensions/statusline/git.test.ts
|
|
5
|
+
*
|
|
6
|
+
* git.ts never imports pi: it takes an injected `exec`, so these tests drive the real
|
|
7
|
+
* `git` binary against throwaway repos in a temp dir. That covers the exact argv we send
|
|
8
|
+
* (`--numstat HEAD`, `ls-files --others --full-name`, `rev-parse --show-toplevel`) and the
|
|
9
|
+
* root-relative path joining, which a stub could not prove.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { after, describe, it } from "node:test";
|
|
18
|
+
|
|
19
|
+
import { countTextLines, parseNumstat, readGitDiffStat, sumDiffStats, type GitExecutor } from "./git.ts";
|
|
20
|
+
|
|
21
|
+
const exec: GitExecutor = async (command, args, options) => {
|
|
22
|
+
const result = spawnSync(command, args, {
|
|
23
|
+
cwd: options.cwd,
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
timeout: options.timeout,
|
|
26
|
+
});
|
|
27
|
+
if (result.error) throw result.error;
|
|
28
|
+
return { stdout: result.stdout ?? "", code: result.status ?? 1, killed: result.signal !== null };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function git(cwd: string, ...args: string[]) {
|
|
32
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
33
|
+
assert.equal(result.status, 0, `git ${args.join(" ")}: ${result.stderr}`);
|
|
34
|
+
return result.stdout;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const dirs: string[] = [];
|
|
38
|
+
|
|
39
|
+
/** New repo with one commit containing `lines.txt` (3 lines). */
|
|
40
|
+
function makeRepo(body: string[] = ["a", "b", "c"]): string {
|
|
41
|
+
const dir = mkdtempSync(join(tmpdir(), "statusline-git-"));
|
|
42
|
+
dirs.push(dir);
|
|
43
|
+
git(dir, "init", "-q");
|
|
44
|
+
git(dir, "config", "user.email", "t@example.com");
|
|
45
|
+
git(dir, "config", "user.name", "t");
|
|
46
|
+
writeFileSync(join(dir, "lines.txt"), `${body.join("\n")}\n`);
|
|
47
|
+
git(dir, "add", "lines.txt");
|
|
48
|
+
git(dir, "commit", "-qm", "init");
|
|
49
|
+
return dir;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const noopSignal = (): AbortSignal => new AbortController().signal;
|
|
53
|
+
|
|
54
|
+
describe("parseNumstat", () => {
|
|
55
|
+
it("sums added and deleted columns", () => {
|
|
56
|
+
assert.deepEqual(parseNumstat("12\t3\tgateway/config.yaml\n1\t0\tadapter/index.js\n"), {
|
|
57
|
+
added: 13,
|
|
58
|
+
deleted: 3,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("counts binary files as zero", () => {
|
|
63
|
+
assert.deepEqual(parseNumstat("-\t-\tassets/blob.png\n4\t1\tline.ts"), { added: 4, deleted: 1 });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("is zero for an empty diff", () => {
|
|
67
|
+
assert.deepEqual(parseNumstat(""), { added: 0, deleted: 0 });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("countTextLines", () => {
|
|
72
|
+
it("counts a trailing open line and ignores the final newline", () => {
|
|
73
|
+
assert.equal(countTextLines(""), 0);
|
|
74
|
+
assert.equal(countTextLines("a"), 1);
|
|
75
|
+
assert.equal(countTextLines("a\n"), 1);
|
|
76
|
+
assert.equal(countTextLines("a\nb"), 2);
|
|
77
|
+
assert.equal(countTextLines("a\nb\n"), 2);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("sumDiffStats", () => {
|
|
82
|
+
it("merges the empty-repo fallback halves", () => {
|
|
83
|
+
assert.deepEqual(sumDiffStats({ added: 2, deleted: 1 }, { added: 5, deleted: 0 }), {
|
|
84
|
+
added: 7,
|
|
85
|
+
deleted: 1,
|
|
86
|
+
});
|
|
87
|
+
assert.deepEqual(sumDiffStats({ added: 2, deleted: 1 }, undefined), { added: 2, deleted: 1 });
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("readGitDiffStat", () => {
|
|
92
|
+
it("counts tracked edits plus untracked files", async () => {
|
|
93
|
+
const repo = makeRepo();
|
|
94
|
+
writeFileSync(join(repo, "lines.txt"), "a\nb\nX\n"); // "c" gone, "X" new: +1 / -1
|
|
95
|
+
writeFileSync(join(repo, "note.txt"), "u1\nu2\nu3\n"); // untracked +3
|
|
96
|
+
assert.deepEqual(await readGitDiffStat(exec, repo, noopSignal()), { added: 4, deleted: 1 });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("counts repo-wide, including untracked files below the cwd", async () => {
|
|
100
|
+
const repo = makeRepo();
|
|
101
|
+
mkdirSync(join(repo, "deep", "deeper"), { recursive: true });
|
|
102
|
+
writeFileSync(join(repo, "lines.txt"), "a\nb\nX\n"); // +1 / -1
|
|
103
|
+
writeFileSync(join(repo, "deep", "deeper", "new.txt"), "s1\ns2\n"); // untracked below cwd
|
|
104
|
+
const nested = join(repo, "deep");
|
|
105
|
+
assert.deepEqual(await readGitDiffStat(exec, nested, noopSignal()), { added: 3, deleted: 1 });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("reports zero for a clean tree", async () => {
|
|
109
|
+
const repo = makeRepo();
|
|
110
|
+
assert.deepEqual(await readGitDiffStat(exec, repo, noopSignal()), { added: 0, deleted: 0 });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("skips untracked binaries and oversized files", async () => {
|
|
114
|
+
const repo = makeRepo();
|
|
115
|
+
writeFileSync(join(repo, "blob.bin"), Buffer.from([0x89, 0, 1, 2, 0, 3]));
|
|
116
|
+
writeFileSync(join(repo, "big.txt"), Buffer.alloc(600 * 1024, 0x41)); // > 512KiB
|
|
117
|
+
assert.deepEqual(await readGitDiffStat(exec, repo, noopSignal()), { added: 0, deleted: 0 });
|
|
118
|
+
writeFileSync(join(repo, "small.txt"), "one\ntwo\n");
|
|
119
|
+
assert.deepEqual(await readGitDiffStat(exec, repo, noopSignal()), { added: 2, deleted: 0 });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("counts staged files in a repo without any commit", async () => {
|
|
123
|
+
const repo = mkdtempSync(join(tmpdir(), "statusline-empty-"));
|
|
124
|
+
dirs.push(repo);
|
|
125
|
+
git(repo, "init", "-q");
|
|
126
|
+
git(repo, "config", "user.email", "t@example.com");
|
|
127
|
+
git(repo, "config", "user.name", "t");
|
|
128
|
+
writeFileSync(join(repo, "staged.txt"), "s1\ns2\n");
|
|
129
|
+
writeFileSync(join(repo, "loose.txt"), "l1\n");
|
|
130
|
+
git(repo, "add", "staged.txt");
|
|
131
|
+
assert.deepEqual(await readGitDiffStat(exec, repo, noopSignal()), { added: 3, deleted: 0 });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns undefined outside a repository", async () => {
|
|
135
|
+
const plain = mkdtempSync(join(tmpdir(), "statusline-nogit-"));
|
|
136
|
+
dirs.push(plain);
|
|
137
|
+
writeFileSync(join(plain, "file.txt"), "x\n");
|
|
138
|
+
assert.equal(await readGitDiffStat(exec, plain, noopSignal()), undefined);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("returns undefined once the signal is aborted", async () => {
|
|
142
|
+
const repo = makeRepo();
|
|
143
|
+
const controller = new AbortController();
|
|
144
|
+
controller.abort();
|
|
145
|
+
assert.equal(await readGitDiffStat(exec, repo, controller.signal), undefined);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns undefined when git itself cannot run", async () => {
|
|
149
|
+
const repo = makeRepo();
|
|
150
|
+
const broken: GitExecutor = async () => {
|
|
151
|
+
throw new Error("ENOENT git");
|
|
152
|
+
};
|
|
153
|
+
assert.equal(await readGitDiffStat(broken, repo, noopSignal()), undefined);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("never asks git for optional locks", async () => {
|
|
157
|
+
const repo = makeRepo();
|
|
158
|
+
const argv: string[][] = [];
|
|
159
|
+
const spy: GitExecutor = async (command, args, options) => {
|
|
160
|
+
argv.push([command, ...args]);
|
|
161
|
+
return exec(command, args, options);
|
|
162
|
+
};
|
|
163
|
+
await readGitDiffStat(spy, repo, noopSignal());
|
|
164
|
+
assert.ok(argv.length >= 3, `expected at least 3 git calls, got ${argv.length}`);
|
|
165
|
+
for (const call of argv) {
|
|
166
|
+
assert.equal(call[0], "git");
|
|
167
|
+
assert.equal(call[1], "--no-optional-locks", call.join(" "));
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
after(() => {
|
|
173
|
+
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
|
|
174
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git.ts — 读工作区相对 HEAD 的 (+新增行,-删除行)
|
|
3
|
+
*
|
|
4
|
+
* 同样**不 import pi**:git 通过注入的 `exec`(`pi.exec` 的瘦接口)调用,纯 Node fs 读文件,
|
|
5
|
+
* 所以能被 `node --test` 直接驱动(真实的临时 git 仓库,见 git.test.ts)。
|
|
6
|
+
*
|
|
7
|
+
* 口径:
|
|
8
|
+
* - 已跟踪文件:`git diff --numstat HEAD` = staged + unstaged 相对 HEAD。空仓库(无 HEAD)
|
|
9
|
+
* 退化成 `git diff --numstat` + `git diff --cached --numstat`(工作区 vs index,再加 index vs 空树)。
|
|
10
|
+
* - 未跟踪文件:`git ls-files --others --exclude-standard --full-name`(仓库根相对路径,
|
|
11
|
+
* 跟着 `rev-parse --show-toplevel` 拼绝对路径),逐个数行。
|
|
12
|
+
* - 上限:最多 200 个未跟踪文件、单文件 512KiB,含 NUL 的(二进制)跳过 —— 状态栏要的是
|
|
13
|
+
* 「刚刚改了多少」,不是精确审计;超限就少算,绝不报错。
|
|
14
|
+
*
|
|
15
|
+
* 全程带 `--no-optional-locks`,不会去抢用户自己的 index.lock。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { open } from "node:fs/promises";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import type { DiffStat } from "./line.ts";
|
|
21
|
+
|
|
22
|
+
export interface ExecResultLike {
|
|
23
|
+
stdout: string;
|
|
24
|
+
code: number;
|
|
25
|
+
killed: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `pi.exec` 的结构化子集。 */
|
|
29
|
+
export type GitExecutor = (
|
|
30
|
+
command: string,
|
|
31
|
+
args: string[],
|
|
32
|
+
options: { cwd: string; timeout: number; signal?: AbortSignal },
|
|
33
|
+
) => Promise<ExecResultLike>;
|
|
34
|
+
|
|
35
|
+
const GIT_TIMEOUT_MS = 3_000;
|
|
36
|
+
const MAX_UNTRACKED_FILES = 200;
|
|
37
|
+
const MAX_UNTRACKED_FILE_BYTES = 512 * 1024;
|
|
38
|
+
|
|
39
|
+
/** 非 git 目录 / git 调用失败 / 已 abort → undefined。 */
|
|
40
|
+
export async function readGitDiffStat(
|
|
41
|
+
exec: GitExecutor,
|
|
42
|
+
cwd: string,
|
|
43
|
+
signal: AbortSignal,
|
|
44
|
+
): Promise<DiffStat | undefined> {
|
|
45
|
+
const run = (args: string[]) =>
|
|
46
|
+
exec("git", ["--no-optional-locks", ...args], { cwd, timeout: GIT_TIMEOUT_MS, signal }).catch(
|
|
47
|
+
() => undefined,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const [tracked, untracked, toplevel] = await Promise.all([
|
|
51
|
+
run(["diff", "--numstat", "HEAD"]),
|
|
52
|
+
run(["ls-files", "--others", "--exclude-standard", "--full-name"]),
|
|
53
|
+
run(["rev-parse", "--show-toplevel"]),
|
|
54
|
+
]);
|
|
55
|
+
if (signal.aborted || !tracked || tracked.killed) return undefined;
|
|
56
|
+
|
|
57
|
+
let stat: DiffStat | undefined = tracked.code === 0 ? parseNumstat(tracked.stdout) : undefined;
|
|
58
|
+
if (!stat) {
|
|
59
|
+
// 空仓库(无 HEAD):工作区 vs index 加上 index vs 空树,未提交的内容也算进来。
|
|
60
|
+
const [worktree, staged] = await Promise.all([run(["diff", "--numstat"]), run(["diff", "--cached", "--numstat"])]);
|
|
61
|
+
if (!worktree || worktree.code !== 0 || worktree.killed) return undefined;
|
|
62
|
+
stat = sumDiffStats(
|
|
63
|
+
parseNumstat(worktree.stdout),
|
|
64
|
+
staged && staged.code === 0 && !staged.killed ? parseNumstat(staged.stdout) : undefined,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const root = toplevel && toplevel.code === 0 ? firstLine(toplevel.stdout) : undefined;
|
|
69
|
+
if (!root || !untracked || untracked.code !== 0 || untracked.killed) return stat;
|
|
70
|
+
|
|
71
|
+
const files = splitLines(untracked.stdout).slice(0, MAX_UNTRACKED_FILES);
|
|
72
|
+
if (files.length === 0) return stat;
|
|
73
|
+
return { added: stat.added + (await countUntrackedLines(files, root, signal)), deleted: stat.deleted };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `git diff --numstat` 文本 → 累加的 added/deleted;二进制列的 "-" 记 0。 */
|
|
77
|
+
export function parseNumstat(output: string): DiffStat {
|
|
78
|
+
let added = 0;
|
|
79
|
+
let deleted = 0;
|
|
80
|
+
for (const line of splitLines(output)) {
|
|
81
|
+
const [adds, dels] = line.split("\t");
|
|
82
|
+
added += toCount(adds);
|
|
83
|
+
deleted += toCount(dels);
|
|
84
|
+
}
|
|
85
|
+
return { added, deleted };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 空仓库分支里把两个 numstat 合起来(工作区 vs index + index vs 空树)。 */
|
|
89
|
+
export function sumDiffStats(base: DiffStat, other: DiffStat | undefined): DiffStat {
|
|
90
|
+
if (!other) return base;
|
|
91
|
+
return { added: base.added + other.added, deleted: base.deleted + other.deleted };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 文本行数:按换行计,末行没有换行也算一行;空文本 0。 */
|
|
95
|
+
export function countTextLines(text: string): number {
|
|
96
|
+
if (!text) return 0;
|
|
97
|
+
let lines = 0;
|
|
98
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
99
|
+
if (text.charCodeAt(i) === 10) lines += 1;
|
|
100
|
+
}
|
|
101
|
+
return text.endsWith("\n") ? lines : lines + 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function countUntrackedLines(
|
|
105
|
+
files: string[],
|
|
106
|
+
root: string,
|
|
107
|
+
signal: AbortSignal,
|
|
108
|
+
): Promise<number> {
|
|
109
|
+
let total = 0;
|
|
110
|
+
for (const relative of files) {
|
|
111
|
+
if (signal.aborted) break;
|
|
112
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
|
113
|
+
try {
|
|
114
|
+
handle = await open(join(root, relative), "r");
|
|
115
|
+
const { size } = await handle.stat();
|
|
116
|
+
if (!size || size > MAX_UNTRACKED_FILE_BYTES) continue;
|
|
117
|
+
const buffer = Buffer.allocUnsafe(size);
|
|
118
|
+
const { bytesRead } = await handle.read(buffer, 0, size, 0);
|
|
119
|
+
const content = buffer.subarray(0, bytesRead);
|
|
120
|
+
if (content.includes(0)) continue; // 二进制
|
|
121
|
+
total += countTextLines(content.toString("utf8"));
|
|
122
|
+
} catch {
|
|
123
|
+
// 权限 / 竞态删除 / abort:算 0 就行,状态栏不为此报错。
|
|
124
|
+
} finally {
|
|
125
|
+
await handle?.close().catch(() => undefined);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return total;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function toCount(value: string | undefined): number {
|
|
132
|
+
const parsed = Number(value);
|
|
133
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function splitLines(output: string): string[] {
|
|
137
|
+
return output.split(/\r?\n/).filter(Boolean);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function firstLine(output: string): string | undefined {
|
|
141
|
+
return output.split(/\r?\n/)[0]?.trim();
|
|
142
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* statusline — 极简 statusline(替代 npm:@narumitw/pi-statusline)
|
|
3
|
+
*
|
|
4
|
+
* 只有一主行 + 一状态行(沿用原 pi-statusline 的两行结构),无配置文件、无 powerline 块、
|
|
5
|
+
* 无 palette:颜色全走 `theme.fg(<语义色>)`,因此自动跟随当前主题(`theme` 是 pi 内部的
|
|
6
|
+
* live Proxy 单例,`/theme` 换肤后下一次渲染即生效)。
|
|
7
|
+
*
|
|
8
|
+
* 主行形态:
|
|
9
|
+
*
|
|
10
|
+
* ⚡️ qwen3.8-flash/xhigh | Ctx 0.0% | main | (+0,-0)
|
|
11
|
+
* ⚡️ qwen3.8-flash/xhigh | Ctx 0.0% | main | (+0,-0) | thinking ← 生成中
|
|
12
|
+
* ⚡️ qwen3.8-flash/xhigh | Ctx 0.0% | no git | (no git) ← 不在 git 仓库
|
|
13
|
+
*
|
|
14
|
+
* 行首的 `⚡️` 是模型段图标(`line.ts` 的 `MODEL_ICON`,取代早期的 `Model:` 文字标签,
|
|
15
|
+
* 省 4 列;emoji 自带颜色不上色)。模型段本体是 `ctx.model.id` + `ctx.thinkingLevel`
|
|
16
|
+
* (live getter)拼成的 `<id>/<推理强度>`(level 读不到时只报 id)。刻意不加 `[1m]`
|
|
17
|
+
* 那种上下文窗口后缀 —— 那是 Claude Code 的写法,pi 用不着。
|
|
18
|
+
*
|
|
19
|
+
* 换行策略:宁可截断也不折行 —— 每行都过 `truncateToWidth(..., width, "…")`;行首恒留一个
|
|
20
|
+
* 空格,不顶格。
|
|
21
|
+
*
|
|
22
|
+
* 拆四个模块:`line.ts`(纯格式化)+ `git.ts`(读 diff,注入 exec)+ `footer-guard.ts`
|
|
23
|
+
* (换会话期间冻结 footer,防默认 footer 闪一帧)+ 本文件(pi 事件接线)。
|
|
24
|
+
* 前三个都不 import pi / pi-tui,所以 `node --test clients/pi/extensions/statusline/*.test.ts`
|
|
25
|
+
* 能直接跑。
|
|
26
|
+
*
|
|
27
|
+
* git 读取一律在后台做(渲染路径上只有一次 Map 查找):`turn_end` / `agent_end` /
|
|
28
|
+
* `tool_execution_end` 防抖 400ms 刷一次,分支变化立即刷,另有 30s 兜底轮询。
|
|
29
|
+
*
|
|
30
|
+
* 换会话(`/new`、`/clear`、`/resume`、fork、rewind)时 pi 会先把 footer 还原成内置那只、
|
|
31
|
+
* 清掉所有 `setStatus`,新会话 `session_start` 才让我们重装 —— 中间那几十毫秒会真的出帧,
|
|
32
|
+
* 于是底部「闪」一下。本扩展用 `footer-guard.ts` 在渲染路径上把这段窗口冻住(重放上一帧的
|
|
33
|
+
* 行),原因与约束见那个文件的头注释;关掉它只需把 `PI_STATUSLINE_FREEZE` 设为 `off`。
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
37
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
38
|
+
import { readGitDiffStat } from "./git.ts";
|
|
39
|
+
import { findRenderContainer, freezeFooterContainer, releaseFooterGuard } from "./footer-guard.ts";
|
|
40
|
+
import {
|
|
41
|
+
STATUSLINE_KEY,
|
|
42
|
+
ELLIPSIS,
|
|
43
|
+
type StatuslineState,
|
|
44
|
+
composeFooterLines,
|
|
45
|
+
} from "./line.ts";
|
|
46
|
+
|
|
47
|
+
const GIT_POLL_INTERVAL_MS = 30_000;
|
|
48
|
+
const GIT_DEBOUNCE_MS = 400;
|
|
49
|
+
const FROZEN_DISABLED = (process.env.PI_STATUSLINE_FREEZE ?? "").trim().toLowerCase() === "off";
|
|
50
|
+
|
|
51
|
+
/** footer 容器要的是这个组件对象;只要 render/dispose 形状,便于在守卫里按身份比对。 */
|
|
52
|
+
interface FooterComponent {
|
|
53
|
+
render(width: number): string[];
|
|
54
|
+
dispose(): void;
|
|
55
|
+
invalidate(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default function statusline(pi: ExtensionAPI) {
|
|
59
|
+
let generation = 0;
|
|
60
|
+
let activeTarget: { cwd: string; generation: number } | undefined;
|
|
61
|
+
let requestRender: (() => void) | undefined;
|
|
62
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
63
|
+
let abortController: AbortController | undefined;
|
|
64
|
+
let refreshInFlight = false;
|
|
65
|
+
let refreshQueued = false;
|
|
66
|
+
|
|
67
|
+
const state: StatuslineState = {
|
|
68
|
+
streaming: false,
|
|
69
|
+
activeTools: new Map<string, number>(),
|
|
70
|
+
diffStat: undefined,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// 冻结窗口要用的四样:TUI 根(找容器)、当前 footer 组件、它的容器、上一帧渲染出的行。
|
|
74
|
+
let tuiHandle: unknown;
|
|
75
|
+
let footerComponent: FooterComponent | undefined;
|
|
76
|
+
let footerContainer: unknown;
|
|
77
|
+
let lastFooterLines: string[] | undefined;
|
|
78
|
+
let guardRelease: (() => void) | undefined;
|
|
79
|
+
|
|
80
|
+
const render = () => requestRender?.();
|
|
81
|
+
|
|
82
|
+
const refreshDiffStat = async (target: { cwd: string; generation: number }) => {
|
|
83
|
+
if (target !== activeTarget) return;
|
|
84
|
+
if (refreshInFlight) {
|
|
85
|
+
refreshQueued = true;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
refreshInFlight = true;
|
|
89
|
+
abortController?.abort(new DOMException("Statusline Git refresh replaced", "AbortError"));
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
abortController = controller;
|
|
92
|
+
try {
|
|
93
|
+
const next = await readGitDiffStat(
|
|
94
|
+
(command, args, options) => pi.exec(command, args, options),
|
|
95
|
+
target.cwd,
|
|
96
|
+
controller.signal,
|
|
97
|
+
);
|
|
98
|
+
if (target !== activeTarget || controller.signal.aborted) return;
|
|
99
|
+
state.diffStat = next;
|
|
100
|
+
render();
|
|
101
|
+
} catch {
|
|
102
|
+
if (target === activeTarget) {
|
|
103
|
+
state.diffStat = undefined;
|
|
104
|
+
render();
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
if (abortController === controller) abortController = undefined;
|
|
108
|
+
refreshInFlight = false;
|
|
109
|
+
if (refreshQueued && target === activeTarget) {
|
|
110
|
+
refreshQueued = false;
|
|
111
|
+
void refreshDiffStat(target);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const scheduleDiffRefresh = (delayMs: number) => {
|
|
117
|
+
const target = activeTarget;
|
|
118
|
+
if (!target) return;
|
|
119
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
120
|
+
if (delayMs <= 0) {
|
|
121
|
+
debounceTimer = undefined;
|
|
122
|
+
void refreshDiffStat(target);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
debounceTimer = setTimeout(() => {
|
|
126
|
+
debounceTimer = undefined;
|
|
127
|
+
void refreshDiffStat(target);
|
|
128
|
+
}, delayMs);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const cancelDiffRefresh = () => {
|
|
132
|
+
if (debounceTimer) {
|
|
133
|
+
clearTimeout(debounceTimer);
|
|
134
|
+
debounceTimer = undefined;
|
|
135
|
+
}
|
|
136
|
+
abortController?.abort(new DOMException("Statusline Git refresh cancelled", "AbortError"));
|
|
137
|
+
refreshQueued = false;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const resetState = () => {
|
|
141
|
+
state.streaming = false;
|
|
142
|
+
state.activeTools.clear();
|
|
143
|
+
state.diffStat = undefined;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const installFooter = (ctx: ExtensionContext) => {
|
|
147
|
+
// 先交还上一段换会话窗口的冻结再装新 footer:两步在同一个同步块里,中间出不了帧。
|
|
148
|
+
guardRelease?.();
|
|
149
|
+
guardRelease = undefined;
|
|
150
|
+
cancelDiffRefresh();
|
|
151
|
+
generation += 1;
|
|
152
|
+
resetState();
|
|
153
|
+
// print / rpc / json 模式没有 footer,别白跑 git。
|
|
154
|
+
activeTarget = ctx.mode === "tui" ? { cwd: ctx.cwd, generation } : undefined;
|
|
155
|
+
ctx.ui.setStatus(STATUSLINE_KEY, undefined);
|
|
156
|
+
if (!activeTarget) {
|
|
157
|
+
requestRender = undefined;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const target = activeTarget;
|
|
161
|
+
|
|
162
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
163
|
+
requestRender = () => tui.requestRender();
|
|
164
|
+
tuiHandle = tui;
|
|
165
|
+
const unsubscribeBranch = footerData.onBranchChange(() => {
|
|
166
|
+
state.diffStat = undefined;
|
|
167
|
+
tui.requestRender();
|
|
168
|
+
scheduleDiffRefresh(0);
|
|
169
|
+
});
|
|
170
|
+
const clock = setInterval(() => scheduleDiffRefresh(0), GIT_POLL_INTERVAL_MS);
|
|
171
|
+
|
|
172
|
+
const component: FooterComponent = {
|
|
173
|
+
dispose() {
|
|
174
|
+
unsubscribeBranch();
|
|
175
|
+
clearInterval(clock);
|
|
176
|
+
// 只有仍属当前会话时才清,避免旧 footer 的 dispose 抹掉新会话的状态。
|
|
177
|
+
if (target === activeTarget) {
|
|
178
|
+
activeTarget = undefined;
|
|
179
|
+
state.diffStat = undefined;
|
|
180
|
+
requestRender = undefined;
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
invalidate() {},
|
|
184
|
+
render(width: number): string[] {
|
|
185
|
+
// 截断 / 缩进 / 不折行的约定在 line.ts 里,统一由单测覆盖。
|
|
186
|
+
const lines = composeFooterLines(theme, ctx, footerData, state, width, truncateToWidth);
|
|
187
|
+
// 换会话时按身份比对我们是否还在容器里,不在就拿这份行重放(见 footer-guard.ts)。
|
|
188
|
+
lastFooterLines = lines;
|
|
189
|
+
return lines;
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
footerComponent = component;
|
|
193
|
+
return component;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// 组件此刻已被 pi 挂进 footer 容器(`setFooter` 是同步的),按对象身份认出那个容器;
|
|
197
|
+
// 换会话时冻结要用。顺手解除别的扩展实例遗留的接管(`/reload` 之后可能有一次)。
|
|
198
|
+
footerContainer = findRenderContainer(tuiHandle, footerComponent);
|
|
199
|
+
if (footerContainer) releaseFooterGuard(footerContainer);
|
|
200
|
+
scheduleDiffRefresh(0);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 换会话窗口内把 footer 钉在上一帧:pi 紧接着(在我们的 `session_shutdown` 回调之后)会
|
|
205
|
+
* 还原内置 footer 并清空 statuses,此时接管容器渲染,默认 footer 一帧都不会出。
|
|
206
|
+
*/
|
|
207
|
+
const freezeFooter = () => {
|
|
208
|
+
if (FROZEN_DISABLED || !footerContainer || !footerComponent) return;
|
|
209
|
+
const component = footerComponent;
|
|
210
|
+
const lines = lastFooterLines;
|
|
211
|
+
guardRelease = freezeFooterContainer({
|
|
212
|
+
container: footerContainer,
|
|
213
|
+
ownComponent: component,
|
|
214
|
+
frozenRender: (width) =>
|
|
215
|
+
lines
|
|
216
|
+
? lines.map((line) => truncateToWidth(line, width, ELLIPSIS))
|
|
217
|
+
: component.render(width),
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const releaseFooter = (ctx: ExtensionContext) => {
|
|
222
|
+
generation += 1;
|
|
223
|
+
cancelDiffRefresh();
|
|
224
|
+
activeTarget = undefined;
|
|
225
|
+
resetState();
|
|
226
|
+
requestRender = undefined;
|
|
227
|
+
ctx.ui.setFooter(undefined);
|
|
228
|
+
ctx.ui.setStatus(STATUSLINE_KEY, undefined);
|
|
229
|
+
freezeFooter();
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const bumpTool = (toolName: string, delta: number) => {
|
|
233
|
+
const next = (state.activeTools.get(toolName) ?? 0) + delta;
|
|
234
|
+
if (next <= 0) state.activeTools.delete(toolName);
|
|
235
|
+
else state.activeTools.set(toolName, next);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
pi.on("session_start", (_event, ctx) => {
|
|
239
|
+
installFooter(ctx);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// resume / fork / rewind 会换掉会话对象,footer 得按新 ctx 重装
|
|
243
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
244
|
+
installFooter(ctx);
|
|
245
|
+
render();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
249
|
+
releaseFooter(ctx);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
pi.on("model_select", () => render());
|
|
253
|
+
|
|
254
|
+
pi.on("thinking_level_select", () => render());
|
|
255
|
+
|
|
256
|
+
pi.on("agent_start", () => {
|
|
257
|
+
state.streaming = true;
|
|
258
|
+
render();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
pi.on("turn_start", () => {
|
|
262
|
+
state.streaming = true;
|
|
263
|
+
render();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
pi.on("agent_end", () => {
|
|
267
|
+
state.activeTools.clear();
|
|
268
|
+
scheduleDiffRefresh(GIT_DEBOUNCE_MS);
|
|
269
|
+
render();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// agent_end 之后 pi 还可能自动重试 / 压缩 / 跑排队消息,settled 才算真停
|
|
273
|
+
pi.on("agent_settled", () => {
|
|
274
|
+
state.streaming = false;
|
|
275
|
+
state.activeTools.clear();
|
|
276
|
+
render();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
pi.on("turn_end", () => {
|
|
280
|
+
scheduleDiffRefresh(GIT_DEBOUNCE_MS);
|
|
281
|
+
render();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
pi.on("tool_execution_start", (event) => {
|
|
285
|
+
bumpTool(event.toolName, 1);
|
|
286
|
+
render();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
pi.on("tool_execution_end", (event) => {
|
|
290
|
+
bumpTool(event.toolName, -1);
|
|
291
|
+
scheduleDiffRefresh(GIT_DEBOUNCE_MS);
|
|
292
|
+
render();
|
|
293
|
+
});
|
|
294
|
+
}
|