@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,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for window.ts — thinking 单行连续滚动窗口的纯逻辑(全行拼接、头部截断、反引号配对、兜底截断)。
|
|
3
|
+
*
|
|
4
|
+
* Run with: node --test clients/pi/extensions/thinking-collapse/window.test.ts
|
|
5
|
+
*
|
|
6
|
+
* 被测模块不 import pi / pi-tui(宽度函数 `widthOf` 是注入的),所以这里喂假实现:
|
|
7
|
+
* ASCII = 1 列、CJK 全角 = 2 列、`…` 也是 1 列 —— 与 pi-tui 的 `visibleWidth`
|
|
8
|
+
* 已核对一致。断言直接比对纯文本。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
ELLIPSIS,
|
|
16
|
+
HEADER_LABEL,
|
|
17
|
+
ITALIC_OFF,
|
|
18
|
+
buildThinkingWindow,
|
|
19
|
+
clipToTail,
|
|
20
|
+
findCodeSpanEnd,
|
|
21
|
+
joinAllLines,
|
|
22
|
+
normalizeTabs,
|
|
23
|
+
stripUnpairedBackticks,
|
|
24
|
+
truncateTail,
|
|
25
|
+
} from "./window.ts";
|
|
26
|
+
|
|
27
|
+
/** 假宽度函数:CJK 常见全角区间按 2 列,其余按 1 列。 */
|
|
28
|
+
function widthOf(text: string): number {
|
|
29
|
+
let width = 0;
|
|
30
|
+
for (const ch of text) {
|
|
31
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
32
|
+
const wide =
|
|
33
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
34
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
35
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
36
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
37
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
38
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
39
|
+
(code >= 0xffe0 && code <= 0xffe6);
|
|
40
|
+
width += wide ? 2 : 1;
|
|
41
|
+
}
|
|
42
|
+
return width;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 生产调用:不开 ANSI(断言直接看纯文本)。 */
|
|
46
|
+
function render(markdown: string, availableWidth: number): string {
|
|
47
|
+
return buildThinkingWindow(markdown, availableWidth, widthOf, false);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const FIRST_PREFIX = HEADER_LABEL;
|
|
51
|
+
|
|
52
|
+
/** 断言某条出口行的反引号**自身配对**(marked 不会拿它跟别的行配成跨行 code span)。 */
|
|
53
|
+
function assertBalancedBackticks(line: string): void {
|
|
54
|
+
let i = 0;
|
|
55
|
+
while (i < line.length) {
|
|
56
|
+
if (line[i] !== "`") {
|
|
57
|
+
i++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const end = findCodeSpanEnd(line, i);
|
|
61
|
+
assert.notEqual(end, -1, `落单的反引号:${JSON.stringify(line)}`);
|
|
62
|
+
i = end;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("normalizeTabs", () => {
|
|
67
|
+
it("tabs become three spaces like pi's markdown renderer", () => {
|
|
68
|
+
assert.equal(normalizeTabs("a\tb"), "a b");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("joinAllLines", () => {
|
|
73
|
+
it("joins ascii lines with exactly one space", () => {
|
|
74
|
+
assert.equal(joinAllLines(["hello", "world"]), "hello world");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("keeps a single spaces inside a line but collapses runs", () => {
|
|
78
|
+
assert.equal(joinAllLines(["a b", "c d"]), "a b c d");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("drops indentation and blank-ish lines", () => {
|
|
82
|
+
assert.equal(joinAllLines([" indented", " ", "next"]), "indented next");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("does not add a space between two wide characters", () => {
|
|
86
|
+
assert.equal(joinAllLines(["用户在问", "这个操作的流程"]), "用户在问这个操作的流程");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("does not add a space around CJK punctuation", () => {
|
|
90
|
+
assert.equal(joinAllLines(["显示逻辑,", "现在是四行"]), "显示逻辑,现在是四行");
|
|
91
|
+
assert.equal(joinAllLines(["看这里:", "下一行"]), "看这里:下一行");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("does not add a space after an opening bracket or before a closing one", () => {
|
|
95
|
+
assert.equal(joinAllLines(["call(", ")"]), "call()");
|
|
96
|
+
assert.equal(joinAllLines(["summary", ".ts"]), "summary.ts");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("does add a space after an ascii comma or in mixed text", () => {
|
|
100
|
+
assert.equal(joinAllLines(["hello,", "world"]), "hello, world");
|
|
101
|
+
assert.equal(joinAllLines(["看这个 file", "下一行"]), "看这个 file 下一行");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("skips blank lines so paragraphs run together with no break", () => {
|
|
105
|
+
// 空行是段落分隔:跳过后上一段的结尾直接接续下一段的开头(ASCII 之间仍是空格)
|
|
106
|
+
assert.equal(joinAllLines(["first para", "", "second para"]), "first para second para");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("inserts a comma at a CJK paragraph break", () => {
|
|
110
|
+
// 段落接缝处中文 ↔ 中文补逗号,把两段在视觉上分开
|
|
111
|
+
assert.equal(joinAllLines(["第一段", "", "第二段"]), "第一段,第二段");
|
|
112
|
+
assert.equal(joinAllLines(["第一段", "", "", "第二段"]), "第一段,第二段");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("does not double up punctuation at a paragraph break", () => {
|
|
116
|
+
// 上一段末尾已有标点(CJK 或 ASCII 收尾标点)就不重复补
|
|
117
|
+
assert.equal(joinAllLines(["第一段。", "", "第二段"]), "第一段。第二段");
|
|
118
|
+
assert.equal(joinAllLines(["ends.", "", "Next"]), "ends. Next");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("keeps the space rule for ascii and mixed paragraph breaks", () => {
|
|
122
|
+
assert.equal(joinAllLines(["English ends", "", "Next starts"]), "English ends Next starts");
|
|
123
|
+
assert.equal(joinAllLines(["中文结尾", "", "English starts"]), "中文结尾 English starts");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("does not insert a comma at an intra-paragraph line break", () => {
|
|
127
|
+
// 段内折行(没有空行)不补逗号:中文之间仍直接接续
|
|
128
|
+
assert.equal(joinAllLines(["用户在问", "这个操作的流程"]), "用户在问这个操作的流程");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("stripUnpairedBackticks", () => {
|
|
133
|
+
it("keeps a balanced code span", () => {
|
|
134
|
+
assert.equal(stripUnpairedBackticks("a `code` b"), "a `code` b");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("drops a dangling closer left behind by a head clip", () => {
|
|
138
|
+
assert.equal(stripUnpairedBackticks("xt here` and more"), "xt here and more");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("drops a dangling opener", () => {
|
|
142
|
+
assert.equal(stripUnpairedBackticks("a `code and more"), "a code and more");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("keeps balanced runs next to a dropped run", () => {
|
|
146
|
+
assert.equal(stripUnpairedBackticks("`a` `b"), "`a` b");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("handles multi-backtick runs with the same rule as marked", () => {
|
|
150
|
+
assert.equal(stripUnpairedBackticks("``a`` `b"), "``a`` b");
|
|
151
|
+
assert.equal(stripUnpairedBackticks("`a`` b`"), "`a`` b`");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("clipToTail / truncateTail", () => {
|
|
156
|
+
it("returns the text unchanged when it fits", () => {
|
|
157
|
+
assert.equal(clipToTail("abc", 3, widthOf), "abc");
|
|
158
|
+
assert.equal(truncateTail("abc", 3, widthOf), "abc");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("keeps the tail behind an ellipsis", () => {
|
|
162
|
+
assert.equal(clipToTail("abcdef", 4, widthOf), "…def");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("keeps the head before an ellipsis", () => {
|
|
166
|
+
assert.equal(truncateTail("abcdef", 4, widthOf), "abc…");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("drops a boundary space instead of leaving it floating before the ellipsis", () => {
|
|
170
|
+
// 贪心取到 "ab " 后把尾巴上的空格去掉(这一行会短 1 列,但不会出现 `the …` 这种悬空空格)
|
|
171
|
+
assert.equal(truncateTail("ab cdef", 4, widthOf), "ab" + ELLIPSIS);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("never exceeds the budget when a wide char straddles the boundary", () => {
|
|
175
|
+
assert.equal(clipToTail("ab中文", 4, widthOf), "…文");
|
|
176
|
+
assert.ok(widthOf(clipToTail("ab中文", 4, widthOf)) <= 4);
|
|
177
|
+
assert.ok(widthOf(clipToTail("ab中", 3, widthOf)) <= 3);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("returns nothing when there is no room for the ellipsis", () => {
|
|
181
|
+
assert.equal(clipToTail("abcdef", 1, widthOf), "");
|
|
182
|
+
assert.equal(clipToTail("abcdef", 0, widthOf), "");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("buildThinkingWindow", () => {
|
|
187
|
+
it("renders one paragraph as a labelled single line", () => {
|
|
188
|
+
assert.equal(render("hello world", 40), `${FIRST_PREFIX}hello world`);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("trims the body and normalizes tabs", () => {
|
|
192
|
+
assert.equal(render("\n a\tb \n", 40), `${FIRST_PREFIX}a b`);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("returns the original markdown when there is nothing to show", () => {
|
|
196
|
+
assert.equal(render(" \n\n ", 40), " \n\n ");
|
|
197
|
+
assert.equal(render("", 40), "");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("always outputs exactly one line, no matter how many paragraphs the thinking has", () => {
|
|
201
|
+
for (const md of ["a\n\nb\n\nc\n\nd\n\ne", "a\nb\nc", "一段\n\n二段\n\n三段", "x".repeat(500)]) {
|
|
202
|
+
const out = render(md, 40);
|
|
203
|
+
assert.equal(out.split("\n").length, 1, `出现了换行:${JSON.stringify(out)}`);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("joins paragraphs with no break so the next one continues the previous line", () => {
|
|
208
|
+
// 空行分段被跳过:上一段结尾直接接续下一段开头;ASCII 之间补空格,中文段落接缝补逗号
|
|
209
|
+
assert.equal(render("first para\n\nsecond para\n\nthird", 60), `${FIRST_PREFIX}first para second para third`);
|
|
210
|
+
assert.equal(render("第一段\n\n第二段", 60), `${FIRST_PREFIX}第一段,第二段`);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("keeps a paragraph on one line by joining its own newlines", () => {
|
|
214
|
+
assert.equal(render("line one\nline two", 40), `${FIRST_PREFIX}line one line two`);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("keeps the whole thinking when it fits, without an ellipsis", () => {
|
|
218
|
+
assert.equal(render("short thinking", 40), `${FIRST_PREFIX}short thinking`);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("pushes the front out and marks it with an ellipsis when the thinking is too wide", () => {
|
|
222
|
+
const text = "one two three four five six seven";
|
|
223
|
+
const line = render(text, 20);
|
|
224
|
+
assert.ok(line.startsWith(FIRST_PREFIX + ELLIPSIS));
|
|
225
|
+
assert.ok(widthOf(line) <= 20);
|
|
226
|
+
// 行尾永远是原文的结尾(最新写下的 token)
|
|
227
|
+
const tail = line.slice(FIRST_PREFIX.length + 1);
|
|
228
|
+
assert.ok(text.endsWith(tail), `tail ${JSON.stringify(tail)} 不是原文后缀`);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("does not pad a short last paragraph up to a full line", () => {
|
|
232
|
+
// 短 thinking 不回填:行尾就是最新内容本身,不会把被顶出去的开头拉回来拼满
|
|
233
|
+
// 全 CJK:预算 33 列 = `…`(1) + 14 个「中」(28) + 段落逗号 `,`(2) + `短`(2)
|
|
234
|
+
const line = render("中".repeat(60) + "\n\n短", 40);
|
|
235
|
+
assert.equal(line, `${FIRST_PREFIX}${ELLIPSIS}${"中".repeat(14)},短`);
|
|
236
|
+
assert.ok(line.endsWith("短"), `行尾必须是最新内容:${JSON.stringify(line)}`);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("leaves the tail blank when a short final paragraph does not fill the line", () => {
|
|
240
|
+
// 定型后也不补行:整行宽度就是内容本身,明显不满就留白
|
|
241
|
+
const line = render("开头写了一些内容\n\n尾", 60);
|
|
242
|
+
assert.equal(line, `${FIRST_PREFIX}开头写了一些内容,尾`);
|
|
243
|
+
assert.ok(widthOf(line) < 60, `不该被补满整行:${widthOf(line)}`);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("renders the same scrolling line while streaming and after it settles", () => {
|
|
247
|
+
// 没有 isStreaming 分支了:流式与定型是同一套逻辑,形态只随内容长度变化
|
|
248
|
+
const md = "第一段的内容\n\n第二段的内容\n\n尾";
|
|
249
|
+
assert.equal(render(md, 60), render(md, 60));
|
|
250
|
+
assert.equal(render(md, 60), `${FIRST_PREFIX}第一段的内容,第二段的内容,尾`);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("grows the tail token by token without ever restarting the line", () => {
|
|
254
|
+
// 逐 token 追加:行尾始终是最新内容,前缀单调延长(或被 `…` 顶掉头部),不另起一行
|
|
255
|
+
const head = "第一段的内容在这里写了不少字\n\n第二段也写了一些内容\n\n";
|
|
256
|
+
let previous = "";
|
|
257
|
+
for (const tail of ["尾", "尾巴", "尾巴收", "尾巴收尾了"]) {
|
|
258
|
+
const line = render(head + tail, 60);
|
|
259
|
+
assert.equal(line.split("\n").length, 1);
|
|
260
|
+
assert.ok(line.endsWith(tail), `行尾不是最新 token:${JSON.stringify(line)}`);
|
|
261
|
+
if (previous !== "") assert.ok(widthOf(line) >= widthOf(previous), "行宽不该回退");
|
|
262
|
+
previous = line;
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("keeps the rendered line within the available width", () => {
|
|
267
|
+
const cases = [
|
|
268
|
+
"a".repeat(200),
|
|
269
|
+
"中文".repeat(80),
|
|
270
|
+
"first `code span` tail",
|
|
271
|
+
"first para\n\n" + "中文English mixed 混排 ".repeat(20),
|
|
272
|
+
"x".repeat(30) + "\n\n" + "y".repeat(30) + "\n\n" + "z".repeat(30),
|
|
273
|
+
"中文".repeat(60) + "\n\n尾",
|
|
274
|
+
"`cut code span`\n\n尾\n\n再一段",
|
|
275
|
+
"head `span` tail\n\n" + "x".repeat(3),
|
|
276
|
+
];
|
|
277
|
+
for (const text of cases) {
|
|
278
|
+
for (const width of [8, 12, 20, 40, 80]) {
|
|
279
|
+
const line = render(text, width);
|
|
280
|
+
assert.equal(line.split("\n").length, 1, `出现了换行:${JSON.stringify(line)}`);
|
|
281
|
+
assert.ok(widthOf(line) <= width, `宽度 ${widthOf(line)} > ${width}:${JSON.stringify(line)}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("keeps backticks balanced inside the line so marked cannot merge lines", () => {
|
|
287
|
+
// 头部截断会切在 code span 中间:落单的闭合反引号必须被删掉
|
|
288
|
+
const text = "aaaa `inline code` bbbb\n\ncccc `another one` dddd";
|
|
289
|
+
const line = render(text, 24);
|
|
290
|
+
assert.equal(line.split("\n").length, 1);
|
|
291
|
+
assertBalancedBackticks(line);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("does not leak an empty line when the content ends with a paragraph break", () => {
|
|
295
|
+
assert.equal(render("done thinking\n\n", 40), `${FIRST_PREFIX}done thinking`);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("wraps the label in italic-off/on codes when ansi is enabled", () => {
|
|
299
|
+
const line = buildThinkingWindow("x", 40, widthOf, true);
|
|
300
|
+
assert.equal(line, `${ITALIC_OFF}${FIRST_PREFIX}${"\u001b[3m"}x`);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("keeps the content budget narrower by the label width", () => {
|
|
304
|
+
const text = "abcdefghij";
|
|
305
|
+
// 标签 `Think: ` = 7 列,宽度 12 → 正文预算 5 → `…` + 4 个字符
|
|
306
|
+
assert.equal(render(text, 12), `${FIRST_PREFIX}…ghij`);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("drops the label instead of overflowing when the terminal is too narrow for it", () => {
|
|
310
|
+
// 宽度 8 < 标签 7 列 + `…` + 1 列正文:丢掉标签,正文独占整行
|
|
311
|
+
const line = render("abcdefghij", 8);
|
|
312
|
+
assert.equal(line, `${ELLIPSIS}defghij`);
|
|
313
|
+
assert.ok(widthOf(line) <= 8);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("renders no bar gutter", () => {
|
|
317
|
+
const line = render("x".repeat(60) + "\n\nshort", 40);
|
|
318
|
+
assert.ok(line.startsWith(HEADER_LABEL), `标签必须顶格:${JSON.stringify(line)}`);
|
|
319
|
+
assert.ok(!line.includes("│"), `不该有竖线:${JSON.stringify(line)}`);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* window.ts — thinking 单行连续滚动窗口的纯逻辑。
|
|
3
|
+
*
|
|
4
|
+
* 从 `thinking-collapse.ts` 抽出来(那个文件到处 import pi,`node --test` import 不进来):
|
|
5
|
+
* 本模块**不 import pi / pi-tui**,可见宽度函数 `widthOf` 由调用方注入(运行时传 pi-tui 的
|
|
6
|
+
* `visibleWidth`,与渲染器同一套实现,量出来的宽度与 pi 实际折行用的宽度严格一致),
|
|
7
|
+
* 所以能用 `node --test` 直接跑:
|
|
8
|
+
*
|
|
9
|
+
* node --test clients/pi/extensions/thinking-collapse/window.test.ts
|
|
10
|
+
*
|
|
11
|
+
* 目录与 `thinking-collapse.ts` **同名且没有 index.ts**:pi 的扩展加载器只认
|
|
12
|
+
* `extensions/<name>.ts` 与 `extensions/<name>/index.ts`(`loader.js` 的
|
|
13
|
+
* `discoverExtensionsInDir` → `resolveExtensionEntries` 找不到入口就整目录跳过),
|
|
14
|
+
* 所以这个目录不会被当成扩展加载(与 `tool-diff/` 同一套做法)。
|
|
15
|
+
*
|
|
16
|
+
* ## 显示形态:一条不间断滚动的行
|
|
17
|
+
*
|
|
18
|
+
* 整个 thinking 块**永远只渲染成一行**,所有 token 在这一行里连续滚动:
|
|
19
|
+
*
|
|
20
|
+
* Think: …最新写下的 token 一直追加在行尾
|
|
21
|
+
*
|
|
22
|
+
* - **不分段、不换行**:thinking 里所有的换行(模型自己折的行、空行分段、列表项之间、
|
|
23
|
+
* 代码围栏内)全部拼进这**同一条**行 —— 上一段结束后,下一段直接接续在上一段的结尾,
|
|
24
|
+
* 不另起一行。于是 Think 区域呈现的是「一条不间断的 token 流」,而不是逐段刷新的多行。
|
|
25
|
+
* - 行首带 `Think: ` 标签(7 列,从第 0 列起),标签属于「块首」,始终钉在这一行最前面。
|
|
26
|
+
* - 拼接处补什么按两侧字符决定(见 `joinSeparator`),并且**区分两种接缝**:
|
|
27
|
+
* - **段内换行**(模型自己折的行、列表项之间):中文之间不补、英文单词之间补一个空格;
|
|
28
|
+
* - **段落接缝**(原文的空行处):中文 ↔ 中文额外补一个**逗号** `,`,免得两段无缝粘连
|
|
29
|
+
* 读起来像一句话(`第一段结尾,第二段开头`)。上一段末尾本来就有标点(。,:等)时
|
|
30
|
+
* 不重复补;英文 / 中英混排仍按空格规则。
|
|
31
|
+
* - 整行超过预算时**从头部**丢掉溢出字符并在行首补一个 `…`:内容越写越长,前面的字符一路
|
|
32
|
+
* 向前挤出视口,行尾永远是最新写下的 token。放得下就全量显示,不补 `…`。
|
|
33
|
+
*
|
|
34
|
+
* 这里**没有**「短段回填补满整行」的逻辑:曾经有过(最后一段太短时把被顶出去的开头拉回来
|
|
35
|
+
* 拼满行),但它会在流式过程中不断改变整行构成、打断「token 持续流动」的观感,已移除。
|
|
36
|
+
* 现在短 thinking 就是短,行尾留白,不补。
|
|
37
|
+
*
|
|
38
|
+
* ## 硬性不折行(本扩展的核心约束,别拆)
|
|
39
|
+
*
|
|
40
|
+
* 出口行必须**恰好是一行**:thinking 不会被仔细阅读,格式整洁比内容完整更重要,
|
|
41
|
+
* 所以宁可截掉字符也不允许 pi 二次折行。而 pi 会把本扩展的返回值再交给 marked 解析一次,
|
|
42
|
+
* 于是唯一能把两条视觉行**合并**成一条超宽行的是行内 code span:marked 的 codespan
|
|
43
|
+
* tokenizer 会把 code span 里的换行**换成空格**(`rules.inline.code` + `newLineCharGlobal`,
|
|
44
|
+
* 实测 `` `a\nb` `` → text `"a b"`),合并出来的超宽行再被 pi 按 contentWidth 折一次,
|
|
45
|
+
* 折出来的续行没有标签、看上去就是凭空多出来的一行 —— 就是屏幕上看到的「折行」。所以出口行里的反引号必须
|
|
46
|
+
* **自身配对**:`stripUnpairedBackticks` 按 marked 的判定规则(`findCodeSpanEnd`)把配不上
|
|
47
|
+
* 对的反引号整段删掉。头部截断会切在 code span 中间、留下一串落单的闭合反引号,正是同一个 bug。
|
|
48
|
+
* 出口处还有一道硬截断兜底(`truncateTail`),保证任何情况下都不超宽。
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/** 可见宽度函数:与 pi-tui `visibleWidth` 同语义(剥 ANSI、宽字符按 2 列)。 */
|
|
52
|
+
export type WidthFn = (text: string) => number;
|
|
53
|
+
|
|
54
|
+
/** 首行标签:`Think: `(占 7 列,从第 0 列开始)。 */
|
|
55
|
+
export const HEADER_LABEL = "Think: ";
|
|
56
|
+
|
|
57
|
+
/** 省略号(1 列):放在被头部截断的那一行最前面,表示左边还有被挤掉的内容。 */
|
|
58
|
+
export const ELLIPSIS = "…";
|
|
59
|
+
|
|
60
|
+
/** 斜体关 / 开。标签要正常字形、正文要斜体,所以标签前后夹一对 SGR。 */
|
|
61
|
+
export const ITALIC_OFF = "\u001b[23m";
|
|
62
|
+
export const ITALIC_ON = "\u001b[3m";
|
|
63
|
+
|
|
64
|
+
/** 内容预算的下限(列)。终端窄到装不下标签时至少还能放一个 `…`。 */
|
|
65
|
+
const MIN_CONTENT_WIDTH = 1;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 段落接缝(原文空行处)中文 ↔ 中文补的标点:逗号。
|
|
69
|
+
*
|
|
70
|
+
* 选逗号而不是句号:thinking 的段落之间多是「接着想」而不是「说完了」,句号会把语气断得太死。
|
|
71
|
+
* 只在**段落**接缝补,段内折行仍按原规则(中文之间不补),否则模型自己折的每一行都会多一个逗号。
|
|
72
|
+
*/
|
|
73
|
+
export const PARAGRAPH_JOIN_PUNCT = ",";
|
|
74
|
+
|
|
75
|
+
/** 可用宽度缺失 / 不合理时的兜底(列)。 */
|
|
76
|
+
const DEFAULT_WIDTH = 80;
|
|
77
|
+
|
|
78
|
+
/** 所有当换行处理的字符:LF / CR / CRLF / NEL / VT / FF / 行分隔符 / 段分隔符。 */
|
|
79
|
+
const LINE_BREAK_RE = /\r\n|[\n\r\u0085\u000b\u000c\u2028\u2029]/;
|
|
80
|
+
|
|
81
|
+
/** 行内 ≥2 个连续空格折叠成 1 个(单个空格保留:单词之间那一个空格是有意义的)。 */
|
|
82
|
+
const SPACE_RUN_RE = / {2,}/g;
|
|
83
|
+
|
|
84
|
+
/** pi 在解析 markdown 前会把 tab 换成 3 个空格,这里先做同样的归一,宽度才算得准。 */
|
|
85
|
+
export function normalizeTabs(text: string): string {
|
|
86
|
+
return text.replace(/\t/g, " ");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 宽字符(终端占 2 列):CJK 表意文字、假名、谚文、全角形式等。
|
|
91
|
+
* 与 `joinSeparator` 的「两侧都是宽字符就不补空格」配套 —— 口径只需覆盖「中日韩正文」,
|
|
92
|
+
* 不必与 wcwidth 表逐格一致(差几格只影响要不要补一个空格)。
|
|
93
|
+
*/
|
|
94
|
+
function isWideChar(ch: string): boolean {
|
|
95
|
+
const c = ch.codePointAt(0) ?? 0;
|
|
96
|
+
return (
|
|
97
|
+
(c >= 0x1100 && c <= 0x115f) ||
|
|
98
|
+
(c >= 0x2e80 && c <= 0xa4cf) || // 含 CJK 标点 0x3000-0x303f
|
|
99
|
+
(c >= 0xac00 && c <= 0xd7a3) ||
|
|
100
|
+
(c >= 0xf900 && c <= 0xfaff) ||
|
|
101
|
+
(c >= 0xfe30 && c <= 0xfe6f) ||
|
|
102
|
+
(c >= 0xff00 && c <= 0xff60) || // 全角形式(含全角标点)
|
|
103
|
+
(c >= 0xffe0 && c <= 0xffe6)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* CJK 标点(,。、;:?!「」『』《》【】… 及全角/半角形式):两侧都不吃空格。
|
|
109
|
+
* 全角区里要排掉字母与数字(`ABC123` 是正文,不是标点)。
|
|
110
|
+
*/
|
|
111
|
+
function isCjkPunct(ch: string): boolean {
|
|
112
|
+
const c = ch.codePointAt(0) ?? 0;
|
|
113
|
+
if (c >= 0x3000 && c <= 0x303f) return true; // CJK 符号与标点
|
|
114
|
+
if (c >= 0xfe30 && c <= 0xfe6f) return true; // 竖排/小型形式
|
|
115
|
+
if (c >= 0xff61 && c <= 0xff65) return true; // 半角片假名标点 。「」、・
|
|
116
|
+
if (c < 0xff01 || c > 0xff60) return false; // 全角形式区
|
|
117
|
+
const digit = c >= 0xff10 && c <= 0xff19;
|
|
118
|
+
const upper = c >= 0xff21 && c <= 0xff3a;
|
|
119
|
+
const lower = c >= 0xff41 && c <= 0xff5a;
|
|
120
|
+
return !digit && !upper && !lower;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 行尾是这些字符时不补空格:开括号 / 开引号 / 反引号 / 破折号省略号一类。 */
|
|
124
|
+
const NO_SPACE_AFTER = new Set(["(", "[", "{", "<", '"', "'", "`", "…", "—", "–", "·", "$"]);
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 行首是这些字符时不补空格:闭括号 / 收尾标点 / 引号 / 路径与扩展名分隔符。
|
|
128
|
+
* 刻意**不含** `-` 与 `_` —— 它们开头的行更可能是 `--verbose` 这类参数或普通正文,
|
|
129
|
+
* 不补空格会和上一行粘成一个词。
|
|
130
|
+
*/
|
|
131
|
+
const NO_SPACE_BEFORE = new Set([")", "]", "}", ">", ",", ".", ";", ":", "!", "?", "%", '"', "'", "`", "…"]);
|
|
132
|
+
|
|
133
|
+
/** 取字符串末尾一个码点(不切半个代理对)。 */
|
|
134
|
+
function lastCodePoint(text: string): string {
|
|
135
|
+
if (text === "") return "";
|
|
136
|
+
const end = text.length;
|
|
137
|
+
const start = prevCodePointStart(text, end);
|
|
138
|
+
return text.slice(start, end);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 取字符串开头一个码点。 */
|
|
142
|
+
function firstCodePoint(text: string): string {
|
|
143
|
+
if (text === "") return "";
|
|
144
|
+
const first = text.charCodeAt(0);
|
|
145
|
+
if (first >= 0xd800 && first <= 0xdbff && text.length >= 2) return text.slice(0, 2);
|
|
146
|
+
return text.slice(0, 1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 从 end 往前退一个码点,返回它的起始下标(代理对整体退,不切半个)。 */
|
|
150
|
+
function prevCodePointStart(text: string, end: number): number {
|
|
151
|
+
if (end <= 1) return 0;
|
|
152
|
+
const low = text.charCodeAt(end - 1);
|
|
153
|
+
if (low >= 0xdc00 && low <= 0xdfff && end >= 2) {
|
|
154
|
+
const high = text.charCodeAt(end - 2);
|
|
155
|
+
if (high >= 0xd800 && high <= 0xdbff) return end - 2;
|
|
156
|
+
}
|
|
157
|
+
return end - 1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 两行相接处该填什么。目标是读起来像原句:中文折行处不留空格,英文单词之间留一个,
|
|
162
|
+
* 标点按它自己的排版习惯(ASCII 逗号后有空格、闭括号前没有)。
|
|
163
|
+
*
|
|
164
|
+
* `paragraphBreak = true` 表示这是**段落接缝**(原文空行处)而不是段内折行:此时中文 ↔ 中文
|
|
165
|
+
* 补一个逗号,把「两段」在视觉上分开(否则无缝粘连读起来像一句话)。上一段末尾已经有标点
|
|
166
|
+
* (。,:等 CJK 标点,或 ASCII 的收尾标点)就不重复补 —— 那个标点本身已经把段落断开了。
|
|
167
|
+
*/
|
|
168
|
+
function joinSeparator(prevEnd: string, nextStart: string, paragraphBreak = false): string {
|
|
169
|
+
if (prevEnd === "" || nextStart === "") return "";
|
|
170
|
+
if (isCjkPunct(prevEnd) || isCjkPunct(nextStart)) return "";
|
|
171
|
+
if (paragraphBreak && isWideChar(prevEnd) && isWideChar(nextStart)) return PARAGRAPH_JOIN_PUNCT;
|
|
172
|
+
if (isWideChar(prevEnd) && isWideChar(nextStart)) return "";
|
|
173
|
+
if (NO_SPACE_AFTER.has(prevEnd)) return "";
|
|
174
|
+
if (NO_SPACE_BEFORE.has(nextStart)) return "";
|
|
175
|
+
return " ";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 把 thinking 的**所有源行**拼成一条连续的行(缩进去掉、空格串折叠、接缝按两侧字符与
|
|
180
|
+
* 「是否段落接缝」决定补什么)。空行(段落分隔)不产生换行,只把下一次拼接标记成段落接缝 ——
|
|
181
|
+
* 于是上一段的结尾直接接续下一段的开头,中文之间补一个逗号。这正是「一条不间断 token 流」
|
|
182
|
+
* 的拼接层。入参是 `body.split(LINE_BREAK_RE)` 的原始结果(**含**空行条目,它们就是段落标记)。
|
|
183
|
+
*/
|
|
184
|
+
export function joinAllLines(lines: readonly string[]): string {
|
|
185
|
+
let out = "";
|
|
186
|
+
let paragraphBreak = false;
|
|
187
|
+
for (const line of lines) {
|
|
188
|
+
const collapsed = line.trim().replace(SPACE_RUN_RE, " ");
|
|
189
|
+
if (collapsed === "") {
|
|
190
|
+
// 空行 = 段落分隔。只有已经攒下内容时才有意义(开头的空行不产生接缝)。
|
|
191
|
+
if (out !== "") paragraphBreak = true;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (out === "") {
|
|
195
|
+
out = collapsed;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
out += joinSeparator(lastCodePoint(out), firstCodePoint(collapsed), paragraphBreak) + collapsed;
|
|
199
|
+
paragraphBreak = false;
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 找出从 start 开始的行内 code span 的结束位置(exclusive),配不上返回 -1。
|
|
206
|
+
*
|
|
207
|
+
* 判定规则对齐 marked 的 codespan tokenizer(`/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/`):
|
|
208
|
+
* 开合反引号数量必须相等、闭合串后面不能再紧跟反引号、内容首字符不能是反引号。
|
|
209
|
+
* 只有和 marked 判定一致,「我这边的配对」才等于「marked 眼里的 code span」,
|
|
210
|
+
* 删掉落单反引号之后才不会再出现跨行 code span。
|
|
211
|
+
*/
|
|
212
|
+
export function findCodeSpanEnd(text: string, start: number): number {
|
|
213
|
+
let openerLength = 0;
|
|
214
|
+
while (text[start + openerLength] === "`") openerLength++;
|
|
215
|
+
const contentStart = start + openerLength;
|
|
216
|
+
let i = contentStart;
|
|
217
|
+
while (i < text.length) {
|
|
218
|
+
if (text[i] !== "`") {
|
|
219
|
+
i++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
let closerLength = 0;
|
|
223
|
+
while (text[i + closerLength] === "`") closerLength++;
|
|
224
|
+
// 内容末字符必然不是反引号(我们只在反引号处停下),所以只需检查首字符。
|
|
225
|
+
const content = text.slice(contentStart, i);
|
|
226
|
+
if (closerLength === openerLength && text[i + closerLength] !== "`" && content[0] !== "`") {
|
|
227
|
+
return i + closerLength;
|
|
228
|
+
}
|
|
229
|
+
i += closerLength;
|
|
230
|
+
}
|
|
231
|
+
return -1;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 删掉配不上对的反引号(连着一整串一起删),配对的 code span 原样保留。
|
|
236
|
+
*
|
|
237
|
+
* 为什么必须删:marked 是在**整条 markdown** 上做 code span 配对的,一条出口行里落单的
|
|
238
|
+
* 反引号会跟另一条出口行的反引号配成跨行 code span,那个 token 内部的新行会被换成空格,
|
|
239
|
+
* 两行于是被并成一条超宽行、被 pi 再折一次(折出来的续行没有标签)。
|
|
240
|
+
* marked 对落单反引号本来就当字面量,删掉它只是把「会显示成裸反引号」变成「不显示」,
|
|
241
|
+
* 而这一行的内容本来就已经被头部截断过,代价可以忽略。
|
|
242
|
+
*/
|
|
243
|
+
export function stripUnpairedBackticks(text: string): string {
|
|
244
|
+
if (!text.includes("`")) return text;
|
|
245
|
+
let out = "";
|
|
246
|
+
let changed = false;
|
|
247
|
+
let i = 0;
|
|
248
|
+
while (i < text.length) {
|
|
249
|
+
if (text[i] === "`") {
|
|
250
|
+
const end = findCodeSpanEnd(text, i);
|
|
251
|
+
if (end > i) {
|
|
252
|
+
out += text.slice(i, end);
|
|
253
|
+
i = end;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
let run = i;
|
|
257
|
+
while (text[run] === "`") run++;
|
|
258
|
+
i = run;
|
|
259
|
+
changed = true;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
out += text[i];
|
|
263
|
+
i++;
|
|
264
|
+
}
|
|
265
|
+
return changed ? out : text;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 从**头部**截断到 maxWidth 列:保留尾部,前面用一个 `…` 顶替被丢掉的字符。
|
|
270
|
+
*
|
|
271
|
+
* 按**码点**从后往前量(不切半个代理对);宽度为 0 的字符(组合符 / 零宽)不占预算,
|
|
272
|
+
* 跟着前面那个字符一起带上。宽字符跨在边界上时宁可少一列也不超宽。
|
|
273
|
+
*/
|
|
274
|
+
export function clipToTail(text: string, maxWidth: number, widthOf: WidthFn, ellipsis = ELLIPSIS): string {
|
|
275
|
+
if (maxWidth <= 0) return "";
|
|
276
|
+
if (widthOf(text) <= maxWidth) return text;
|
|
277
|
+
const budget = maxWidth - widthOf(ellipsis);
|
|
278
|
+
if (budget <= 0) return "";
|
|
279
|
+
let used = 0;
|
|
280
|
+
let i = text.length;
|
|
281
|
+
while (i > 0) {
|
|
282
|
+
const start = prevCodePointStart(text, i);
|
|
283
|
+
const width = widthOf(text.slice(start, i));
|
|
284
|
+
if (width > 0 && used + width > budget) break;
|
|
285
|
+
used += width;
|
|
286
|
+
i = start;
|
|
287
|
+
}
|
|
288
|
+
return ellipsis + text.slice(i);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 从**尾部**截断到 maxWidth 列(保留头部,截掉的部分换成一个 `…`)。出口兜底截断用。
|
|
293
|
+
*
|
|
294
|
+
* 截断点正好落在空白上时那个空白一并去掉:`the …` 里的空格只是被切掉一半的空白,
|
|
295
|
+
* 不表达任何信息,去掉后这一行可能比预算少 1 列(肉眼看不出来)。
|
|
296
|
+
*/
|
|
297
|
+
export function truncateTail(text: string, maxWidth: number, widthOf: WidthFn, ellipsis = ELLIPSIS): string {
|
|
298
|
+
if (maxWidth <= 0) return "";
|
|
299
|
+
if (widthOf(text) <= maxWidth) return text;
|
|
300
|
+
const budget = maxWidth - widthOf(ellipsis);
|
|
301
|
+
if (budget <= 0) return "";
|
|
302
|
+
let out = "";
|
|
303
|
+
let used = 0;
|
|
304
|
+
for (const ch of text) {
|
|
305
|
+
const width = widthOf(ch);
|
|
306
|
+
if (used + width > budget) break;
|
|
307
|
+
out += ch;
|
|
308
|
+
used += width;
|
|
309
|
+
}
|
|
310
|
+
return out.replace(/\s+$/, "") + ellipsis;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 把 thinking 正文压成**一条连续滚动的行**。导出供测试直接调用。
|
|
315
|
+
*
|
|
316
|
+
* @param markdown thinking 块原文(pi 会把多个 thinking 块用 `\n\n` 拼起来)
|
|
317
|
+
* @param availableWidth pi 给 transform 的内容宽度(已扣掉 Markdown 组件左右各 1 列 padding)
|
|
318
|
+
* @param widthOf 可见宽度函数(运行时传 pi-tui 的 `visibleWidth`)
|
|
319
|
+
* @param ansi 是否夹斜体开关控制码
|
|
320
|
+
*/
|
|
321
|
+
export function buildThinkingWindow(
|
|
322
|
+
markdown: string,
|
|
323
|
+
availableWidth: number,
|
|
324
|
+
widthOf: WidthFn,
|
|
325
|
+
ansi = true,
|
|
326
|
+
): string {
|
|
327
|
+
const body = normalizeTabs(markdown).trim();
|
|
328
|
+
if (body === "") return markdown;
|
|
329
|
+
|
|
330
|
+
// 所有源行(含空行分段)拼成一条连续的行:上一段结尾直接接续下一段开头,不换行。
|
|
331
|
+
const line = joinAllLines(body.split(LINE_BREAK_RE));
|
|
332
|
+
if (line === "") return markdown;
|
|
333
|
+
|
|
334
|
+
const width = Math.max(8, Math.floor(availableWidth) || DEFAULT_WIDTH);
|
|
335
|
+
|
|
336
|
+
const italicOff = ansi ? ITALIC_OFF : "";
|
|
337
|
+
const italicOn = ansi ? ITALIC_ON : "";
|
|
338
|
+
// 窄到连「标签 + `…` + 至少 1 列正文」都放不下时丢掉标签:正文比标记重要,
|
|
339
|
+
// 否则标签 (7 列) 会把整行吃光、一个字都剩不下。
|
|
340
|
+
const useLabel = width >= widthOf(HEADER_LABEL) + widthOf(ELLIPSIS) + MIN_CONTENT_WIDTH;
|
|
341
|
+
const label = useLabel ? HEADER_LABEL : "";
|
|
342
|
+
const budget = Math.max(MIN_CONTENT_WIDTH, width - widthOf(label));
|
|
343
|
+
|
|
344
|
+
// 头部截断滚动:放得下就全显示,放不下就从头部丢掉溢出字符、行首补 `…`,行尾永远是最新 token。
|
|
345
|
+
let content = stripUnpairedBackticks(clipToTail(line, budget, widthOf));
|
|
346
|
+
// 兜底:任何情况下出口行都不许超宽(超了宁可截掉尾部)。
|
|
347
|
+
if (widthOf(label) + widthOf(content) > width) {
|
|
348
|
+
content = truncateTail(content, width - widthOf(label), widthOf);
|
|
349
|
+
}
|
|
350
|
+
content = stripUnpairedBackticks(content);
|
|
351
|
+
|
|
352
|
+
// 标签要正常字形(关掉 pi 给的斜体再开回来);没有标签时正文沿用斜体。
|
|
353
|
+
return label === "" ? content : italicOff + label + italicOn + content;
|
|
354
|
+
}
|