@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,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thinking Window Extension(文件名沿用 thinking-collapse.ts)
|
|
3
|
+
*
|
|
4
|
+
* 把 assistant 的 thinking 块渲染成**一条连续横向滚动的行**,避免大段推理刷屏。
|
|
5
|
+
* 纯显示层:session / 模型上下文里的原文完全不变。
|
|
6
|
+
*
|
|
7
|
+
* 形状永远是:
|
|
8
|
+
*
|
|
9
|
+
* Think: …最新写下的 token 一直追加在行尾
|
|
10
|
+
*
|
|
11
|
+
* 设计意图是「一条不间断的 token 流」:
|
|
12
|
+
* - **不分段、不换行**:thinking 里所有换行(模型自己折的行、空行分段、列表项、代码围栏内)
|
|
13
|
+
* 全部拼进这同一条行 —— 上一段结束后,下一段直接接续在上一段的结尾,**不另起一行**。
|
|
14
|
+
* 于是 Think 区域从头到尾只有一行在滚动,不会看到「换行重新起头」的动作。
|
|
15
|
+
* - **段落接缝(原文空行处)中文 ↔ 中文补一个逗号**,免得两段无缝粘连读起来像一句话
|
|
16
|
+
* (`第一段,第二段`);上一段末尾本来就有标点时不重复补,英文 / 中英混排仍按空格规则。
|
|
17
|
+
* 段内折行(没有空行)不补逗号 —— 否则模型自己折的每一行都会多一个。
|
|
18
|
+
* - 整行超出终端宽度时从**头部**丢掉溢出字符、行首补一个 `…`,行尾永远是最新写下的 token;
|
|
19
|
+
* 放得下就全量显示,不补 `…`。
|
|
20
|
+
* - 行首带 `Think: ` 标签(顶格,无竖线 gutter),始终钉在这一行最前面。
|
|
21
|
+
*
|
|
22
|
+
* 这里**没有**「短段回填补满整行」的逻辑:曾经有过(最后一段太短时把被顶出去的开头拉回来
|
|
23
|
+
* 拼满行,还配过 `isStreaming` 闸门只在定型后补),但它会不断改变整行构成、打断「token 持续
|
|
24
|
+
* 流动」的观感,已整体移除。现在短 thinking 就是短,行尾留白,不补。
|
|
25
|
+
*
|
|
26
|
+
* 标签用 `\x1b[23m` 关掉斜体再 `\x1b[3m` 打开,于是标签是正常字形、正文仍是 pi 给 thinking
|
|
27
|
+
* 块的斜体 + thinkingText 颜色(终端不支持 ANSI 时不夹这对控制码)。
|
|
28
|
+
*
|
|
29
|
+
* 刻意**不再**输出 `… (123 tokens hidden)` 提示行:底部 spinner 已经在数 token 了。
|
|
30
|
+
*
|
|
31
|
+
* ## 硬性不折行(本扩展的核心约束)
|
|
32
|
+
*
|
|
33
|
+
* 本扩展输出的行必须**恰好一行**,绝不允许被 pi 二次折行 —— thinking 内容不会被仔细阅读,
|
|
34
|
+
* 格式整洁比内容完整更重要,所以宁可截掉字符(补 `…`)也不折行。全行拼接、头部截断、
|
|
35
|
+
* 反引号配对、出口兜底截断的全部实现与理由都在 `./thinking-collapse/window.ts`
|
|
36
|
+
* (那个模块**不 import pi / pi-tui**,宽度函数注入,因此 `node --test` 能直接跑它)。
|
|
37
|
+
*
|
|
38
|
+
* 行宽度量用 pi-tui 的 `visibleWidth`(和 pi 自己的渲染器同一套实现),所以本扩展预裁出来的
|
|
39
|
+
* 行在 pi 那边不会再被二次折行。
|
|
40
|
+
*
|
|
41
|
+
* **刻意不注册命令**:窗口形态只有这一种,没有需要用户调的状态,也没有开关;
|
|
42
|
+
* 曾经有过 `/thinking-collapse off | on | <n>`,已移除。
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
46
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
47
|
+
|
|
48
|
+
import { buildThinkingWindow } from "./thinking-collapse/window.ts";
|
|
49
|
+
|
|
50
|
+
/** 终端明显不支持 ANSI 时不要夹控制码,否则会把 `\x1b[23m` 当字面字符画出来。 */
|
|
51
|
+
function ansiCapable(): boolean {
|
|
52
|
+
return process.env.TERM !== "dumb" && !process.env.NO_COLOR;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default function (pi: ExtensionAPI) {
|
|
56
|
+
pi.registerMarkdownTransformer((markdown, context) => {
|
|
57
|
+
if (context.messageType !== "assistant-thinking") return markdown;
|
|
58
|
+
return buildThinkingWindow(markdown, context.availableWidth, visibleWidth, ansiCapable());
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for title-row.ts — `Edit` / `Write` 标题行的纯排版(预算分配、按宽度截断、闪烁占位、行数缩略)。
|
|
3
|
+
*
|
|
4
|
+
* Run with: node --test clients/pi/extensions/tool-diff/title-row.test.ts
|
|
5
|
+
*
|
|
6
|
+
* 被测模块不 import pi / pi-tui(宽度与截断函数都是注入的),所以这里喂假实现:
|
|
7
|
+
* ASCII = 1 列、CJK = 2 列;`●`(U+25CF)与 `…` 都是 1 列 —— 与 pi-tui 的 `visibleWidth`
|
|
8
|
+
* 已核对一致(`visibleWidth("●") === 1`)。主题的 `fg` 换成恒等函数,断言里直接看纯文本。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
BLINK_MARKER,
|
|
16
|
+
ELLIPSIS,
|
|
17
|
+
TitleRow,
|
|
18
|
+
blinkSuffix,
|
|
19
|
+
formatCount,
|
|
20
|
+
shortenPath,
|
|
21
|
+
statsSuffix,
|
|
22
|
+
truncateStartToWidth,
|
|
23
|
+
type TitleRowDeps,
|
|
24
|
+
type TitleTheme,
|
|
25
|
+
} from "./title-row.ts";
|
|
26
|
+
|
|
27
|
+
/** 恒等主题:不着色,断言直接比对纯文本。 */
|
|
28
|
+
const plainTheme: TitleTheme = { fg: (_color, text) => text };
|
|
29
|
+
|
|
30
|
+
/** 假宽度函数:CJK 常见全角区间按 2 列,其余按 1 列。 */
|
|
31
|
+
function widthOf(text: string): number {
|
|
32
|
+
let width = 0;
|
|
33
|
+
for (const ch of text) {
|
|
34
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
35
|
+
const wide =
|
|
36
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
37
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
38
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
39
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
40
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
41
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
42
|
+
(code >= 0xffe0 && code <= 0xffe6);
|
|
43
|
+
width += wide ? 2 : 1;
|
|
44
|
+
}
|
|
45
|
+
return width;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 假截断函数(生产环境传 pi-tui 的 truncateToWidth):从尾部硬切,够用。 */
|
|
49
|
+
function truncateToWidth(text: string, width: number, ellipsis = ELLIPSIS): string {
|
|
50
|
+
if (widthOf(text) <= width) return text;
|
|
51
|
+
let out = "";
|
|
52
|
+
let used = 0;
|
|
53
|
+
for (const ch of text) {
|
|
54
|
+
const w = widthOf(ch);
|
|
55
|
+
if (used + w > width - widthOf(ellipsis)) break;
|
|
56
|
+
out += ch;
|
|
57
|
+
used += w;
|
|
58
|
+
}
|
|
59
|
+
return out + ellipsis;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function deps(home = "/Users/me"): TitleRowDeps {
|
|
63
|
+
return { widthOf, truncateToWidth, home, indent: " " };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 一条必须从头部截断才能放进 90 列的深层路径。 */
|
|
67
|
+
const LONG_PATH =
|
|
68
|
+
"/Users/me/work/project/packages/renderer/src/components/inner/deep/deeper/deepest/final/renderer-impl.ts";
|
|
69
|
+
|
|
70
|
+
function titleLine(path: string, suffix: string, width: number, rowDeps: TitleRowDeps = deps()): string {
|
|
71
|
+
return new TitleRow(rowDeps, plainTheme, "Write", path, suffix).render(width)[0] ?? "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("blinkSuffix: 灭态等宽占位", () => {
|
|
75
|
+
it("keeps the same visible width in both phases so the path budget cannot move", () => {
|
|
76
|
+
assert.equal(widthOf(blinkSuffix(plainTheme, true)), 1);
|
|
77
|
+
assert.equal(widthOf(blinkSuffix(plainTheme, false)), 1);
|
|
78
|
+
assert.equal(widthOf(blinkSuffix(plainTheme, true)), widthOf(blinkSuffix(plainTheme, false)));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("renders the marker only while the phase is on (the dark phase is blank)", () => {
|
|
82
|
+
assert.equal(blinkSuffix(plainTheme, true), BLINK_MARKER);
|
|
83
|
+
assert.equal(blinkSuffix(plainTheme, false).trim(), "");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("TitleRow: 闪烁期间超长路径不摆动(回归)", () => {
|
|
88
|
+
it("renders the same line in both blink phases, modulo the marker itself", () => {
|
|
89
|
+
const on = titleLine(LONG_PATH, blinkSuffix(plainTheme, true), 90);
|
|
90
|
+
const off = titleLine(LONG_PATH, blinkSuffix(plainTheme, false), 90);
|
|
91
|
+
// 两态的唯一差别是标记字符 —— 修复前灭态是空串,预算每帧跳 2 列,
|
|
92
|
+
// 截断窗口跟着左右挪(`…ccccc` vs `…ccccccc`),观感就是"文件名也在闪"
|
|
93
|
+
assert.equal(on.replace(BLINK_MARKER, " "), off);
|
|
94
|
+
assert.equal(widthOf(on), widthOf(off));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("keeps the file name and the line width stable across a sweep of widths", () => {
|
|
98
|
+
for (let width = 40; width <= 140; width += 1) {
|
|
99
|
+
const on = titleLine(LONG_PATH, blinkSuffix(plainTheme, true), width);
|
|
100
|
+
const off = titleLine(LONG_PATH, blinkSuffix(plainTheme, false), width);
|
|
101
|
+
assert.ok(widthOf(on) <= width, `line wider than ${width}: ${JSON.stringify(on)}`);
|
|
102
|
+
assert.equal(on.replace(BLINK_MARKER, " "), off, `path moved at width ${width}`);
|
|
103
|
+
// 路径永远保留尾部:无论宽度多窄,文件名都能完整看到
|
|
104
|
+
assert.ok(on.replace(BLINK_MARKER, " ").trimEnd().endsWith("renderer-impl.ts"), `no file name at width ${width}`);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("keeps the tail (file name) visible while dropping the head of a long path", () => {
|
|
109
|
+
const line = titleLine(LONG_PATH, blinkSuffix(plainTheme, true), 90);
|
|
110
|
+
assert.ok(line.startsWith(" Write "), line);
|
|
111
|
+
assert.ok(line.includes(ELLIPSIS), line);
|
|
112
|
+
assert.ok(line.includes("/final/renderer-impl.ts"), line);
|
|
113
|
+
assert.ok(!line.includes("/Users/me/work"), line);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("TitleRow: 工具名与尾部永远完整", () => {
|
|
118
|
+
it("keeps a wide stats suffix intact and truncates the path instead", () => {
|
|
119
|
+
const line = titleLine(LONG_PATH, statsSuffix(plainTheme, { added: 123456, removed: 9999 }), 90);
|
|
120
|
+
assert.ok(line.endsWith("(+123k -9999)"), line);
|
|
121
|
+
assert.ok(line.includes(ELLIPSIS), line);
|
|
122
|
+
assert.ok(widthOf(line) <= 90, line);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("falls back to whole-line truncation when the path budget runs out", () => {
|
|
126
|
+
const line = titleLine(LONG_PATH, blinkSuffix(plainTheme, true), 8);
|
|
127
|
+
assert.equal(widthOf(line) <= 8, true, line);
|
|
128
|
+
assert.ok(line.includes("Write"), line);
|
|
129
|
+
// 退回整行截断时两态同样只差标记:尾部是等宽空格,预算不会跳
|
|
130
|
+
const off = titleLine(LONG_PATH, blinkSuffix(plainTheme, false), 8);
|
|
131
|
+
assert.equal(line.replace(BLINK_MARKER, " "), off);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("renders the streaming-start form (no path yet) without a dangling separator", () => {
|
|
135
|
+
assert.equal(titleLine("", blinkSuffix(plainTheme, true), 60).trimEnd(), " Write ●");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("TitleRow: 路径与缩进", () => {
|
|
140
|
+
it("shortens the home prefix and honours a custom indent", () => {
|
|
141
|
+
const line = titleLine("/Users/me/work/project/src/index.ts", "", 60, {
|
|
142
|
+
...deps(),
|
|
143
|
+
indent: " ",
|
|
144
|
+
});
|
|
145
|
+
assert.equal(line, " Write ~/work/project/src/index.ts");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("colors the path with the text token, after truncation (ANSI never reaches the measurer)", () => {
|
|
149
|
+
const calls: Array<[string, string]> = [];
|
|
150
|
+
const recordingTheme: TitleTheme = {
|
|
151
|
+
fg: (color, text) => {
|
|
152
|
+
calls.push([color, text]);
|
|
153
|
+
return text;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
new TitleRow(deps(), recordingTheme, "Write", LONG_PATH, "").render(90);
|
|
157
|
+
assert.equal(calls.length, 1);
|
|
158
|
+
assert.equal(calls[0]?.[0], "text");
|
|
159
|
+
assert.ok(calls[0]?.[1].startsWith(ELLIPSIS), calls[0]?.[1]);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("formatCount", () => {
|
|
164
|
+
it("keeps counts below 10k exact", () => {
|
|
165
|
+
assert.equal(formatCount(0), "0");
|
|
166
|
+
assert.equal(formatCount(1), "1");
|
|
167
|
+
assert.equal(formatCount(9999), "9999");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("abbreviates counts at or above 10k with a k suffix", () => {
|
|
171
|
+
assert.equal(formatCount(10_000), "10k");
|
|
172
|
+
assert.equal(formatCount(10_043), "10k");
|
|
173
|
+
assert.equal(formatCount(11_133), "11.1k");
|
|
174
|
+
assert.equal(formatCount(10_450), "10.5k");
|
|
175
|
+
assert.equal(formatCount(99_999), "100k");
|
|
176
|
+
assert.equal(formatCount(123_456), "123k");
|
|
177
|
+
assert.equal(formatCount(1_234_567), "1235k");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("statsSuffix", () => {
|
|
182
|
+
/** 记录 fg 调用的主题,用来断言着色 token。 */
|
|
183
|
+
function recorder(): { theme: TitleTheme; calls: Array<[string, string]> } {
|
|
184
|
+
const calls: Array<[string, string]> = [];
|
|
185
|
+
return {
|
|
186
|
+
calls,
|
|
187
|
+
theme: {
|
|
188
|
+
fg: (color, text) => {
|
|
189
|
+
calls.push([color, text]);
|
|
190
|
+
return text;
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
it("returns an empty string when there is nothing to report", () => {
|
|
197
|
+
assert.equal(statsSuffix(plainTheme, undefined), "");
|
|
198
|
+
assert.equal(statsSuffix(plainTheme, { added: 0, removed: 0 }), "");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("omits the side that is zero", () => {
|
|
202
|
+
assert.equal(statsSuffix(plainTheme, { added: 3, removed: 0 }), "(+3)");
|
|
203
|
+
assert.equal(statsSuffix(plainTheme, { added: 0, removed: 5 }), "(-5)");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("shows both sides with the diff color tokens", () => {
|
|
207
|
+
const { theme, calls } = recorder();
|
|
208
|
+
assert.equal(statsSuffix(theme, { added: 3, removed: 5 }), "(+3 -5)");
|
|
209
|
+
assert.deepEqual(calls, [
|
|
210
|
+
["toolDiffAdded", "+3"],
|
|
211
|
+
["toolDiffRemoved", "-5"],
|
|
212
|
+
["dim", "("],
|
|
213
|
+
["dim", ")"],
|
|
214
|
+
]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("abbreviates huge counts (the tail must stay narrow)", () => {
|
|
218
|
+
assert.equal(statsSuffix(plainTheme, { added: 10_043, removed: 11_133 }), "(+10k -11.1k)");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("shortenPath", () => {
|
|
223
|
+
it("shortens the home prefix at a path boundary only", () => {
|
|
224
|
+
assert.equal(shortenPath("/Users/me/work/index.ts", "/Users/me"), "~/work/index.ts");
|
|
225
|
+
assert.equal(shortenPath("/Users/me", "/Users/me"), "~");
|
|
226
|
+
// `/Users/melo` 不该被缩成 `~lo`
|
|
227
|
+
assert.equal(shortenPath("/Users/melo/index.ts", "/Users/me"), "/Users/melo/index.ts");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("leaves the path alone without a home directory or outside of it", () => {
|
|
231
|
+
assert.equal(shortenPath("/tmp/renderer.ts", ""), "/tmp/renderer.ts");
|
|
232
|
+
assert.equal(shortenPath("/tmp/renderer.ts", "/Users/me"), "/tmp/renderer.ts");
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("truncateStartToWidth", () => {
|
|
237
|
+
it("returns the text unchanged when it fits", () => {
|
|
238
|
+
assert.equal(truncateStartToWidth("abcdef", 6, widthOf), "abcdef");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("keeps the tail and marks the dropped head with an ellipsis", () => {
|
|
242
|
+
assert.equal(truncateStartToWidth("abcdef", 4, widthOf), "…def");
|
|
243
|
+
assert.equal(truncateStartToWidth("abcdef", 1, widthOf), ELLIPSIS);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("returns nothing when there is no room at all", () => {
|
|
247
|
+
assert.equal(truncateStartToWidth("abcdef", 0, widthOf), "");
|
|
248
|
+
assert.equal(truncateStartToWidth("abcdef", -3, widthOf), "");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("measures CJK characters as two columns", () => {
|
|
252
|
+
assert.equal(truncateStartToWidth("路径文件名", 5, widthOf), "…件名");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* title-row.ts — `Edit` / `Write` 标题行(`Edit <path> (+3 -5)`)的纯排版逻辑。
|
|
3
|
+
* 工具名由调用方传入(`titleRow(theme, "Edit", …)`),本模块不认工具、只看宽度。
|
|
4
|
+
*
|
|
5
|
+
* 从 `tool-diff.ts` 抽出来的理由有两个:
|
|
6
|
+
*
|
|
7
|
+
* 1. **可单测**。这段是纯字符串排版(预算分配 + 按宽度截断),却埋在 1300 多行、到处
|
|
8
|
+
* import pi 的 `tool-diff.ts` 里,`node --test` 根本 import 不进来。抽出后本模块
|
|
9
|
+
* **不 import pi / pi-tui**:宽度函数 `widthOf`、截断函数 `truncateToWidth`、home
|
|
10
|
+
* 目录、缩进全部从 `deps` 注入(与 `working-summary.ts` 同一套做法),测试里喂一个
|
|
11
|
+
* ASCII=1 / CJK=2 的假量宽函数就能跑满每个分支。
|
|
12
|
+
* 2. **「超长路径跟着闪烁一起抖」的现场就在这里**。路径预算按「可用宽度 − 工具名 −
|
|
13
|
+
* 尾部」算,而流式期间尾部是那个闪烁的 `●` —— 亮 / 灭两态各自占 2 列和 0 列,于是
|
|
14
|
+
* 每 500ms 预算变一次、路径的截断窗口跟着左右挪 2 列,看起来就是「文件名自己在闪」。
|
|
15
|
+
* 修法见 `blinkSuffix`(灭态等宽占位)。
|
|
16
|
+
*
|
|
17
|
+
* 模块放在 `extensions/tool-diff/` 这个**同名目录**下(`tool-diff.ts` 与 `tool-diff/` 可以
|
|
18
|
+
* 并存):目录里没有 `index.ts`,而 pi 的加载器只认 `extensions/<name>.ts` 与
|
|
19
|
+
* `extensions/<name>/index.ts`(loader.js 的 `discoverExtensionsInDir` → `resolveExtensionEntries`
|
|
20
|
+
* 找不到入口就整目录跳过),所以它不会被当成扩展加载。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** 从头部截断时用的省略号。1 列宽。 */
|
|
24
|
+
export const ELLIPSIS = "…";
|
|
25
|
+
/** 流式加载中的标记字符。1 列宽(`visibleWidth("●") === 1`,已核对)。 */
|
|
26
|
+
export const BLINK_MARKER = "●";
|
|
27
|
+
|
|
28
|
+
/** 渲染需要的主题子集(`Theme.fg` 的方法声明是**双变**的,真实 `Theme` 可直接赋值)。 */
|
|
29
|
+
export interface TitleTheme {
|
|
30
|
+
fg(color: string, text: string): string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 标题行尾部 `(+N -M)` 的行数统计。 */
|
|
34
|
+
export interface TitleStats {
|
|
35
|
+
added: number;
|
|
36
|
+
removed: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 排版依赖(全部注入,本模块因此不 import pi / pi-tui)。
|
|
41
|
+
*
|
|
42
|
+
* `widthOf` / `truncateToWidth` 运行时传 pi-tui 的 `visibleWidth` / `truncateToWidth`
|
|
43
|
+
* (与渲染器同一套实现,量出来的宽度与 pi 实际折行严格一致);`home` 传 `os.homedir()`;
|
|
44
|
+
* `indent` 传调用方的标题缩进。
|
|
45
|
+
*/
|
|
46
|
+
export interface TitleRowDeps {
|
|
47
|
+
/** 可见宽度(忽略 ANSI 转义、CJK 记 2 列) */
|
|
48
|
+
widthOf(text: string): number;
|
|
49
|
+
/** 按可见宽度从**尾部**截断(ANSI 安全),超出部分换成一个省略号 */
|
|
50
|
+
truncateToWidth(text: string, width: number, ellipsis: string): string;
|
|
51
|
+
/** home 目录(`""` = 不做 `~` 缩写) */
|
|
52
|
+
home?: string;
|
|
53
|
+
/** 标题缩进,默认 1 个空格 */
|
|
54
|
+
indent?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 闪烁标记的着色尾部。**灭态返回等宽空格占位,绝不返回空串。**
|
|
59
|
+
*
|
|
60
|
+
* 为什么不能返回空串:`TitleRow` 的路径预算 = 可用宽度 − 工具名 − 尾部(分隔空格 + 标记),
|
|
61
|
+
* 尾部在「亮 = 2 列 / 灭 = 0 列」之间来回就是**预算每 500ms 跳 2 列**。路径短(装得下)时
|
|
62
|
+
* 看不出来,路径长到需要截断时,截断窗口跟着一起左右挪 —— 实测每个闪烁周期里路径的前半段
|
|
63
|
+
* (`…` 之后那截)和文件名的落点都会平移 2 列,观感是「标记在闪、文件名也在闪」。
|
|
64
|
+
* 用空格占位后两态同宽,路径恒定,只有标记本身在亮灭。
|
|
65
|
+
*/
|
|
66
|
+
export function blinkSuffix(theme: TitleTheme, on: boolean): string {
|
|
67
|
+
return theme.fg("dim", on ? BLINK_MARKER : " ");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 行数的显示形态:万位以上缩略成 `k`(`10043` → `10k`、`11133` → `11.1k`)。
|
|
72
|
+
*
|
|
73
|
+
* 尾部 `(+N -M)` 是这一行里**不能被截掉**的部分,但两个五位数并排能吃掉 13 列以上,
|
|
74
|
+
* 窄终端里那几列本该留给路径。缩到 `k` 之后最多 5-6 列,路径的可用宽度也稳定了
|
|
75
|
+
* (写入量越大越极端,这里只影响显示,不影响任何统计口径)。
|
|
76
|
+
* 万位以下原样显示 —— `+1043` 比 `+1.0k` 精确,也不长。
|
|
77
|
+
*/
|
|
78
|
+
export function formatCount(count: number): string {
|
|
79
|
+
if (!Number.isFinite(count) || count < 10_000) return String(count);
|
|
80
|
+
const thousands = count / 1000;
|
|
81
|
+
if (thousands < 100) {
|
|
82
|
+
const rounded = Math.round(thousands * 10) / 10;
|
|
83
|
+
return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}k`;
|
|
84
|
+
}
|
|
85
|
+
return `${Math.round(thousands)}k`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 标题行尾部的 `(+3 -5)`:新增绿、删除红(沿用 diff 的 `toolDiffAdded` / `toolDiffRemoved`,
|
|
90
|
+
* 跟卡片里的 +/- 行同色),括号保持 dim。为 0 的一侧省略,两侧都为 0(无改动 / 算不出来)
|
|
91
|
+
* 返回空串 —— 空括号比没有括号更难看。
|
|
92
|
+
*/
|
|
93
|
+
export function statsSuffix(theme: TitleTheme, stats: TitleStats | undefined): string {
|
|
94
|
+
if (stats === undefined) return "";
|
|
95
|
+
const parts: string[] = [];
|
|
96
|
+
if (stats.added > 0) parts.push(theme.fg("toolDiffAdded", `+${formatCount(stats.added)}`));
|
|
97
|
+
if (stats.removed > 0) parts.push(theme.fg("toolDiffRemoved", `-${formatCount(stats.removed)}`));
|
|
98
|
+
if (parts.length === 0) return "";
|
|
99
|
+
return `${theme.fg("dim", "(")}${parts.join(" ")}${theme.fg("dim", ")")}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 把 home 目录前缀缩写成 `~`,跟内置工具标题的惯例一致 */
|
|
103
|
+
export function shortenPath(path: string, home: string): string {
|
|
104
|
+
if (home === "" || !path.startsWith(home)) return path;
|
|
105
|
+
const rest = path.slice(home.length);
|
|
106
|
+
// 只在边界处缩写:`~` 本身或 `~/...`,避免把 `/Users/bachir/...` 误缩成 `~r/...`
|
|
107
|
+
if (rest === "" || rest.startsWith("/")) return `~${rest}`;
|
|
108
|
+
return path;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 从**头部**截断到指定列宽:保留尾部,前面用一个 `…` 顶替被丢掉的字符。
|
|
113
|
+
* 按**码点**走(不是 UTF-16 单元),所以代理对不会被切成半个;宽度用 `widthOf`
|
|
114
|
+
* 逐字符量,CJK / emoji 占 2 列也算得对。
|
|
115
|
+
*/
|
|
116
|
+
export function truncateStartToWidth(
|
|
117
|
+
text: string,
|
|
118
|
+
maxWidth: number,
|
|
119
|
+
widthOf: (text: string) => number,
|
|
120
|
+
ellipsis = ELLIPSIS,
|
|
121
|
+
): string {
|
|
122
|
+
if (maxWidth <= 0) return "";
|
|
123
|
+
if (widthOf(text) <= maxWidth) return text;
|
|
124
|
+
const budget = maxWidth - widthOf(ellipsis);
|
|
125
|
+
if (budget <= 0) return ellipsis;
|
|
126
|
+
const chars = Array.from(text);
|
|
127
|
+
let used = 0;
|
|
128
|
+
let start = chars.length;
|
|
129
|
+
for (let i = chars.length - 1; i >= 0; i -= 1) {
|
|
130
|
+
const charWidth = widthOf(chars[i] ?? "");
|
|
131
|
+
if (used + charWidth > budget) break;
|
|
132
|
+
used += charWidth;
|
|
133
|
+
start = i;
|
|
134
|
+
}
|
|
135
|
+
return ellipsis + chars.slice(start).join("");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 标题行组件。用 `Text` 的话长路径会被折成两三行(工具名 / 路径 / 计数各一行),
|
|
140
|
+
* 所以自己按宽度排版成**恒一行**。
|
|
141
|
+
*
|
|
142
|
+
* 排版优先级:工具名和尾部(`(+N -M)` / 闪烁的 `●`)**永远完整**,路径吃剩下的宽度、
|
|
143
|
+
* 超宽时从头部截断。尾部是这一行的信息量所在,被截掉就失去意义了;只有窄到连一个
|
|
144
|
+
* 字符的路径都放不下时,才退回整行尾部截断(至少保住工具名)。
|
|
145
|
+
*/
|
|
146
|
+
export class TitleRow {
|
|
147
|
+
// 字段显式声明 + 构造里赋值,**不用参数属性**(`constructor(private readonly x)`):
|
|
148
|
+
// Node 的类型剥离不支持它(`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`),而本模块要能被
|
|
149
|
+
// `node --test` 直接 import。
|
|
150
|
+
private readonly deps: TitleRowDeps;
|
|
151
|
+
private readonly theme: TitleTheme;
|
|
152
|
+
/** 已着色的工具名 */
|
|
153
|
+
private readonly tool: string;
|
|
154
|
+
/** 纯文本路径(未着色)—— 先按宽度截断再上色,否则 ANSI 串会被截断逻辑当成可见字符 */
|
|
155
|
+
private readonly path: string;
|
|
156
|
+
/** 已着色的尾部 */
|
|
157
|
+
private readonly suffix: string;
|
|
158
|
+
|
|
159
|
+
constructor(deps: TitleRowDeps, theme: TitleTheme, tool: string, path: string, suffix: string) {
|
|
160
|
+
this.deps = deps;
|
|
161
|
+
this.theme = theme;
|
|
162
|
+
this.tool = tool;
|
|
163
|
+
this.path = path;
|
|
164
|
+
this.suffix = suffix;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
render(width: number): string[] {
|
|
168
|
+
const { widthOf } = this.deps;
|
|
169
|
+
const indent = this.deps.indent ?? " ";
|
|
170
|
+
// 缩进占 1 列,所以正文的可用宽度要相应减 1,否则整行会超宽
|
|
171
|
+
const available = Math.max(1, width - widthOf(indent));
|
|
172
|
+
const path = this.path === "" ? "" : shortenPath(this.path, this.deps.home ?? "");
|
|
173
|
+
const toolWidth = widthOf(this.tool);
|
|
174
|
+
// 尾部与路径各占一个分隔空格
|
|
175
|
+
const suffixWidth = this.suffix === "" ? 0 : 1 + widthOf(this.suffix);
|
|
176
|
+
const pathBudget = available - toolWidth - suffixWidth - (path === "" ? 0 : 1);
|
|
177
|
+
|
|
178
|
+
if (path === "" || pathBudget < 1) {
|
|
179
|
+
const fallback = [this.tool, path === "" ? "" : this.theme.fg("text", path), this.suffix]
|
|
180
|
+
.filter((part) => part !== "")
|
|
181
|
+
.join(" ");
|
|
182
|
+
return [indent + this.deps.truncateToWidth(fallback, available, ELLIPSIS)];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const shown = this.theme.fg("text", truncateStartToWidth(path, pathBudget, widthOf));
|
|
186
|
+
const tail = this.suffix === "" ? "" : ` ${this.suffix}`;
|
|
187
|
+
return [indent + `${this.tool} ${shown}${tail}`];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
invalidate(): void {}
|
|
191
|
+
}
|