@herbertgao/pi-extensions 2026.10.3 → 2026.10.5

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.
Files changed (34) hide show
  1. package/README.md +3 -2
  2. package/THIRD_PARTY_NOTICES.md +4 -0
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +12 -7
  4. package/node_modules/@herbertgao/pi-cc-extensions/README.md +12 -7
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +67 -16
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +240 -48
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer-currency.ts +29 -0
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer.ts +92 -59
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/git-stats.ts +162 -0
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/token-rate.ts +192 -0
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts +51 -65
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +113 -25
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +160 -24
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +30 -1
  15. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +26 -24
  16. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/sticky-prompt.ts +402 -0
  17. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/ask-user-question.ts +173 -0
  18. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/codemode.ts +4 -8
  19. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-edit-render.ts +64 -11
  20. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-presentation.ts +41 -0
  21. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +5 -1
  22. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-write-render.ts +35 -9
  23. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +101 -31
  24. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/names.ts +3 -3
  25. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +106 -15
  26. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/format.ts +9 -0
  27. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/patch-keys.ts +5 -0
  28. package/node_modules/@herbertgao/pi-cc-extensions/package.json +4 -4
  29. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-dark.json +1 -1
  30. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-light.json +1 -1
  31. package/node_modules/pi-provider-qoder/README.md +140 -0
  32. package/node_modules/pi-provider-qoder/dist/index.js +10249 -0
  33. package/node_modules/pi-provider-qoder/package.json +57 -0
  34. package/package.json +5 -2
@@ -0,0 +1,402 @@
1
+ /**
2
+ * fullscreen 粘性用户消息头。
3
+ *
4
+ * userMessage 的正文首行滚出视口上沿后,把该行钉在视口上沿(单行省略),一直保留到
5
+ * 下一条用户消息自己滚出上沿;单击粘性行跳回该消息正文开头。触发只看位置,不看消息长短。
6
+ * regular 由终端回滚拥有,没有应用自有视口,不参与。
7
+ *
8
+ * 两个补丁:
9
+ * - UserMessageComponent.render 注入零宽标记(\x1b_cc:p / \x1b_cc:P),给出消息行范围
10
+ * 的精确边界;只在 fullscreen 注入,regular 没有消费方、也不该把标记写进终端。
11
+ * - TuiAltScreen.compositeScrollToEndIndicator 包装:剥离标记,并在视口首行合成粘性行。
12
+ */
13
+ import {
14
+ compositeTuiLine,
15
+ stripTerminalSequences,
16
+ truncateToWidth,
17
+ TuiAltScreen,
18
+ visibleWidth,
19
+ } from "@earendil-works/pi-tui";
20
+ import { UserMessageComponent } from "@earendil-works/pi-coding-agent";
21
+ import { config } from "../../config/config.ts";
22
+ import {
23
+ patchRegistry,
24
+ STICKY_PROMPT_FRAME_PATCH,
25
+ STICKY_PROMPT_MARKER_PATCH,
26
+ STICKY_PROMPT_STATE_SLOT,
27
+ } from "../../utils/patch-keys.ts";
28
+ import { isToolTuiFullscreen } from "../tool/show-more-hint.ts";
29
+ import type { SgrMousePacket } from "./packets.ts";
30
+ import { setScrollButtonVisible } from "./scroll.ts";
31
+
32
+ /** 消息正文首行/末行的零宽 APC 标记;终端忽略,合成前剥离。 */
33
+ const MESSAGE_START = "\x1b_cc:p\x07";
34
+ const MESSAGE_END = "\x1b_cc:P\x07";
35
+ const MESSAGE_MARKER_RE = /\x1b_cc:[pP]\x07/g;
36
+ /** kitty/iTerm2 图片行:合成会破坏图片数据,整行放行。 */
37
+ const KITTY_IMAGE = "\x1b_G";
38
+ const ITERM2_IMAGE = "\x1b]1337;File=";
39
+
40
+ const STICKY_LEAD = " ";
41
+ const STICKY_HINT_TEXT = " [click to jump]";
42
+ /** 视口宽度低于该值时不画点击提示,先保证正文省得下。 */
43
+ const STICKY_HINT_MIN_WIDTH = 24;
44
+
45
+ type StickyPromptState = {
46
+ /** 最近一帧的布局对象:交互前与 tui.currentLayout 恒等比较,避免拿上一帧的行号动手。 */
47
+ layout: any;
48
+ visible: boolean;
49
+ hovered: boolean;
50
+ /** 粘性行所在屏幕行,与 bar 的起始列/可用宽度,来自最近一帧。 */
51
+ row: number;
52
+ x: number;
53
+ width: number;
54
+ /** 正文首行的内容行号。 */
55
+ jumpRow: number;
56
+ text: string;
57
+ theme: any;
58
+ };
59
+
60
+ function stickyState(): StickyPromptState {
61
+ return patchRegistry.ensure(STICKY_PROMPT_STATE_SLOT, () => ({
62
+ layout: null,
63
+ visible: false,
64
+ hovered: false,
65
+ row: -1,
66
+ x: 0,
67
+ width: 0,
68
+ jumpRow: 0,
69
+ text: "",
70
+ theme: null,
71
+ }));
72
+ }
73
+
74
+ export function setStickyPromptTheme(theme: any): void {
75
+ stickyState().theme = theme;
76
+ }
77
+
78
+ export function resetStickyPromptState(): void {
79
+ const state = stickyState();
80
+ state.layout = null;
81
+ state.visible = false;
82
+ state.hovered = false;
83
+ }
84
+
85
+ /** 功能开关:仅 fullscreen 生效,off 模式还原原生渲染。 */
86
+ function stickyPromptActive(): boolean {
87
+ return config.stickyUserMessage && config.mode !== "off";
88
+ }
89
+
90
+ function setStickyHovered(hovered: boolean): boolean {
91
+ const state = stickyState();
92
+ if (state.hovered === hovered) return false;
93
+ state.hovered = hovered;
94
+ return true;
95
+ }
96
+
97
+ function lineText(line: string): string {
98
+ return stripTerminalSequences(line).trim();
99
+ }
100
+
101
+ function isImageLine(line: string): boolean {
102
+ return line.includes(KITTY_IMAGE) || line.includes(ITERM2_IMAGE);
103
+ }
104
+
105
+ /** 当前指令:start 是消息首行(跳转目标),textRow 是正文首行(粘性行文本)。 */
106
+ type PromptCursor = {
107
+ start: number;
108
+ textRow: number;
109
+ };
110
+
111
+ /** 消息内找正文首行的行数上限;超过说明这条消息没有正文。 */
112
+ const PROMPT_TEXT_SCAN_LIMIT = 64;
113
+
114
+ /**
115
+ * 视口上沿上方最近一条 userMessage 就是当前指令。不看 END 标记:
116
+ * 指令滚出上沿后一直钉,直到下一条指令自己滚出上沿接手。
117
+ */
118
+ function scanPrompt(lines: readonly string[], row: number): PromptCursor | null {
119
+ let start = -1;
120
+ for (let i = Math.min(row, lines.length - 1); i >= 0; i--) {
121
+ if ((lines[i] ?? "").includes(MESSAGE_START)) {
122
+ start = i;
123
+ break;
124
+ }
125
+ }
126
+ if (start < 0) return null;
127
+ const textRow = findPromptTextRow(lines, start);
128
+ return textRow < 0 ? null : { start, textRow };
129
+ }
130
+
131
+ /** 正文首行:跳过消息内的空行;以 END 标记为界,避免读到助手正文。 */
132
+ function findPromptTextRow(lines: readonly string[], start: number): number {
133
+ const end = findMessageEnd(lines, start);
134
+ if (end < 0) return -1;
135
+ for (let i = start; i <= end; i++) {
136
+ if (lineText(lines[i] ?? "") !== "") return i;
137
+ }
138
+ return -1;
139
+ }
140
+
141
+ function findMessageEnd(lines: readonly string[], start: number): number {
142
+ const limit = Math.min(lines.length - 1, start + PROMPT_TEXT_SCAN_LIMIT);
143
+ for (let i = start; i <= limit; i++) {
144
+ if ((lines[i] ?? "").includes(MESSAGE_END)) return i;
145
+ }
146
+ return -1;
147
+ }
148
+
149
+ /** 主滚动视图的布局盒:优先按 scrollView 引用,退化到第一个有内容行的 scroll 盒。 */
150
+ function findScrollBox(box: any, scrollView: any): any {
151
+ let fallback: any = null;
152
+ const visit = (node: any): any => {
153
+ if (!node) return null;
154
+ if (scrollView && node.scrollView === scrollView) return node;
155
+ if (!fallback && Array.isArray(node.scrollContentLines) && node.scrollContentLines.length > 0) {
156
+ fallback = node;
157
+ }
158
+ for (const child of node.children ?? []) {
159
+ const hit = visit(child);
160
+ if (hit) return hit;
161
+ }
162
+ return null;
163
+ };
164
+ return visit(box) ?? fallback;
165
+ }
166
+
167
+ export type StickyPromptInfo = {
168
+ row: number;
169
+ x: number;
170
+ width: number;
171
+ jumpRow: number;
172
+ text: string;
173
+ };
174
+
175
+ /** 视口上沿落在 userMessage 之下时,返回粘性行的位置与文本。 */
176
+ export function computeStickyPrompt(tui: any, layout: any): StickyPromptInfo | null {
177
+ if (!layout?.root) return null;
178
+ const scrollView = layout.primaryScrollView ?? tui?.getPrimaryScrollView?.();
179
+ const box = findScrollBox(layout.root, scrollView);
180
+ const lines = box?.scrollContentLines as readonly string[] | undefined;
181
+ const clip = box?.clip;
182
+ if (!box || !Array.isArray(lines) || !clip || clip.width <= 0 || clip.height <= 0) return null;
183
+ const scrollTop = Math.max(0, Math.floor(Number(box.scrollView?.scrollTop ?? 0)));
184
+ if (scrollTop <= 0) return null;
185
+ const cursor = scanPrompt(lines, scrollTop);
186
+ if (!cursor) return null;
187
+ // 正文首行还在视口内时消息本就在场,不需要钉。
188
+ if (cursor.textRow >= scrollTop) return null;
189
+ const scrollbar = box.scrollView?.isScrollbarVisible === true && clip.width > 1;
190
+ return {
191
+ row: clip.y,
192
+ x: clip.x,
193
+ width: scrollbar ? clip.width - 1 : clip.width,
194
+ // 跳消息首行而不是正文首行:原生消息块自带上下村白,跳正文会把顶边切掉。
195
+ jumpRow: cursor.start,
196
+ text: lineText(lines[cursor.textRow] ?? ""),
197
+ };
198
+ }
199
+
200
+ /**
201
+ * 整行铺满可用宽度的用户消息底色条:正文 + 点击提示(首列留一个空格与消息正文对齐)。
202
+ * truncateToWidth 会在截断处插入样式重置,必须在包底色前剥掉,否则省略号与提示会
203
+ * 脱出底色,按终端默认色显示。
204
+ */
205
+ export function stickyPromptBar(text: string, width: number, hovered: boolean, theme: any): string {
206
+ const hint = width >= STICKY_HINT_MIN_WIDTH ? STICKY_HINT_TEXT : "";
207
+ const room = Math.max(0, width - visibleWidth(STICKY_LEAD) - visibleWidth(hint));
208
+ const body = stripTerminalSequences(truncateToWidth(text, room, "…"));
209
+ const pad = " ".repeat(
210
+ Math.max(0, width - visibleWidth(STICKY_LEAD) - visibleWidth(body) - visibleWidth(hint)),
211
+ );
212
+ const plain = STICKY_LEAD + body + pad + hint;
213
+ try {
214
+ if (typeof theme?.fg !== "function" || typeof theme?.bg !== "function") return plain;
215
+ return theme.bg("userMessageBg", theme.fg(hovered ? "text" : "dim", plain));
216
+ } catch {
217
+ // 主题槽位缺失时退回无样式,不打断整帧渲染。
218
+ return plain;
219
+ }
220
+ }
221
+
222
+ /** 屏幕行里的消息标记必须在写往终端前剥离。 */
223
+ function stripMessageMarkers(lines: string[]): string[] {
224
+ const dirty = lines.some(
225
+ (line) =>
226
+ typeof line === "string" && (line.includes(MESSAGE_START) || line.includes(MESSAGE_END)),
227
+ );
228
+ return dirty ? lines.map((line) => line.replace(MESSAGE_MARKER_RE, "")) : lines;
229
+ }
230
+
231
+ /**
232
+ * TuiAltScreen 帧合成钩子:先剥离标记,再在视口首行覆盖粘性行。
233
+ * 位于 diff 比较之前,因此内容滚动、hover 高亮、跳转都会正常进差分。
234
+ */
235
+ export function renderStickyPromptScreen(
236
+ tui: any,
237
+ screen: string[],
238
+ layout: any,
239
+ terminalWidth: number,
240
+ ): string[] {
241
+ const stripped = stripMessageMarkers(screen);
242
+ const state = stickyState();
243
+ const info = stickyPromptActive() ? computeStickyPrompt(tui, layout) : null;
244
+ const sameTarget = Boolean(
245
+ info && state.visible && state.row === info.row && state.jumpRow === info.jumpRow,
246
+ );
247
+ state.layout = layout;
248
+ state.visible = Boolean(info);
249
+ if (!info) {
250
+ state.hovered = false;
251
+ return stripped;
252
+ }
253
+ state.row = info.row;
254
+ state.x = info.x;
255
+ state.width = info.width;
256
+ state.jumpRow = info.jumpRow;
257
+ state.text = info.text;
258
+ if (!sameTarget) state.hovered = false;
259
+ if (info.row < 0 || info.row >= stripped.length) return stripped;
260
+ const base = stripped[info.row] ?? "";
261
+ if (isImageLine(base)) return stripped;
262
+ const bar = stickyPromptBar(info.text, info.width, state.hovered, state.theme);
263
+ const next = [...stripped];
264
+ next[info.row] = compositeTuiLine(base, bar, info.x, info.width, terminalWidth);
265
+ return next;
266
+ }
267
+
268
+ /**
269
+ * 指针是否落在粘性行上。返回 true 时调用方不再做工具 hover,避免同一行双重高亮;
270
+ * hover 状态变化自行请求重绘(它不在 hover.ts 的状态里)。
271
+ */
272
+ function stickyBarHit(tui: any, packet: SgrMousePacket): boolean {
273
+ const state = stickyState();
274
+ if (!stickyPromptActive() || !state.visible || state.layout !== tui?.currentLayout) return false;
275
+ if (packet.row - 1 !== state.row) return false;
276
+ const col = packet.col - 1;
277
+ return col >= state.x && col < state.x + state.width;
278
+ }
279
+
280
+ export function updateStickyPromptHover(tui: any, packet: SgrMousePacket): boolean {
281
+ const onBar = stickyBarHit(tui, packet);
282
+ if (setStickyHovered(onBar)) tui.requestRender?.();
283
+ return onBar;
284
+ }
285
+
286
+ /** 单击粘性行:视口滚到该消息首行(整块可见),并亮出回到底部按钮。 */
287
+ export function handleStickyPromptClick(tui: any, packet: SgrMousePacket): boolean {
288
+ const state = stickyState();
289
+ if (!stickyBarHit(tui, packet)) return false;
290
+ const view = tui.getPrimaryScrollView?.();
291
+ if (typeof view?.scrollTo === "function") {
292
+ view.scrollTo(state.jumpRow, { disableFollow: true });
293
+ if (view.isFollowingEnd === false) setScrollButtonVisible(true);
294
+ }
295
+ // 落点已到消息头,这一帧的粘性行立即失效;等下一帧按新位置重算。
296
+ state.visible = false;
297
+ state.hovered = false;
298
+ tui.requestRender?.();
299
+ return true;
300
+ }
301
+
302
+ type StickyPatch = {
303
+ active: boolean;
304
+ original: (...args: any[]) => any;
305
+ installed: (...args: any[]) => any;
306
+ };
307
+
308
+ /**
309
+ * 本模块实例安装的两个补丁。dispose 只能碰自己装的那一个:
310
+ * /reload 时新实例可能已经接手(旧 shutdown 后到),按注册表取会把新补丁停掉。
311
+ */
312
+ let markerPatch: StickyPatch | null = null;
313
+ let framePatch: StickyPatch | null = null;
314
+
315
+ /** UserMessageComponent.render 注入零宽标记(仅 fullscreen)。 */
316
+ export function installStickyPromptMarkerPatch(): void {
317
+ const prototype = (UserMessageComponent as any)?.prototype;
318
+ if (typeof prototype?.render !== "function") return;
319
+ const previous = patchRegistry.get<StickyPatch>(STICKY_PROMPT_MARKER_PATCH);
320
+ if (previous) previous.active = false;
321
+ const original =
322
+ previous && prototype.render === previous.installed ? previous.original : prototype.render;
323
+ const patch: StickyPatch = {
324
+ active: true,
325
+ original,
326
+ installed(this: any, width: number) {
327
+ const lines = original.call(this, width);
328
+ if (!patch.active || !isToolTuiFullscreen()) return lines;
329
+ if (!Array.isArray(lines) || lines.length === 0) return lines;
330
+ // 接在行尾:行首的 OSC133 前缀保持在下游的可剥离位置。
331
+ lines[0] += MESSAGE_START;
332
+ lines[lines.length - 1] += MESSAGE_END;
333
+ return lines;
334
+ },
335
+ };
336
+ prototype.render = patch.installed;
337
+ patchRegistry.install(STICKY_PROMPT_MARKER_PATCH, patch);
338
+ markerPatch = patch;
339
+ }
340
+
341
+ /** TuiAltScreen.compositeScrollToEndIndicator 包装:合成粘性行。返回是否安装成功。 */
342
+ export function installStickyPromptFramePatch(): boolean {
343
+ const prototype = (TuiAltScreen as any)?.prototype;
344
+ if (typeof prototype?.compositeScrollToEndIndicator !== "function") return false;
345
+ const previous = patchRegistry.get<StickyPatch>(STICKY_PROMPT_FRAME_PATCH);
346
+ if (previous) previous.active = false;
347
+ const original =
348
+ previous && prototype.compositeScrollToEndIndicator === previous.installed
349
+ ? previous.original
350
+ : prototype.compositeScrollToEndIndicator;
351
+ const patch: StickyPatch = {
352
+ active: true,
353
+ original,
354
+ installed(this: any, screen: string[], layout: any, width: number) {
355
+ const result = original.call(this, screen, layout, width);
356
+ if (!patch.active) return result;
357
+ return renderStickyPromptScreen(this, result, layout, width);
358
+ },
359
+ };
360
+ prototype.compositeScrollToEndIndicator = patch.installed;
361
+ patchRegistry.install(STICKY_PROMPT_FRAME_PATCH, patch);
362
+ framePatch = patch;
363
+ return true;
364
+ }
365
+
366
+ export function installStickyPromptPatches(): void {
367
+ // 标记必须有消费方:帧合成补丁装不上(上游改名)时不注入,
368
+ // 否则零宽标记会随帧写进终端。
369
+ if (installStickyPromptFramePatch()) installStickyPromptMarkerPatch();
370
+ }
371
+
372
+ export function disposeStickyPromptPatches(): void {
373
+ disposeStickyPatch(
374
+ STICKY_PROMPT_MARKER_PATCH,
375
+ markerPatch,
376
+ (UserMessageComponent as any)?.prototype,
377
+ "render",
378
+ );
379
+ disposeStickyPatch(
380
+ STICKY_PROMPT_FRAME_PATCH,
381
+ framePatch,
382
+ (TuiAltScreen as any)?.prototype,
383
+ "compositeScrollToEndIndicator",
384
+ );
385
+ markerPatch = null;
386
+ framePatch = null;
387
+ resetStickyPromptState();
388
+ }
389
+
390
+ /** 只停自己装的补丁;已被新实例接手时停掉包装器就够,不动原型也不动槽位。 */
391
+ function disposeStickyPatch(
392
+ key: symbol,
393
+ patch: StickyPatch | null,
394
+ prototype: any,
395
+ method: string,
396
+ ): void {
397
+ if (!patch) return;
398
+ patch.active = false;
399
+ if (!patchRegistry.owns(key, patch)) return;
400
+ if (prototype && prototype[method] === patch.installed) prototype[method] = patch.original;
401
+ patchRegistry.dispose(key, patch);
402
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * ask_user_question 的结果渲染。
3
+ *
4
+ * 这个工具来自第三方扩展,没有自己的渲染器,通用渲染只把结果收成
5
+ * 「N lines returned」,用户选了什么要展开才看得到,而且是一段给模型的英文。
6
+ * 选择在 `result.details.answers` 里,这里直接用它。
7
+ *
8
+ * 折叠态摊成树(与 codemode 子调用同一套衔接符,缩进单独对齐本工具标题的勾):
9
+ * ├ 问题1 -> 回答1
10
+ * └ 问题2(多选)-> 回答2, 回答2
11
+ * 标题行在后面追加 (+N) 报问题数。展开态按「问题 / 所选 / 备注」逐条列出。
12
+ */
13
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
14
+ import { stripAnsi } from "../../utils/ansi-text.ts";
15
+ import { oneLine } from "../../utils/format.ts";
16
+ import { showMoreHintText } from "./show-more-hint.ts";
17
+ import { headTruncateToWidth, toolViewportWidth } from "./result.ts";
18
+
19
+ export const ASK_USER_QUESTION_TOOL_NAME = "ask_user_question";
20
+
21
+ /** 折叠态正文缩进:1 格,让 ├/└ 落在标题行勾号(" ✓")的正下方。 */
22
+ const INDENT = " ";
23
+ /** 没选、取消、或答案为空时的占位。 */
24
+ const NO_ANSWER = "(no answer)";
25
+
26
+ export type AskUserAnswer = {
27
+ questionIndex?: unknown;
28
+ question?: unknown;
29
+ kind?: unknown;
30
+ answer?: unknown;
31
+ selected?: unknown;
32
+ notes?: unknown;
33
+ };
34
+
35
+ function text(value: unknown): string {
36
+ return typeof value === "string" ? value.trim() : "";
37
+ }
38
+
39
+ export function askUserAnswers(result: any): AskUserAnswer[] {
40
+ const answers = result?.details?.answers;
41
+ return Array.isArray(answers) ? answers : [];
42
+ }
43
+
44
+ /** 按问题下标排序;下标缺失的排到最后,保持原有相对顺序。 */
45
+ function orderedAnswers(result: any): AskUserAnswer[] {
46
+ return askUserAnswers(result)
47
+ .map((answer, index) => ({ answer, index }))
48
+ .sort((left, right) => {
49
+ const leftIndex = Number(left.answer.questionIndex);
50
+ const rightIndex = Number(right.answer.questionIndex);
51
+ const leftMissing = !Number.isFinite(leftIndex);
52
+ const rightMissing = !Number.isFinite(rightIndex);
53
+ if (leftMissing || rightMissing) {
54
+ if (leftMissing !== rightMissing) return leftMissing ? 1 : -1;
55
+ return left.index - right.index;
56
+ }
57
+ return leftIndex - rightIndex;
58
+ })
59
+ .map((entry) => entry.answer);
60
+ }
61
+
62
+ /** 一个问题的作答文本:多选取全部标签,其余取 answer;空则占位。 */
63
+ export function askUserAnswerText(answer: AskUserAnswer): string {
64
+ if (answer?.kind === "multi") {
65
+ const selected = Array.isArray(answer.selected)
66
+ ? answer.selected.map(text).filter(Boolean)
67
+ : [];
68
+ return selected.length > 0 ? selected.join(", ") : NO_ANSWER;
69
+ }
70
+ return text(answer?.answer) || NO_ANSWER;
71
+ }
72
+
73
+ /**
74
+ * 标题行追加的问题数。结果还没到时从参数里数,这样调用期间标题也只留工具名,
75
+ * 不会先闪出第一个问题再缩回去。
76
+ */
77
+ export function askUserQuestionCount(result: any, args?: any): number | undefined {
78
+ const answered = askUserAnswers(result).length;
79
+ if (answered > 0) return answered;
80
+ const questions = Array.isArray(args?.questions) ? args.questions.length : 0;
81
+ return questions > 0 ? questions : undefined;
82
+ }
83
+
84
+ function answerRows(result: any): { question: string; answer: string }[] {
85
+ return orderedAnswers(result).map((answer) => ({
86
+ question: text(answer?.question) || "Question",
87
+ answer: askUserAnswerText(answer),
88
+ }));
89
+ }
90
+
91
+ /**
92
+ * 折叠态的问答树。问题行一律用 ├,最后一行用 └ 收汇总,
93
+ * 展开提示只挂在收尾行上,不追加到问题行(与 codemode 子调用一致)。
94
+ * 没有作答(取消、校验失败)时退回一行摘要。
95
+ */
96
+ export function askUserCollapsedLines(options: {
97
+ result: any;
98
+ theme: any;
99
+ isError: boolean;
100
+ width: number;
101
+ hovered?: boolean;
102
+ }): string[] {
103
+ const { result, theme, isError } = options;
104
+ const fg = theme.fg.bind(theme);
105
+ const rowWidth = toolViewportWidth(options.width);
106
+ const rows = answerRows(result);
107
+ if (rows.length === 0) {
108
+ const label = result?.details?.cancelled === true ? "Declined" : "Done";
109
+ return [truncateToWidth(`${INDENT}${fg(isError ? "error" : "muted", label)}`, rowWidth, "")];
110
+ }
111
+ const lines = rows.map((row) => {
112
+ const prefix = `${INDENT}${fg("dim", "├")} `;
113
+ const separator = fg("dim", " -> ");
114
+ const fixed = visibleWidth(prefix) + visibleWidth(separator);
115
+ const share = Math.max(1, Math.floor((rowWidth - fixed) / 2));
116
+ // 问题只是标签,低一档对比度;亮色留给标题和回答。
117
+ const question = fg("muted", headTruncateToWidth(row.question, share));
118
+ const answerWidth = Math.max(1, rowWidth - fixed - visibleWidth(question));
119
+ const answer = fg(isError ? "error" : "success", headTruncateToWidth(row.answer, answerWidth));
120
+ return truncateToWidth(`${prefix}${question}${separator}${answer}`, rowWidth, "");
121
+ });
122
+ // 圆点保持 dim;hover 只高亮可点击文字。
123
+ const prefix = `${INDENT}${fg("dim", "└")} `;
124
+ const hint = ` ${fg("dim", "·")} ${fg(options.hovered ? "text" : "dim", showMoreHintText())}`;
125
+ const bodyWidth = Math.max(1, rowWidth - visibleWidth(prefix) - visibleWidth(hint));
126
+ const summary = headTruncateToWidth(`${rows.length} answered`, bodyWidth);
127
+ lines.push(
128
+ truncateToWidth(`${prefix}${fg(isError ? "error" : "muted", summary)}${hint}`, rowWidth, ""),
129
+ );
130
+ return lines;
131
+ }
132
+
133
+ /** 展开态正文:每个问题一段,所选在下一行,备注和总备注附在后面。 */
134
+ export function askUserExpandedBody(result: any): string {
135
+ const parts = orderedAnswers(result).map((answer) => {
136
+ const lines = [text(answer?.question) || "Question", ` ${askUserAnswerText(answer)}`];
137
+ const notes = text(answer?.notes);
138
+ if (notes) lines.push(` notes: ${oneLine(notes, 4_000)}`);
139
+ return lines.join("\n");
140
+ });
141
+ const globalNote = text(result?.details?.globalNote);
142
+ if (globalNote) parts.push(`Note: ${oneLine(globalNote, 4_000)}`);
143
+ if (parts.length === 0 && result?.details?.cancelled === true) return "Declined";
144
+ return parts.join("\n\n");
145
+ }
146
+
147
+ /**
148
+ * 折叠态结果组件。只有最后一行是展开入口;行本身很便宜,不自己缓存
149
+ * (settled 卡另有整行 paint 缓存)。hover 状态在 render() 内取。
150
+ */
151
+ export function createAskUserResultComponent(options: {
152
+ result: any;
153
+ theme: any;
154
+ isError: boolean;
155
+ isHovered?: () => boolean;
156
+ }): any {
157
+ const { result, theme, isError } = options;
158
+ return {
159
+ render(width: number): string[] {
160
+ return askUserCollapsedLines({
161
+ result,
162
+ theme,
163
+ isError,
164
+ width,
165
+ hovered: options.isHovered?.() ?? false,
166
+ });
167
+ },
168
+ invalidate(): void {},
169
+ isCollapsedHintLine(plainLine: string): boolean {
170
+ return /(click to show more|to show more)\s*$/.test(stripAnsi(plainLine));
171
+ },
172
+ };
173
+ }
@@ -20,14 +20,14 @@ import {
20
20
  toolCallSummary,
21
21
  } from "./names.ts";
22
22
  import { showMoreHintText } from "./show-more-hint.ts";
23
- import { headTruncateToWidth, toolViewportWidth } from "./result.ts";
23
+ import { formatToolDuration, headTruncateToWidth, toolViewportWidth } from "./result.ts";
24
24
 
25
25
  export const CODEMODE_TOOL_NAME = "codemode";
26
26
 
27
27
  /** 折叠态最多列几条子调用(与 pi 原生一致,更早的折叠成一行)。 */
28
28
  const COLLAPSED_CALL_LIMIT = 8;
29
- /** 折叠态正文缩进,与 ` ↳ …` 对齐。 */
30
- const INDENT = " ";
29
+ /** 折叠态正文缩进:1 格,让 ├/└ 落在标题行勾号(" ✓")的正下方。 */
30
+ const INDENT = " ";
31
31
 
32
32
  /** 脚本结果头;只有整块正文恰好等于它时才算头。 */
33
33
  const SCRIPT_HEADER = /^Script (completed|failed)\nWall time [\d.]+ seconds\nOutput:\n$/;
@@ -94,10 +94,6 @@ function fitSummaryParts(parts: readonly string[], width: number): string {
94
94
  return visibleWidth(text) <= width ? text : headTruncateToWidth(text, width);
95
95
  }
96
96
 
97
- function formatDuration(ms: number): string {
98
- return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
99
- }
100
-
101
97
  /** 与 pi 原生一致:不到 1 分显示两位有效数字。 */
102
98
  function formatCost(cost: number): string {
103
99
  return `$${cost >= 0.01 ? cost.toFixed(2) : cost.toPrecision(2)}`;
@@ -108,7 +104,7 @@ function callMeta(call: CodemodeNestedCall): string[] {
108
104
  const out: string[] = [];
109
105
  const duration = call?.durationMs;
110
106
  if (typeof duration === "number" && Number.isFinite(duration) && duration >= 0) {
111
- out.push(formatDuration(duration));
107
+ out.push(formatToolDuration(duration));
112
108
  }
113
109
  const cost = call?.cost;
114
110
  if (typeof cost === "number" && Number.isFinite(cost) && cost > 0) out.push(formatCost(cost));