pi-timeline 0.1.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/src/index.ts ADDED
@@ -0,0 +1,1770 @@
1
+ /*
2
+ Timeline -- 在长会话里跳转到某一条用户消息
3
+ 列出会话里的用户消息, 选中后把 transcript 滚动到那条消息
4
+ 需要 TUI mode
5
+ */
6
+
7
+ import {
8
+ DynamicBorder,
9
+ getAgentDir,
10
+ parseSkillBlock,
11
+ sessionEntryToContextMessages,
12
+ type ExtensionAPI,
13
+ type ExtensionCommandContext,
14
+ type ExtensionContext,
15
+ type Theme,
16
+ } from "@earendil-works/pi-coding-agent";
17
+ import {
18
+ Container,
19
+ Input,
20
+ SelectList,
21
+ SettingsList,
22
+ Spacer,
23
+ Text,
24
+ matchesKey,
25
+ truncateToWidth,
26
+ visibleWidth,
27
+ type Component,
28
+ type KeyId,
29
+ type KeybindingsManager,
30
+ type SelectListTheme,
31
+ type SettingItem,
32
+ type SettingsListTheme,
33
+ type TUI,
34
+ } from "@earendil-works/pi-tui";
35
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
36
+ import { homedir } from "node:os";
37
+ import { dirname, join } from "node:path";
38
+ import {
39
+ CONFIG_FILE_NAME,
40
+ JUMP_TO_VALUES,
41
+ LANGUAGES,
42
+ messages,
43
+ type JumpTo,
44
+ type Language,
45
+ type Messages,
46
+ type SettingId,
47
+ } from "./i18n.ts";
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // 界面语言: 文案表全在 i18n.ts, 这里只记住配置里的取值, 要用文案就 t()
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /** 当前界面语言(读配置时刷新, 见 loadConfig) */
54
+ let currentLanguage: Language = "auto";
55
+
56
+ function t(): Messages {
57
+ return messages(currentLanguage);
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // transcript 内部结构
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * Pi 给"语义标记块"的首行加的 OSC 133 标记(终端 shell 集成"跳到上一条提示符"用的同一套).
66
+ * 哪些块算标记块: 用户消息, 以及**不带工具调用**的助手块(也就是真正的回答).
67
+ * 带工具调用的助手块("我先看看..."那种开场白)不打标记--这正是 pi 原生 ctrl+↑/↓ 在
68
+ * "用户消息 / 正文"之间交替跳的依据; 定位不对的话可以拿这个正则交叉验证.
69
+ */
70
+ const OSC133_PROMPT_START = /^\x1b\]133;A(?:\x07|\x1b\\)/;
71
+
72
+ /** 能拿来做跳转的 ScrollView 子集(避开 instanceof, 打包/虚拟模块下类身份未必一致). */
73
+ interface ScrollViewLike {
74
+ scrollTop: number;
75
+ scrollTo(row: number, options?: { disableFollow?: boolean }): void;
76
+ render(width: number): string[];
77
+ children?: unknown[];
78
+ primary?: boolean;
79
+ }
80
+
81
+ function isScrollViewLike(value: unknown): value is ScrollViewLike {
82
+ const candidate = value as Partial<ScrollViewLike> | undefined;
83
+ return (
84
+ !!candidate &&
85
+ typeof candidate.render === "function" &&
86
+ typeof candidate.scrollTo === "function" &&
87
+ typeof candidate.scrollTop === "number"
88
+ );
89
+ }
90
+
91
+ function findPrimaryScrollView(root: unknown, depth = 0): ScrollViewLike | undefined {
92
+ if (!root || depth > 16) return undefined;
93
+ const node = root as { primary?: boolean; children?: unknown[] };
94
+ if (isScrollViewLike(root) && node.primary === true) return root as ScrollViewLike;
95
+ if (!Array.isArray(node.children)) return undefined;
96
+ for (const child of node.children) {
97
+ const found = findPrimaryScrollView(child, depth + 1);
98
+ if (found) return found;
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ /**
104
+ * 找到 transcript 的 ScrollView.
105
+ * Pi 内部自己也是通过 TuiAltScreen.getPrimaryScrollView() 拿它来做 ctrl+↑/↓ 跳转的,
106
+ * 只是这个方法没进公开类型; 这里按"内部方法 → 布局帧 → 布局根"的顺序探测, 失败就返回 undefined.
107
+ */
108
+ function resolveTranscript(tui: unknown): ScrollViewLike | undefined {
109
+ const renderer = tui as {
110
+ mode?: string;
111
+ getPrimaryScrollView?: () => unknown;
112
+ currentLayout?: { primaryScrollView?: unknown };
113
+ layoutRoot?: unknown;
114
+ };
115
+ if (!renderer || renderer.mode !== "fullscreen") return undefined;
116
+ try {
117
+ const direct = renderer.getPrimaryScrollView?.();
118
+ if (isScrollViewLike(direct) && (direct as { primary?: boolean }).primary === true) return direct;
119
+ } catch {
120
+ /* 版本差异, 继续用下面的兜底 */
121
+ }
122
+ try {
123
+ const fromFrame = renderer.currentLayout?.primaryScrollView;
124
+ if (isScrollViewLike(fromFrame)) return fromFrame;
125
+ } catch {
126
+ /* ignore */
127
+ }
128
+ return findPrimaryScrollView(renderer.layoutRoot) ?? findPrimaryScrollView(tui);
129
+ }
130
+
131
+ /** 布局帧里对应 ScrollView 的盒子(结构和 pi-tui 的 getScrollViewBox 一致, 只是不引它的深路径). */
132
+ function findScrollBox(box: any, view: ScrollViewLike): any {
133
+ if (!box) return undefined;
134
+ if (box.scrollView === view) return box;
135
+ for (const child of box.children ?? []) {
136
+ const found = findScrollBox(child, view);
137
+ if (found) return found;
138
+ }
139
+ return undefined;
140
+ }
141
+
142
+ /**
143
+ * transcript 当前渲染出的完整行(含 ANSI / OSC 转义).
144
+ * 优先用布局帧里缓存的那些行--那正是渲染到屏幕上的内容, 行号才能和 scrollTo 对上.
145
+ */
146
+ function transcriptLines(tui: unknown, view: ScrollViewLike): string[] {
147
+ const renderer = tui as { currentLayout?: { root?: unknown }; terminal?: { columns?: number } };
148
+ try {
149
+ const box = findScrollBox(renderer.currentLayout?.root, view);
150
+ const cached = box?.scrollContentLines;
151
+ if (Array.isArray(cached) && cached.length > 0) return cached as string[];
152
+ } catch {
153
+ /* ignore */
154
+ }
155
+ return view.render(renderer.terminal?.columns ?? 80);
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // 文本处理
160
+ // ---------------------------------------------------------------------------
161
+
162
+ function stripAnsi(text: string): string {
163
+ return text
164
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") // OSC(含 OSC 133 标记与超链接)
165
+ .replace(/\x1b_pi:c\x07/g, "") // 光标标记
166
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "") // CSI
167
+ .replace(/\x1b[()][A-Za-z0-9]/g, "")
168
+ .replace(/\x1b[=>]/g, "");
169
+ }
170
+
171
+ /**
172
+ * 比对用的归一化: 去掉空白和 markdown 装饰字符.
173
+ * 渲染会重排文本, 丢掉 `**`/`` ` ``/`#` 之类的记号, 两边按同一套规则抹平才能对上.
174
+ */
175
+ function normalize(text: string): string {
176
+ return stripAnsi(text)
177
+ .replace(/\s+/g, "")
178
+ .replace(/[*_`#>~|[\]()]/g, "");
179
+ }
180
+
181
+ /** 折成一行, 用于列表里的预览. */
182
+ function collapse(text: string): string {
183
+ return stripAnsi(text).replace(/\s+/g, " ").trim();
184
+ }
185
+
186
+ function previewOf(text: string, max = 100): string {
187
+ const line = collapse(text);
188
+ return line.length > max ? `${line.slice(0, max - 1)}...` : line;
189
+ }
190
+
191
+ function formatTime(value: unknown): string {
192
+ const date = typeof value === "number" ? new Date(value) : typeof value === "string" ? new Date(value) : undefined;
193
+ if (!date || Number.isNaN(date.getTime())) return "";
194
+ const now = new Date();
195
+ const sameDay =
196
+ date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate();
197
+ const clock = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
198
+ return sameDay ? clock : `${date.getMonth() + 1}/${date.getDate()} ${clock}`;
199
+ }
200
+
201
+ /** 消息文本, 和 Pi 的 getUserMessageText() 一致: 拼接所有 text 片段. */
202
+ function messageText(message: any): string {
203
+ if (!message || message.role !== "user") return "";
204
+ return contentText(message.content);
205
+ }
206
+
207
+ /** 助手消息的正文(多个 text 片段拼起来). */
208
+ function assistantText(message: any): string {
209
+ if (!message || message.role !== "assistant") return "";
210
+ return contentText(message.content);
211
+ }
212
+
213
+ function contentText(content: any): string {
214
+ if (typeof content === "string") return content;
215
+ if (!Array.isArray(content)) return "";
216
+ return content
217
+ .filter((part: any) => part?.type === "text" && typeof part.text === "string")
218
+ .map((part: any) => part.text)
219
+ .join("");
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // 会话 → 列表项
224
+ // ---------------------------------------------------------------------------
225
+
226
+ interface TimelineItem {
227
+ kind: "user" | "bash";
228
+ entryId: string;
229
+ /** 放进输入框用的完整文本(用户消息的原文, 不含技能块包装) */
230
+ text: string;
231
+ /** 和渲染出来的组件对齐用的原文(`!命令` 只取命令本身) */
232
+ matchText: string;
233
+ preview: string;
234
+ time: string;
235
+ badge?: string;
236
+ }
237
+
238
+ interface PickerData {
239
+ items: TimelineItem[];
240
+ }
241
+
242
+ /**
243
+ * 和 pi 的 projectContextEntry 一致: `context_edit` 条目会把目标消息的内容换成 replacement
244
+ * (null 表示整条删掉). 列表里的文本必须用投影后的, 不然和渲染出来的组件对不上.
245
+ */
246
+ function projectReplacement(messages: any[], replacement: any): any[] {
247
+ if (!replacement) return messages;
248
+ return messages.map((message) => {
249
+ const role = message?.role;
250
+ if (role !== "user" && role !== "assistant" && role !== "toolResult") return message;
251
+ const content =
252
+ (role === "assistant" || role === "toolResult") && typeof replacement.content === "string"
253
+ ? [{ type: "text", text: replacement.content }]
254
+ : replacement.content;
255
+ return { ...message, content };
256
+ });
257
+ }
258
+
259
+ /**
260
+ * 从当前会话收集候选消息.
261
+ *
262
+ * 只取 buildContextEntries()--也就是 Pi 真正渲染进 transcript 的那批条目:
263
+ * 压缩之后消失的旧消息不在里面, 跳过去也没有意义.
264
+ * 取哪些消息, 什么条件下渲染, 都和 pi 的 addMessageToChat 一一对应--
265
+ * 列表项和转录组件是靠**顺序**一一对上的, 两边少一条都会事.
266
+ */
267
+ function buildPickerData(ctx: ExtensionContext): PickerData {
268
+ const manager = ctx.sessionManager as unknown as {
269
+ buildContextEntries?: () => any[];
270
+ getBranch?: () => any[];
271
+ };
272
+ const entries = manager.buildContextEntries?.() ?? manager.getBranch?.() ?? [];
273
+
274
+ // `context_edit` 会改写目标消息的内容, 先收集起来
275
+ const edits = new Map<string, any>();
276
+ for (const entry of entries) {
277
+ if (entry?.type === "context_edit" && typeof entry.targetId === "string") edits.set(entry.targetId, entry);
278
+ }
279
+
280
+ const items: TimelineItem[] = [];
281
+
282
+ for (const entry of entries) {
283
+ let messages: any[];
284
+ try {
285
+ messages = sessionEntryToContextMessages(entry) ?? [];
286
+ } catch {
287
+ messages = entry?.type === "message" && entry.message ? [entry.message] : [];
288
+ }
289
+
290
+ const edit = typeof entry?.id === "string" ? edits.get(entry.id) : undefined;
291
+ if (edit) {
292
+ if (edit.replacement === null) continue; // 被删掉的消息不渲染, 也不该出现在列表里
293
+ messages = projectReplacement(messages, edit.replacement);
294
+ }
295
+
296
+ for (const message of messages) {
297
+ const role = message?.role;
298
+
299
+ if (role === "user") {
300
+ // 和 pi 的 `if (textContent)` 一致: 空文本不渲染; 空白消息照渲染, 所以也要占一项
301
+ const text = messageText(message);
302
+ if (!text) continue;
303
+
304
+ let body = text;
305
+ let badge: string | undefined;
306
+ try {
307
+ const skill = parseSkillBlock(text);
308
+ if (skill) {
309
+ if (!skill.userMessage) continue; // 只渲染了技能头部, 没有用户气泡
310
+ body = skill.userMessage;
311
+ badge = `⚡${skill.name}`;
312
+ }
313
+ } catch {
314
+ /* 技能块解析失败就当普通文本处理 */
315
+ }
316
+
317
+ items.push({
318
+ kind: "user",
319
+ entryId: entry.id,
320
+ text: body,
321
+ matchText: body,
322
+ preview: previewOf(body) || t().emptyMessage,
323
+ time: formatTime(entry.timestamp),
324
+ badge,
325
+ });
326
+ continue;
327
+ }
328
+
329
+ if (role === "assistant") {
330
+ // 助手正文不列进列表(列表只要用户消息); 它的组件会在定位时被读到,
331
+ // 用来支持 jumpTo: "reply"(跳到这条用户消息下面的正文)
332
+ continue;
333
+ }
334
+
335
+ if (role === "bashExecution" && typeof message.command === "string") {
336
+ // `!命令`: 渲染成 BashExecutionComponent, 也能按组件精确定位到
337
+ items.push({
338
+ kind: "bash",
339
+ entryId: entry.id,
340
+ text: `!${message.command}`,
341
+ matchText: message.command,
342
+ preview: previewOf(`!${message.command}`) || "!",
343
+ time: formatTime(message.timestamp),
344
+ });
345
+ }
346
+ }
347
+ }
348
+
349
+ return { items };
350
+ }
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // 消息 → 行号
354
+ // ---------------------------------------------------------------------------
355
+
356
+ function promptMarkerRows(lines: string[]): number[] {
357
+ const rows: number[] = [];
358
+ for (let row = 0; row < lines.length; row++) {
359
+ if (OSC133_PROMPT_START.test(lines[row] ?? "")) rows.push(row);
360
+ }
361
+ return rows;
362
+ }
363
+
364
+ /**
365
+ * key 在 block 里能匹配多长. 整串命中最好; 渲染会改写 markdown 开头
366
+ * (`# `, `- `, `> `, 代码围栏等), 所以再退回按前缀 / 中间片段匹配.
367
+ */
368
+ function matchScore(block: string, key: string): number {
369
+ if (!block || !key) return 0;
370
+ if (block.includes(key)) return key.length;
371
+ for (const length of [24, 16, 12, 8, 6]) {
372
+ if (length > key.length) continue;
373
+ if (block.includes(key.slice(0, length))) return length;
374
+ for (let start = 1; start + length <= key.length; start++) {
375
+ if (block.includes(key.slice(start, start + length))) return length;
376
+ }
377
+ }
378
+ return 0;
379
+ }
380
+
381
+ function findLineWithText(lines: string[], key: string, from: number): number | undefined {
382
+ for (const length of [24, 16, 8, Math.min(6, key.length)]) {
383
+ if (length <= 0) continue;
384
+ const needle = key.slice(0, length);
385
+ for (let row = Math.max(0, from); row < lines.length; row++) {
386
+ if (normalize(lines[row] ?? "").includes(needle)) return row;
387
+ }
388
+ }
389
+ return undefined;
390
+ }
391
+
392
+ /**
393
+ * 按组件精确定位: 给每条消息算出它第一行在内容里的行号.
394
+ *
395
+ * 依据: 布局帧里 transcript 那个滚动盒子的孩子就是整份文档, ScrollView 的内容就是这份文档的渲染结果,
396
+ * 所以文档的第一行就是内容的第 0 行; 而文档/聊天容器都是普通 Container,
397
+ * `render` 就是把子组件的行首尾拼起来, 于是按顺序累加每个子组件 `render(width).length` 就能算出起始行.
398
+ *
399
+ * 比数 OSC 133 标记再猜可靠得多: 只有工具调用, 没有文字的助手消息根本不产生标记,
400
+ * 流式中的消息又多出一个还没落盘的组件, 两头一抵消就会整体错位.
401
+ * 而组件本身带着原文(用户消息的 text, `!命令`的 command), 可以直接和会话条目对上.
402
+ */
403
+ interface MessageRecord {
404
+ kind: "user" | "assistant" | "bash";
405
+ /** 该组件第一行的内容行号(也就是带 OSC 133 标记的那一行) */
406
+ row: number;
407
+ /** 组件占多少行 */
408
+ height: number;
409
+ /** 组件持有的原文, 用来和会话条目对上 */
410
+ text: string;
411
+ /** 是不是 pi 眼里的"语义标记块"(用户消息 / 不带工具调用的助手回答) */
412
+ landmark: boolean;
413
+ /** 助手块里带不带思考: 带思考就跳过思考落正文, 不带就落块首(= pi 原生) */
414
+ hasThinking?: boolean;
415
+ /** 正文文字第一行在块内的偏移(带思考的块才用得上) */
416
+ textOffset?: number;
417
+ }
418
+
419
+ /** 命中的消息在渲染里的位置 */
420
+ interface LocatedMessage {
421
+ row: number;
422
+ /** 在 MessageRecord 序列里的下标 */
423
+ index: number;
424
+ }
425
+
426
+ function classifyComponent(component: any): MessageRecord["kind"] | undefined {
427
+ if (!component || typeof component !== "object") return undefined;
428
+ // UserMessageComponent
429
+ if (typeof component.text === "string" && typeof component.rebuild === "function" && typeof component.outputPad === "number") {
430
+ return "user";
431
+ }
432
+ // AssistantMessageComponent
433
+ if (typeof component.updateContent === "function" && "lastMessage" in component) return "assistant";
434
+ // BashExecutionComponent(`!命令`)
435
+ if (typeof component.command === "string" && Array.isArray(component.outputLines) && typeof component.appendOutput === "function") {
436
+ return "bash";
437
+ }
438
+ return undefined;
439
+ }
440
+
441
+ /** 有 children 的容器候选(是不是真"纯拼接"由 walk 里的高度校验定, 不靠类名) */
442
+ function isPlainContainer(component: any): boolean {
443
+ return !!component && Array.isArray(component.children) && component.children.length > 0;
444
+ }
445
+
446
+ function collectMessageRecords(tui: any, view: ScrollViewLike): { records: MessageRecord[]; lines: string[] } | undefined {
447
+ const frame = tui?.currentLayout;
448
+ const scrollBox = findScrollBox(frame?.root, view);
449
+ const document = scrollBox?.children?.[0]?.component ?? (view as any).child;
450
+ if (!document || typeof document.render !== "function") return undefined;
451
+
452
+ const columns = scrollBox?.rect?.width ?? tui?.terminal?.columns ?? 80;
453
+ const width = typeof view.getContentWidth === "function" ? view.getContentWidth(columns) : columns;
454
+
455
+ // 只算每个叶子/消息组件的高度, 容器不重复渲染(长会话动辄几万行, 这一步很贵)
456
+ const records: MessageRecord[] = [];
457
+ let total = 0; // 文档的第一行就是内容的第 0 行
458
+ walk(document);
459
+
460
+ // 完整性校验: 累加高度必须正好等于内容行数. 坐标系只信**现在这一遍**渲染出来的行:
461
+ // 布局帧缓存的行可能是上一帧的(正在流式输出 / 别的扩展改了渲染), 拿它当坐标系就会错位;
462
+ // 以前对不上就 `view.render(columns)` 再试一次--那返回的是**视口那几十行**, 长会话永远对不上,
463
+ // 于是悄悄退回模糊的标记匹配("有概率定位错"就是这么来的).
464
+ let lines: string[];
465
+ try {
466
+ lines = document.render(width);
467
+ } catch {
468
+ return undefined;
469
+ }
470
+ if (total !== lines.length) return undefined;
471
+ return { records, lines };
472
+
473
+ function walk(component: any): void {
474
+ if (!component || typeof component.render !== "function") return;
475
+ const kind = classifyComponent(component);
476
+ if (kind) {
477
+ const height = component.render(width).length;
478
+ const thinking = kind === "assistant" ? hasThinkingPart(component?.lastMessage) : false;
479
+ records.push({
480
+ kind,
481
+ row: total,
482
+ height,
483
+ text: String(kind === "bash" ? (component.command ?? "") : kind === "assistant" ? assistantText(component.lastMessage) : (component.text ?? "")),
484
+ // 带工具调用的助手块在 pi 那里不算"正文/提示符"(不打 OSC 133 标记)
485
+ landmark: kind === "assistant" ? component.hasToolCalls !== true : kind === "user",
486
+ hasThinking: thinking,
487
+ textOffset: thinking ? assistantBodyOffset(component, width) : undefined,
488
+ });
489
+ total += height;
490
+ return;
491
+ }
492
+ if (isPlainContainer(component)) {
493
+ // 容器只有在"渲染 == 子组件行拼接"时才往下钻: ToolExecutionComponent 这类
494
+ // Container 子类会自己画额外的行(工具头 / 图片 / 缩进), 往下钻就丢行--累加高度
495
+ // 和真实渲染对不上, 整个精确路径作废(退回模糊的标记匹配, 甚至跳到用户消息).
496
+ // 对不上的整块按叶子算高度, 行号照样是准的.
497
+ const rendered = component.render(width);
498
+ let childTotal = 0;
499
+ let plain = true;
500
+ for (const child of component.children) {
501
+ if (!child || typeof child.render !== "function") {
502
+ plain = false;
503
+ break;
504
+ }
505
+ childTotal += child.render(width).length;
506
+ }
507
+ if (plain && childTotal === rendered.length) {
508
+ for (const child of component.children) walk(child);
509
+ return;
510
+ }
511
+ total += rendered.length;
512
+ return;
513
+ }
514
+ total += component.render(width).length;
515
+ }
516
+ }
517
+
518
+ /** 把列表项和渲染出来的组件对上: 先认原文完全一致的, 认不上就按顺序取下一个同类组件.
519
+ * 同时记住它在组件序列里的下标, 后续要拿它后面的"正文". */
520
+ /**
521
+ * 把列表项和转录里的组件按顺序对上(按 kind 分别对齐).
522
+ *
523
+ * 两边出自同一批会话条目, 顺序一致: pi 每条有文本的用户消息渲染成一个
524
+ * UserMessageComponent, 每条 `!命令` 一个 BashExecutionComponent, 严格一一对应.
525
+ * 所以以**位置**为准, 文本只当锚点: 归一化后在两边都恰好只出现一次的文本是确定配对,
526
+ * 锚点之间的区间按顺序平移. 这样重复发同一句话, 某条文本被改写, 中间少了一条,
527
+ * 都不会整段串位--纯文本比对会把后一条消息认到前面同文本的组件上,
528
+ * 表现就是"有时对有时错".
529
+ */
530
+ function locateRowsFromRecords(items: TimelineItem[], records: MessageRecord[]): Array<LocatedMessage | undefined> {
531
+ const located: Array<LocatedMessage | undefined> = items.map(() => undefined);
532
+
533
+ for (const kind of ["user", "bash"] as const) {
534
+ const itemIndices: number[] = [];
535
+ for (let index = 0; index < items.length; index++) if (items[index].kind === kind) itemIndices.push(index);
536
+ const recordIndices: number[] = [];
537
+ for (let index = 0; index < records.length; index++) if (records[index].kind === kind) recordIndices.push(index);
538
+ if (itemIndices.length === 0 || recordIndices.length === 0) continue;
539
+
540
+ // 锚点: 归一化文本在两边都恰好只出现一次
541
+ const itemByText = new Map<string, number[]>();
542
+ for (let position = 0; position < itemIndices.length; position++) {
543
+ const key = normalize(items[itemIndices[position]].matchText);
544
+ if (!key) continue;
545
+ const list = itemByText.get(key) ?? [];
546
+ list.push(position);
547
+ itemByText.set(key, list);
548
+ }
549
+ const recordByText = new Map<string, number[]>();
550
+ for (let position = 0; position < recordIndices.length; position++) {
551
+ const key = normalize(records[recordIndices[position]].text ?? "");
552
+ if (!key) continue;
553
+ const list = recordByText.get(key) ?? [];
554
+ list.push(position);
555
+ recordByText.set(key, list);
556
+ }
557
+ const anchors: Array<[number, number]> = [];
558
+ for (const [key, itemPositions] of itemByText) {
559
+ if (itemPositions.length !== 1) continue;
560
+ const recordPositions = recordByText.get(key);
561
+ if (!recordPositions || recordPositions.length !== 1) continue;
562
+ anchors.push([itemPositions[0], recordPositions[0]]);
563
+ }
564
+ anchors.sort((left, right) => left[0] - right[0]);
565
+ // 两边顺序对不上的锚点丢掉(以先到者为准), 后面的区间照样重新对齐
566
+ const kept: Array<[number, number]> = [];
567
+ let lastRecordPosition = -1;
568
+ for (const anchor of anchors) {
569
+ if (anchor[1] <= lastRecordPosition) continue;
570
+ kept.push(anchor);
571
+ lastRecordPosition = anchor[1];
572
+ }
573
+
574
+ // 区间内按顺序平移; 两侧数量不齐时多出来的那截不配对, 下个锚点重新对上
575
+ let itemPosition = 0;
576
+ let recordPosition = 0;
577
+ const zip = (itemEnd: number, recordEnd: number): void => {
578
+ while (itemPosition < itemEnd && recordPosition < recordEnd) {
579
+ located[itemIndices[itemPosition]] = {
580
+ row: records[recordIndices[recordPosition]].row,
581
+ index: recordIndices[recordPosition],
582
+ };
583
+ itemPosition += 1;
584
+ recordPosition += 1;
585
+ }
586
+ itemPosition = Math.max(itemPosition, itemEnd);
587
+ recordPosition = Math.max(recordPosition, recordEnd);
588
+ };
589
+ for (const [anchorItemPosition, anchorRecordPosition] of kept) {
590
+ zip(anchorItemPosition, anchorRecordPosition);
591
+ located[itemIndices[anchorItemPosition]] = {
592
+ row: records[recordIndices[anchorRecordPosition]].row,
593
+ index: recordIndices[anchorRecordPosition],
594
+ };
595
+ itemPosition = anchorItemPosition + 1;
596
+ recordPosition = anchorRecordPosition + 1;
597
+ }
598
+ zip(itemIndices.length, recordIndices.length);
599
+ }
600
+
601
+ return located;
602
+ }
603
+
604
+ /**
605
+ * 落脚的那条记录.
606
+ *
607
+ * `reply` = 这条消息之后**第一个"正文块"**(不带工具调用的助手块, pi 的 ctrl+↓ 目标, 它首行有
608
+ * OSC 133 标记). 中间那些带工具调用的块("我先看看..."开场白, 几十个只有工具调用的块)会被跳过.
609
+ * 不限定在"这一轮"里: 用户中途打断时这一轮可能只有工具调用, 没有正文, 那种时候最近的下一段
610
+ * 正文才是有用的落点(连发的几条消息也自然共享同一个正文).
611
+ */
612
+ function pickAnchorRecord(located: LocatedMessage, records: MessageRecord[], jumpTo: JumpTo): MessageRecord | undefined {
613
+ if (jumpTo !== "reply") return records[located.index];
614
+
615
+ // 优先找**带文字**的正文块: 思考也算内容, 但用户要的"正文"是文字;
616
+ // 半截/被打断的回答可能只有思考没有文字, 那种块往后放.
617
+ for (let k = located.index + 1; k < records.length; k++) {
618
+ if (records[k].kind === "assistant" && records[k].landmark && (records[k].text ?? "").trim()) return records[k];
619
+ }
620
+ for (let k = located.index + 1; k < records.length; k++) {
621
+ if (records[k].kind === "assistant" && records[k].landmark) return records[k];
622
+ }
623
+
624
+ // 后面没有任何正文块: 退而求其次--最后一条有文字的助手块 → 最后一条助手块 → 下一个块 → 它自己
625
+ let lastWithText: MessageRecord | undefined;
626
+ let lastAssistant: MessageRecord | undefined;
627
+ for (let k = located.index + 1; k < records.length; k++) {
628
+ if (records[k].kind !== "assistant") continue;
629
+ lastAssistant = records[k];
630
+ if ((records[k].text ?? "").trim()) lastWithText = records[k];
631
+ }
632
+ return lastWithText ?? lastAssistant ?? records[located.index + 1] ?? records[located.index];
633
+ }
634
+
635
+ /**
636
+ * 纯兜底: 数量 / 组件都拿不到时, 用 OSC 133 标记块 + 文本比对找.
637
+ * 只认"文本落在块开头附近"的块--用户消息的正文就是紧跟在标记后面,
638
+ * 这样不会跳到助手消息里引用同一句话的地方.
639
+ */
640
+ function locateRowsByMarkers(
641
+ items: TimelineItem[],
642
+ lines: string[],
643
+ markerRows: number[],
644
+ jumpTo: JumpTo,
645
+ ): { rows: Array<number | undefined>; blockRows: Array<number | undefined> } {
646
+ const rows: Array<number | undefined> = items.map(() => undefined);
647
+ const blockRows: Array<number | undefined> = items.map(() => undefined);
648
+ const blocks: string[] = [];
649
+ const headLines: string[] = []; // 标记行后面前几行, 用来判断"正文是不是就在开头"
650
+ for (let index = 0; index < markerRows.length; index++) {
651
+ const start = markerRows[index];
652
+ const end = markerRows[index + 1] ?? lines.length;
653
+ blocks.push(normalize(lines.slice(start, end).join("\n")));
654
+ headLines.push(normalize(lines.slice(start, Math.min(end, start + 5)).join("\n")));
655
+ }
656
+
657
+ let cursor = 0;
658
+ for (let index = 0; index < items.length; index++) {
659
+ const item = items[index];
660
+ const key = normalize(item.matchText);
661
+ if (!key) continue;
662
+ // 阈值不能太松: 只拿 6 个字符当线索的话, 同开头的消息(好几个"fabric...")会互相认错
663
+ const threshold = Math.min(12, key.length);
664
+
665
+ let best = -1;
666
+ let bestScore = threshold - 1;
667
+ for (let markerIndex = cursor; markerIndex < blocks.length; markerIndex++) {
668
+ const score = matchScore(blocks[markerIndex], key);
669
+ if (score < threshold) continue;
670
+ const nearStart = matchScore(headLines[markerIndex], key) >= threshold;
671
+ const weighted = score + (nearStart ? 1000 : 0); // 正文就在块开头 → 优先
672
+ if (weighted > bestScore) {
673
+ bestScore = weighted;
674
+ best = markerIndex;
675
+ if (nearStart) break;
676
+ }
677
+ }
678
+ if (best >= 0) {
679
+ blockRows[index] = markerRows[best];
680
+ // 跳到"下方正文"时, 拿下一个标记块; 没有下一个就还是它自己
681
+ rows[index] = jumpTo === "reply" ? (markerRows[best + 1] ?? markerRows[best]) : markerRows[best];
682
+ cursor = best + 1;
683
+ continue;
684
+ }
685
+
686
+ // `!命令`这类没有标记的块: 按整行文本找
687
+ const found = findLineWithText(lines, key, rows[index - 1] ?? 0);
688
+ if (found !== undefined) {
689
+ const next = jumpTo === "reply" ? markerRows.find((row) => row > found) : undefined;
690
+ rows[index] = next ?? found;
691
+ blockRows[index] = found;
692
+ }
693
+ }
694
+ return { rows, blockRows };
695
+ }
696
+
697
+ /** pi 自己包思考块的 MouseRegion: `{ child, onMouse }` */
698
+ function isMouseRegion(value: any): boolean {
699
+ return !!value && typeof value === "object" && typeof value.onMouse === "function" && "child" in value;
700
+ }
701
+
702
+ /**
703
+ * 这个子组件是"思考块"吗?
704
+ *
705
+ * pi 把每段思考包在 `MouseRegion` 里, 但别的扩展会在外面再套一层自己的外壳
706
+ * (thinking-display 的折叠标记就是这样: 外层是它自己的 view, 里面才是 MouseRegion).
707
+ * 所以除了自己就是 MouseRegion, 还往下找几层; 只往普通对象里找(正文 Markdown 的
708
+ * `cachedLines` 是几千行字符串), 设深度上限防环.
709
+ */
710
+ function isThinkingChild(value: any, depth = 0): boolean {
711
+ if (isMouseRegion(value)) return true;
712
+ if (depth >= 3 || !value || typeof value !== "object" || Array.isArray(value)) return false;
713
+ for (const key of Object.keys(value)) {
714
+ const inner = value[key];
715
+ if (inner && typeof inner === "object" && isThinkingChild(inner, depth + 1)) return true;
716
+ }
717
+ return false;
718
+ }
719
+
720
+ /** 消息里带不带思考段 */
721
+ function hasThinkingPart(message: any): boolean {
722
+ const content: any[] = Array.isArray(message?.content) ? message.content : [];
723
+ return content.some((part: any) => part?.type === "thinking" && String(part.thinking ?? "").trim());
724
+ }
725
+
726
+ /** 消息里的正文段(pi 每段 text 渲染成一个 Markdown, 源文本就是这段去掉首尾空白) */
727
+ function assistantTextParts(message: any): string[] {
728
+ const content: any[] = Array.isArray(message?.content) ? message.content : [];
729
+ return content
730
+ .filter((part: any) => part?.type === "text" && String(part.text ?? "").trim())
731
+ .map((part: any) => String(part.text).trim());
732
+ }
733
+
734
+ /**
735
+ * 这个子组件在渲染"正文段"吗? 按**内容**认: 它(或它包着的内层)的源文本正好是消息里某一段
736
+ * text. 比按形状认准--形状只认"pi 的思考块长什么样", 别的扩展把思考块换成自己的东西时
737
+ * (见 pitfalls)就会认错, 源文本比对不受影响.
738
+ */
739
+ function hasTextSource(value: any, parts: string[], depth = 0): boolean {
740
+ if (!value || typeof value !== "object" || parts.length === 0) return false;
741
+ if (typeof value.text === "string" && parts.includes(value.text.trim())) return true;
742
+ if (depth >= 2 || Array.isArray(value)) return false;
743
+ for (const key of Object.keys(value)) {
744
+ const inner = value[key];
745
+ if (inner && typeof inner === "object" && hasTextSource(inner, parts, depth + 1)) return true;
746
+ }
747
+ return false;
748
+ }
749
+
750
+ /**
751
+ * 带思考的助手块里"正文文字"的起点(块内偏移行数).
752
+ * 认正文先按内容(源文本 == 消息里的 text 段), 认不出再按形状(第一个不是思考块的可见块).
753
+ */
754
+ function assistantBodyOffset(component: any, width: number): number | undefined {
755
+ const children: any[] | undefined = component?.contentContainer?.children;
756
+ if (!Array.isArray(children) || children.length === 0) return undefined;
757
+ const parts = assistantTextParts(component?.lastMessage);
758
+ const entries: Array<{ child: any; offset: number; visible: boolean }> = [];
759
+ let offset = 0;
760
+ for (const child of children) {
761
+ const lines: string[] = typeof child?.render === "function" ? child.render(width) : [];
762
+ entries.push({ child, offset, visible: lines.some((line) => stripAnsi(line).trim().length > 0) });
763
+ offset += lines.length;
764
+ }
765
+ for (const entry of entries) {
766
+ if (entry.visible && hasTextSource(entry.child, parts)) return entry.offset;
767
+ }
768
+ for (const entry of entries) {
769
+ if (entry.visible && !isThinkingChild(entry.child)) return entry.offset;
770
+ }
771
+ return undefined;
772
+ }
773
+
774
+ /**
775
+ * 落脚行.
776
+ *
777
+ * - 纯文本回复: **块的第一行**--和 pi 原生 `Ctrl+↑/↓`(`scrollToPrompt`)逐行一致(它认的
778
+ * 就是行首带 OSC 133 块标记的那行, 也就是块首的上边距).
779
+ * - 回复块里**带思考**: 跳过思考, 落**正文文字第一行**--不然几十行思考顶在视口上,
780
+ * 正文被推出屏幕(踩过: #5 那种).
781
+ */
782
+ function anchoredRow(record: MessageRecord | undefined): number | undefined {
783
+ if (!record) return undefined;
784
+ if (record.kind === "assistant" && record.hasThinking && record.textOffset !== undefined) {
785
+ return record.row + record.textOffset;
786
+ }
787
+ return record.row;
788
+ }
789
+
790
+ /**
791
+ * 滚到目标行.
792
+ *
793
+ * 先把 ScrollView 的布局状态(内容总行数 / 视口高度)补到这一遍量出来的值, 再滚:
794
+ * 它可能是上一拍的(内容长了还没重绘), `scrollTo` 会把目标**夹到旧的边界**上,
795
+ * 落点看着就"有概率错". `updateLayout` 是 pi-tui 布局系统自己调的那个入口,
796
+ * 这里原样保留它原来的渲染回调(否则后续滚动不再触发重绘).
797
+ */
798
+ function jumpToRow(view: ScrollViewLike, row: number, contentHeight: number | undefined): void {
799
+ try {
800
+ const internal = view as unknown as {
801
+ updateLayout?: (contentHeight: number, viewportHeight: number, requestRender: () => void) => void;
802
+ requestRenderCallback?: () => void;
803
+ };
804
+ if (contentHeight !== undefined && typeof internal.updateLayout === "function") {
805
+ const requestRender = typeof internal.requestRenderCallback === "function" ? internal.requestRenderCallback : () => {};
806
+ internal.updateLayout(contentHeight, (view as unknown as { viewportHeight?: number }).viewportHeight ?? 0, requestRender);
807
+ }
808
+ } catch {
809
+ /* 版本差异: 当作没这回事, 照常滚 */
810
+ }
811
+ view.scrollTo(row, { disableFollow: true });
812
+ }
813
+
814
+ /**
815
+ * 排障用: 扩展目录里放一个 `debug.flag` 文件, 每次定位/跳转就把内部算出的
816
+ * 列表项, 组件记录, 配对和落点写进 `debug-dump.json`(不放旗标就完全不写).
817
+ */
818
+ let lastLocateDebug: Record<string, unknown> | undefined;
819
+ function debugDump(payload: Record<string, unknown>): void {
820
+ try {
821
+ const dir = extensionDir();
822
+ if (!existsSync(join(dir, "debug.flag"))) return;
823
+ writeFileSync(join(dir, "debug-dump.json"), JSON.stringify({ at: new Date().toISOString(), ...payload }, undefined, 1), "utf8");
824
+ } catch {
825
+ /* 诊断不影响功能 */
826
+ }
827
+ }
828
+
829
+ /** 定位结果: 跳转目标行 + 每条消息自己的块首行(判断"正在看哪一轮"用) + 内容总行数. */
830
+ interface LocateResult {
831
+ rows: Array<number | undefined>;
832
+ blockRows: Array<number | undefined>;
833
+ contentHeight?: number;
834
+ }
835
+
836
+ /** 优先按组件精确定位; 组件结构识别不了(Pi 改了内部实现)再退回标记匹配. */
837
+ function locateRows(items: TimelineItem[], tui: any, view: ScrollViewLike, jumpTo: JumpTo): LocateResult {
838
+ const collected = collectMessageRecords(tui, view);
839
+ if (collected && collected.records.length > 0) {
840
+ const located = locateRowsFromRecords(items, collected.records);
841
+ if (located.some((item) => item !== undefined)) {
842
+ const rows = located.map((item) => {
843
+ if (!item) return undefined;
844
+ // 落脚行 = 块的第一行(和 pi 原生 Ctrl+↑/↓ 认的那行一致)
845
+ return anchoredRow(pickAnchorRecord(item, collected.records, jumpTo));
846
+ });
847
+ const blockRows = located.map((item) => (item ? collected.records[item.index].row : undefined));
848
+ lastLocateDebug = {
849
+ path: "component",
850
+ jumpTo,
851
+ total: collected.records.reduce((sum, record) => Math.max(sum, record.row + record.height), 0),
852
+ lines: collected.lines.length,
853
+ items: items.map((item, index) => ({ index, kind: item.kind, text: item.matchText.slice(0, 40) })),
854
+ records: collected.records.map((record, index) => ({
855
+ index,
856
+ kind: record.kind,
857
+ row: record.row,
858
+ height: record.height,
859
+ landmark: record.landmark,
860
+ text: record.text.slice(0, 40),
861
+ })),
862
+ pairs: located.map((item, index) => ({ item: index, record: item?.index, row: rows[index] })),
863
+ rows,
864
+ blockRows,
865
+ };
866
+ return { rows, blockRows, contentHeight: collected.lines.length };
867
+ }
868
+ }
869
+ const lines = collected?.lines ?? transcriptLines(tui, view);
870
+ const matched = locateRowsByMarkers(items, lines, promptMarkerRows(lines), jumpTo);
871
+ lastLocateDebug = {
872
+ path: "markers",
873
+ jumpTo,
874
+ lines: lines.length,
875
+ markerRows: promptMarkerRows(lines),
876
+ items: items.map((item, index) => ({ index, kind: item.kind, text: item.matchText.slice(0, 40) })),
877
+ rows: matched.rows,
878
+ blockRows: matched.blockRows,
879
+ contentHeight: lines.length,
880
+ };
881
+ return matched;
882
+ }
883
+
884
+ // ---------------------------------------------------------------------------
885
+ // 选择界面
886
+ // ---------------------------------------------------------------------------
887
+
888
+ type PickerResult = { action: "jump" | "insert"; index: number };
889
+
890
+ /**
891
+ * 给内容套一个完整的圆角框.
892
+ * pi 自带的 DynamicBorder 只画上下两条横线, 左右是空的, 看着像个没封口的表格.
893
+ */
894
+ function renderFrame(lines: string[], width: number, theme: Theme): string[] {
895
+ const border = (text: string) => theme.fg("accent", text);
896
+ const safeWidth = Math.max(4, width);
897
+ const innerWidth = safeWidth - 4; // "│ " + 内容 + " │"
898
+ const dashes = "─".repeat(Math.max(0, safeWidth - 2));
899
+
900
+ const output = [border(`╭${dashes}╮`)];
901
+ for (const line of lines) {
902
+ const text = truncateToWidth(line, innerWidth, "");
903
+ const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(text)));
904
+ output.push(`${border("│")} ${text}${padding} ${border("│")}`);
905
+ }
906
+ output.push(border(`╰${dashes}╯`));
907
+ return output;
908
+ }
909
+
910
+ /** 把容器包进框里; 容器按内宽渲染, 保证子组件自己换行时也算得对. */
911
+ function framed(container: Container, theme: Theme): Component {
912
+ return {
913
+ invalidate() {
914
+ container.invalidate();
915
+ },
916
+ render(width: number) {
917
+ return renderFrame(container.render(Math.max(8, width - 4)), width, theme);
918
+ },
919
+ };
920
+ }
921
+
922
+ /** 斜体: 正常主题都带 italic; 万一碰到不带的(比如测试桩), 就保持原样而不是炸掉整个面板. */
923
+ function italic(theme: Theme, text: string): string {
924
+ const apply = (theme as unknown as { italic?: (value: string) => string }).italic;
925
+ return typeof apply === "function" ? apply.call(theme, text) : text;
926
+ }
927
+
928
+ function buildPicker(
929
+ items: TimelineItem[],
930
+ rows: Array<number | undefined>,
931
+ currentIndex: number,
932
+ jumpTo: JumpTo,
933
+ theme: Theme,
934
+ tui: TUI,
935
+ kb: KeybindingsManager,
936
+ done: (result: PickerResult | null) => void,
937
+ ): Component {
938
+ const container = new Container();
939
+
940
+ const hidden = items.length - rows.filter((row) => row !== undefined).length;
941
+ const header = [
942
+ // 标题名保持强调色; 模式居中, 数量(含它前面那个 ·)用灰色放最后
943
+ theme.fg("accent", theme.bold("Timeline")),
944
+ // 当前模式一眼可见: 跳转落到消息本身还是下面的正文(和条数同一档灰, 连前面的 ·)
945
+ theme.fg("muted", t().pickerMode(jumpTo === "reply" ? "reply" : "user")),
946
+ theme.fg("muted", t().pickerCount(items.length)),
947
+ hidden > 0 ? theme.fg("warning", t().pickerUnlocatable(hidden)) : "",
948
+ ]
949
+ .filter(Boolean)
950
+ .join(" ");
951
+ container.addChild(new Text(header, 1, 0));
952
+
953
+ const search = new Input({
954
+ prompt: "⌕ ",
955
+ placeholder: t().pickerFilter,
956
+ placeholderStyle: (text: string) => italic(theme, theme.fg("dim", text)), // 占位提示: 暗灰 + 斜体, 不和输入内容抢眼
957
+ });
958
+ search.focused = true;
959
+ container.addChild(search);
960
+
961
+ const maxVisible = Math.max(5, Math.min(15, Math.floor((tui.terminal?.rows ?? 24) * 0.5) - 3));
962
+ const state = { query: "", filtered: [] as number[], selected: 0 };
963
+
964
+ const refilter = () => {
965
+ const needle = state.query.trim().toLowerCase();
966
+ state.filtered = items
967
+ .map((_, index) => index)
968
+ .filter((index) => {
969
+ if (!needle) return true;
970
+ const item = items[index];
971
+ return `${item.preview} ${item.text} ${item.badge ?? ""} ${item.time}`.toLowerCase().includes(needle);
972
+ });
973
+ state.selected = 0;
974
+ };
975
+ refilter();
976
+ // 打开时光标就停在"当前这一轮"的消息上(定位不到就停在第一条)
977
+ const startPosition = currentIndex >= 0 ? state.filtered.indexOf(currentIndex) : -1;
978
+ if (startPosition >= 0) state.selected = startPosition;
979
+
980
+ const list: Component = {
981
+ invalidate() {},
982
+ render(width: number): string[] {
983
+ if (state.filtered.length === 0) {
984
+ return [theme.fg("muted", t().pickerNoMatch)];
985
+ }
986
+
987
+ const start = Math.max(0, Math.min(state.selected - Math.floor(maxVisible / 2), state.filtered.length - maxVisible));
988
+ const end = Math.min(start + maxVisible, state.filtered.length);
989
+ const output: string[] = [];
990
+
991
+ for (let position = start; position < end; position++) {
992
+ const index = state.filtered[position];
993
+ const item = items[index];
994
+ const isSelected = position === state.selected;
995
+ const row = rows[index];
996
+
997
+ let right = item.time;
998
+ if (index === currentIndex) right = right ? `${right} ·${t().currentTag}` : t().currentTag;
999
+ if (row === undefined) right = right ? `${right} ·${t().unlocatableTag}` : t().unlocatableTag;
1000
+ const rightText = theme.fg("dim", right);
1001
+
1002
+ const cursor = isSelected ? theme.fg("accent", "› ") : " ";
1003
+ const roleMark = item.kind === "bash" ? theme.fg("dim", "$ ") : "";
1004
+ const label = `#${index + 1} ${roleMark}${item.badge ? `${item.badge} ` : ""}${item.preview}`;
1005
+ const text = isSelected ? theme.bold(theme.fg("accent", label)) : label;
1006
+
1007
+ const available = Math.max(10, width - 2 - (right ? visibleWidth(rightText) + 2 : 0));
1008
+ const left = truncateToWidth(`${cursor}${text}`, available, "...");
1009
+ const pad = " ".repeat(Math.max(0, width - available - (right ? visibleWidth(rightText) + 2 : 0)));
1010
+ output.push(right ? `${left}${pad} ${rightText}` : left);
1011
+ }
1012
+
1013
+ if (state.filtered.length > maxVisible) {
1014
+ output.push(theme.fg("muted", ` (${state.selected + 1}/${state.filtered.length})`));
1015
+ }
1016
+ return output;
1017
+ },
1018
+ };
1019
+ container.addChild(list);
1020
+
1021
+ container.addChild(
1022
+ new Text(
1023
+ theme.fg("dim", t().pickerHint),
1024
+ 1,
1025
+ 0,
1026
+ ),
1027
+ );
1028
+
1029
+ const move = (delta: number) => {
1030
+ if (state.filtered.length === 0) return;
1031
+ const next = state.selected + delta;
1032
+ const total = state.filtered.length;
1033
+ state.selected = next < 0 ? total - 1 : next >= total ? 0 : next;
1034
+ };
1035
+ const confirm = (action: "jump" | "insert") => {
1036
+ const index = state.filtered[state.selected];
1037
+ if (index === undefined) return;
1038
+ done({ index, action });
1039
+ };
1040
+
1041
+ return {
1042
+ ...framed(container, theme),
1043
+ handleInput(data: string) {
1044
+ if (kb.matches(data, "tui.select.up")) move(-1);
1045
+ else if (kb.matches(data, "tui.select.down")) move(1);
1046
+ else if (kb.matches(data, "tui.select.pageUp")) move(-maxVisible);
1047
+ else if (kb.matches(data, "tui.select.pageDown")) move(maxVisible);
1048
+ else if (matchesKey(data, "ctrl+enter")) return confirm("insert");
1049
+ else if (kb.matches(data, "tui.select.confirm")) return confirm("jump");
1050
+ else if (kb.matches(data, "tui.select.cancel")) return done(null);
1051
+ else {
1052
+ search.handleInput(data);
1053
+ state.query = search.getValue();
1054
+ refilter();
1055
+ }
1056
+ tui.requestRender();
1057
+ },
1058
+ };
1059
+ }
1060
+
1061
+ // ---------------------------------------------------------------------------
1062
+ // 命令 / 快捷键
1063
+ // ---------------------------------------------------------------------------
1064
+
1065
+ const JUMP_SHORTCUT_DEFAULT = ["alt+h"];
1066
+ const CONFIG_MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
1067
+ const CONFIG_NAMED_KEYS = new Set([
1068
+ "escape",
1069
+ "esc",
1070
+ "enter",
1071
+ "return",
1072
+ "tab",
1073
+ "space",
1074
+ "backspace",
1075
+ "delete",
1076
+ "insert",
1077
+ "clear",
1078
+ "home",
1079
+ "end",
1080
+ "pageup",
1081
+ "pagedown",
1082
+ "up",
1083
+ "down",
1084
+ "left",
1085
+ "right",
1086
+ ...Array.from({ length: 12 }, (_, index) => `f${index + 1}`),
1087
+ ]);
1088
+ const CONFIG_PRINTABLE_KEY = /^[a-z0-9`\-=\[\]\\;',.\/!@#$%^&*()_+|~{}:<>?"]$/;
1089
+
1090
+ /** 校验配置里的快捷键; 返回问题说明, 合法则返回 undefined. */
1091
+ function validateShortcut(key: string): string | undefined {
1092
+ const parts = key.split("+");
1093
+ const base = parts.pop() ?? "";
1094
+ if (!base) return t().validateMissingKey;
1095
+ for (const part of parts) {
1096
+ if (!CONFIG_MODIFIERS.has(part)) return t().validateBadModifier(part);
1097
+ }
1098
+ if (parts.length === 0 && !CONFIG_NAMED_KEYS.has(base)) {
1099
+ return t().validatePlainKey;
1100
+ }
1101
+ if (!CONFIG_NAMED_KEYS.has(base) && !CONFIG_PRINTABLE_KEY.test(base)) return t().validateBadKey(base);
1102
+ return undefined;
1103
+ }
1104
+
1105
+ interface TimelineConfig {
1106
+ shortcuts: string[];
1107
+ jumpTo: JumpTo;
1108
+ language: Language;
1109
+ /** 读配置时收集到的问题(启动后提示一次) */
1110
+ problems: string[];
1111
+ }
1112
+
1113
+ const JUMP_TO_DEFAULT: JumpTo = "user";
1114
+
1115
+ function defaultConfig(problems: string[] = [], shortcuts: string[] = JUMP_SHORTCUT_DEFAULT): TimelineConfig {
1116
+ return { shortcuts, jumpTo: JUMP_TO_DEFAULT, language: "auto", problems };
1117
+ }
1118
+ /** 配置里写得宽松一点: 中英文, 几种常见叫法都认 */
1119
+ const JUMP_TO_ALIASES: Record<string, JumpTo> = {
1120
+ user: "user",
1121
+ message: "user",
1122
+ prompt: "user",
1123
+ question: "user",
1124
+ 消息: "user",
1125
+ 提问: "user",
1126
+ reply: "reply",
1127
+ answer: "reply",
1128
+ assistant: "reply",
1129
+ below: "reply",
1130
+ content: "reply",
1131
+ body: "reply",
1132
+ 正文: "reply",
1133
+ 回答: "reply",
1134
+ 下方: "reply",
1135
+ };
1136
+
1137
+ /**
1138
+ * 包目录(源码在它的 `src/` 里, 旧位置的 config.json 也放在这一层).
1139
+ * jiti 加载时会注入 `__dirname`, 指向源码目录(`src/`), 所以往上一级;
1140
+ * 万一没有(比如打包进二进制的形态)就按约定路径推.
1141
+ */
1142
+ function extensionDir(): string {
1143
+ try {
1144
+ if (typeof __dirname === "string" && __dirname) return join(__dirname, "..");
1145
+ } catch {
1146
+ /* 环境里没有 __dirname */
1147
+ }
1148
+ return join(getAgentDir(), "extensions", "timeline");
1149
+ }
1150
+
1151
+ /**
1152
+ * 配置文件只有一个位置: `~/.pi/agent/timeline.json`.
1153
+ *
1154
+ * 为什么不放包目录里: 包目录升级时会被整体替换, 配置写在里面就有丢的路径; 而且本地开发和实际
1155
+ * 使用本来就是同一份配置, 没必要两边兜. 要沙箱就把 `PI_CODING_AGENT_DIR` 指到临时目录.
1156
+ */
1157
+ function configPath(): string {
1158
+ return join(getAgentDir(), CONFIG_FILE_NAME);
1159
+ }
1160
+
1161
+ /** 早期位置: 扩展目录里的 `config.json`(本地开发 / 旧版本). 只用来读一次, 读到就搬走. */
1162
+ function legacyConfigPath(): string {
1163
+ return join(extensionDir(), "config.json");
1164
+ }
1165
+
1166
+ /** 面板页脚的路径: 用 `~` 替掉主目录前缀. Windows 下的完整长路径会折成两行, 挤掉列表空间. */
1167
+ function shortenHomePath(path: string): string {
1168
+ try {
1169
+ const home = homedir();
1170
+ if (home && path.startsWith(home)) {
1171
+ const rest = path.slice(home.length);
1172
+ if (rest === "" || rest.startsWith("/") || rest.startsWith("\\")) return `~${rest}`;
1173
+ }
1174
+ } catch {
1175
+ /* 取不到主目录就原样显示 */
1176
+ }
1177
+ return path;
1178
+ }
1179
+
1180
+ /** 读原始 JSON(保留我们不认识的字段); 文件不存在或坏了就当空对象. */
1181
+ function readRawConfig(path: string): Record<string, any> {
1182
+ try {
1183
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
1184
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1185
+ } catch {
1186
+ return {};
1187
+ }
1188
+ }
1189
+
1190
+ /** 改几项设置并写回配置(只动这几项, 文件里别的字段原样保留). 返回写入的路径. */
1191
+ function writeConfigPatch(patch: Record<string, unknown>): string {
1192
+ const target = configPath();
1193
+ const base = readRawConfig(target);
1194
+ mkdirSync(dirname(target), { recursive: true });
1195
+ writeFileSync(target, `${JSON.stringify({ ...base, ...patch }, null, 2)}\n`, "utf8");
1196
+ return target;
1197
+ }
1198
+
1199
+ /** 首次使用时落一份默认配置: 让用户找得到这个文件, 能手改(和兄弟包一致). */
1200
+ function ensureConfigFile(): void {
1201
+ if (existsSync(configPath())) return;
1202
+ writeConfigPatch({ shortcut: [...JUMP_SHORTCUT_DEFAULT], jumpTo: JUMP_TO_DEFAULT, language: "auto" });
1203
+ }
1204
+
1205
+ /**
1206
+ * 读配置: 快捷键 + 落脚点 + 界面语言.
1207
+ *
1208
+ * 为什么快捷键不用 keybindings.json: Pi 的扩展快捷键是按字面按键字符串匹配的
1209
+ * (内部就是 matchesKey(输入, 注册时那个字符串)), 没有动作 id 可以中转.
1210
+ * 也不用 --flag: 扩展 flag 的值是扩展加载完之后才填进去的, 而注册快捷键发生在加载期.
1211
+ */
1212
+ function loadConfig(): TimelineConfig {
1213
+ const problems: string[] = [];
1214
+ let path = configPath();
1215
+
1216
+ // 旧位置(扩展目录里的 config.json)的配置搬过来, 只搬一次;
1217
+ // 那份 JSON 本身是坏的就不搬, 直接在原地按"读配置失败"报错
1218
+ if (!existsSync(path)) {
1219
+ const legacy = legacyConfigPath();
1220
+ if (existsSync(legacy)) {
1221
+ try {
1222
+ const text = readFileSync(legacy, "utf8");
1223
+ JSON.parse(text);
1224
+ mkdirSync(dirname(path), { recursive: true });
1225
+ writeFileSync(path, text.endsWith("\n") ? text : `${text}\n`, "utf8");
1226
+ try {
1227
+ unlinkSync(legacy);
1228
+ } catch {
1229
+ /* 删不掉就留着, 不影响使用 */
1230
+ }
1231
+ } catch {
1232
+ path = legacy;
1233
+ }
1234
+ }
1235
+ }
1236
+
1237
+ // 还没读到配置之前先按 auto 挑文案(报错信息也要有语言)
1238
+ currentLanguage = "auto";
1239
+ if (!existsSync(path)) return defaultConfig(problems);
1240
+
1241
+ let raw: any;
1242
+ try {
1243
+ raw = JSON.parse(readFileSync(path, "utf8"));
1244
+ } catch (error) {
1245
+ return defaultConfig([t().configParseError(error instanceof Error ? error.message : String(error), path)]);
1246
+ }
1247
+
1248
+ // 界面语言: "zh" / "en" 写死, 其余一律按 "auto" 跟随系统区域
1249
+ const rawLanguage = typeof raw?.language === "string" ? raw.language.trim().toLowerCase() : "";
1250
+ const language: Language = rawLanguage === "zh" || rawLanguage === "en" ? rawLanguage : "auto";
1251
+ currentLanguage = language;
1252
+
1253
+ // 落脚点: 跳到消息本身还是它下面的正文
1254
+ let jumpTo: JumpTo = JUMP_TO_DEFAULT;
1255
+ const jumpToValue = raw?.jumpTo ?? raw?.anchor ?? raw?.target;
1256
+ if (jumpToValue !== undefined) {
1257
+ const alias = typeof jumpToValue === "string" ? JUMP_TO_ALIASES[jumpToValue.trim().toLowerCase()] : undefined;
1258
+ if (alias) jumpTo = alias;
1259
+ else problems.push(t().configBadJumpTo(JSON.stringify(jumpToValue), path));
1260
+ }
1261
+
1262
+ // 快捷键
1263
+ const value = raw?.shortcut;
1264
+ if (value === undefined) return { ...defaultConfig(problems), jumpTo, language };
1265
+ if (value === null || value === false || value === "") return { shortcuts: [], jumpTo, language, problems };
1266
+
1267
+ const entries: unknown[] = Array.isArray(value) ? value : [value];
1268
+ if (entries.length === 0) return { shortcuts: [], jumpTo, language, problems }; // [] = 不要快捷键
1269
+ if (entries.some((item) => typeof item !== "string")) {
1270
+ problems.push(t().configBadShortcutType(path));
1271
+ return { ...defaultConfig(problems), jumpTo, language };
1272
+ }
1273
+
1274
+ const shortcuts: string[] = [];
1275
+ const seen = new Set<string>();
1276
+ for (const entry of entries as string[]) {
1277
+ const key = entry.trim().toLowerCase();
1278
+ if (!key || seen.has(key)) continue;
1279
+ const problem = validateShortcut(key);
1280
+ if (problem) {
1281
+ problems.push(t().configBadShortcut(entry, problem, path));
1282
+ continue;
1283
+ }
1284
+ seen.add(key);
1285
+ shortcuts.push(key);
1286
+ }
1287
+
1288
+ if (shortcuts.length === 0) {
1289
+ problems.push(t().configNoShortcut(JUMP_SHORTCUT_DEFAULT.join(", ")));
1290
+ return { shortcuts: JUMP_SHORTCUT_DEFAULT, jumpTo, language, problems };
1291
+ }
1292
+ return { shortcuts, jumpTo, language, problems };
1293
+ }
1294
+
1295
+ function noticeForMissingView(mode: string): { title: string; body: string[]; hint: string } {
1296
+ if (mode === "fullscreen") {
1297
+ return {
1298
+ title: t().noticeNoViewportTitle,
1299
+ body: [t().noticeNoViewportBody],
1300
+ hint: t().noticeCloseHint,
1301
+ };
1302
+ }
1303
+ return {
1304
+ title: t().noticeFullscreenTitle,
1305
+ body: [t().noticeFullscreenBody1, t().noticeFullscreenBody2],
1306
+ hint: t().noticeFullscreenHint,
1307
+ };
1308
+ }
1309
+
1310
+ /** 拿不到视口时的说明面板(也要有完整的框, 别只是一行字飘在屏幕上). */
1311
+ function buildMissingViewPanel(mode: string, theme: Theme, done: (result: null) => void): Component {
1312
+ const notice = noticeForMissingView(mode);
1313
+ const container = new Container();
1314
+ container.addChild(new Text(theme.fg("warning", theme.bold(notice.title)), 1, 0));
1315
+ for (const line of notice.body) container.addChild(new Text(theme.fg("text", line), 1, 0));
1316
+ container.addChild(new Text(theme.fg("accent", notice.hint), 1, 0));
1317
+
1318
+ return {
1319
+ ...framed(container, theme),
1320
+ handleInput() {
1321
+ done(null);
1322
+ },
1323
+ };
1324
+ }
1325
+
1326
+ /**
1327
+ * 设置面板 / 降级对话框.
1328
+ *
1329
+ * Pi 的 /settings 是内置的固定列表(字段写死在 SettingsConfig 里, 扩展没有接入点), 所以这里用 pi
1330
+ * 自己的 SettingsList 拼一个同样式的面板: 上下 DynamicBorder + 标题(缩进 2) + 列表 + 页脚(配置文件
1331
+ * 路径, 同样缩进 2). 面板与"有 UI 但没有自定义组件"的宿主共用一张设置项表(MENU), 改一项就立即
1332
+ * 写回配置文件.
1333
+ */
1334
+ const CUSTOM_SHORTCUT = "__custom__";
1335
+ const SHORTCUT_OFF = "__off__";
1336
+ const SHORTCUT_CHOICES = ["alt+t", "alt+h", "ctrl+h"];
1337
+
1338
+ interface SettingsResult {
1339
+ /** 用户选了「自定义...」, 调用方还要问一次键 */
1340
+ customShortcut?: boolean;
1341
+ /** 语言真的换了, 调用方要用新语言重开面板 */
1342
+ languageChanged?: boolean;
1343
+ }
1344
+
1345
+ /** 设置项表: 面板与降级对话框共用; 行的 id 就是配置字段名. 新增设置只改这里 + i18n.ts */
1346
+ interface SettingDef {
1347
+ id: SettingId;
1348
+ /** 有 values = 本行回车直接切换; 没有 = 回车进入候选子菜单(候选只有显示文案, 反查成配置值) */
1349
+ values?: (m: Messages) => string[];
1350
+ choices?: (m: Messages) => string[];
1351
+ /** 当前值(显示文案) */
1352
+ current: (config: TimelineConfig, m: Messages) => string;
1353
+ }
1354
+
1355
+ const MENU: SettingDef[] = [
1356
+ {
1357
+ id: "jumpTo",
1358
+ values: (m) => JUMP_TO_VALUES.map((jump) => m.jumpToLabels[jump]),
1359
+ current: (config, m) => m.jumpToLabels[config.jumpTo],
1360
+ },
1361
+ {
1362
+ id: "shortcut",
1363
+ choices: (m) => [...SHORTCUT_CHOICES, m.customShortcutChoice, m.shortcutOffChoice],
1364
+ current: (config, m) => shortcutText(config, m),
1365
+ },
1366
+ // 语言项放最后: 前两项用得最多, 而且行位置一变, 按 ↓ 次数定位的用例就得跟着改
1367
+ {
1368
+ id: "language",
1369
+ choices: (m) => LANGUAGES.map((lang) => m.languageLabels[lang]),
1370
+ current: (config, m) => m.languageLabels[config.language],
1371
+ },
1372
+ ];
1373
+
1374
+ function shortcutText(config: TimelineConfig, m: Messages): string {
1375
+ return config.shortcuts.join(" / ") || m.shortcutOffValue;
1376
+ }
1377
+
1378
+ /** 显示文案 → 配置值: 一律拿当前 Messages 反查, 不写死语言名 */
1379
+ function labelToJumpTo(value: string, m: Messages): JumpTo {
1380
+ return JUMP_TO_VALUES.find((jump) => m.jumpToLabels[jump] === value) ?? JUMP_TO_DEFAULT;
1381
+ }
1382
+
1383
+ function labelToLanguage(value: string, m: Messages): Language {
1384
+ return LANGUAGES.find((lang) => m.languageLabels[lang] === value) ?? "auto";
1385
+ }
1386
+
1387
+ /**
1388
+ * 显示文案 → 配置值: 面板与降级对话框的回调拿到的都是显示文案, 一律拿同一张 Messages 反查,
1389
+ * 不写死语言名或键名.
1390
+ */
1391
+ function labelToValue(id: SettingId, label: string, m: Messages): string {
1392
+ if (id === "jumpTo") return labelToJumpTo(label, m);
1393
+ if (id === "language") return labelToLanguage(label, m);
1394
+ if (label === m.shortcutOffChoice) return SHORTCUT_OFF;
1395
+ if (label === m.customShortcutChoice) return CUSTOM_SHORTCUT;
1396
+ return label; // 预设键: 显示文案就是键本身
1397
+ }
1398
+
1399
+ /** 把配置值写回文件(入参是反查过的配置值, 不是显示文案) */
1400
+ function applySetting(id: SettingId, value: string): void {
1401
+ if (id === "jumpTo") writeConfigPatch({ jumpTo: value });
1402
+ else if (id === "language") writeConfigPatch({ language: value });
1403
+ else if (id === "shortcut") writeConfigPatch({ shortcut: value === SHORTCUT_OFF ? null : value });
1404
+ }
1405
+
1406
+ /** 原生设置列表主题(与 /settings 一样式). 用传入的 theme 着色: 扩展经 jiti 加载, 全局主题不一定初始化过. */
1407
+ function makeListTheme(theme: Theme): SettingsListTheme {
1408
+ return {
1409
+ label: (text, selected) => (selected ? theme.fg("accent", text) : text),
1410
+ value: (text, selected) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)),
1411
+ description: (text) => theme.fg("dim", text),
1412
+ cursor: theme.fg("accent", "→ "),
1413
+ hint: (text) => theme.fg("dim", text),
1414
+ };
1415
+ }
1416
+
1417
+ /**
1418
+ * 二级菜单用的 SelectList 主题, 同样从传入的 theme 派生.
1419
+ * 逐字段照抄 pi 原生的 `getSelectListTheme()`: scrollInfo / noMatch 是 muted(不是 dim).
1420
+ */
1421
+ function makeSelectTheme(theme: Theme): SelectListTheme {
1422
+ return {
1423
+ selectedPrefix: (text) => theme.fg("accent", text),
1424
+ selectedText: (text) => theme.fg("accent", text),
1425
+ description: (text) => theme.fg("muted", text),
1426
+ scrollInfo: (text) => theme.fg("muted", text),
1427
+ noMatch: (text) => theme.fg("muted", text),
1428
+ };
1429
+ }
1430
+
1431
+ /**
1432
+ * 二级菜单: 从若干显示文案里选一个(反查配置值由 labelToValue 负责).
1433
+ *
1434
+ * 顶部空一行: 一级列表那里是"搜索行 + 空行", 子菜单没有搜索行, 不补就会和面板标题贴在一起.
1435
+ * 用 Spacer + 转发输入/鼠标(不要自己拼字符串--那样会把子菜单的鼠标命中行号错开).
1436
+ */
1437
+ /**
1438
+ * 面板的外框状态: 进了子菜单就把标题换成那一项的名字, 出来再换回去.
1439
+ * 标题是渲染时现看的, 所以不能只建一次 Text 就不管了.
1440
+ */
1441
+ interface PanelChrome {
1442
+ /** 子菜单打开: 传那一项的行标签; 关闭: 不传 */
1443
+ show(label?: string): void;
1444
+ }
1445
+
1446
+ /**
1447
+ * 子菜单的外壳: 顶部空一行(一级列表那里是"搜索行 + 空行"), 底部也空一行(跟页脚隔开).
1448
+ * 用 Spacer + Container 转发输入/鼠标, 不要自己拼字符串--那样会把鼠标命中行号错开.
1449
+ */
1450
+ function wrapSubmenu(inner: Component): Component {
1451
+ const container = new Container();
1452
+ container.addChild(new Spacer(1));
1453
+ container.addChild(inner);
1454
+ container.addChild(new Spacer(1));
1455
+ return {
1456
+ render: (width: number) => container.render(width),
1457
+ invalidate: () => container.invalidate(),
1458
+ handleInput: (data: string) => inner.handleInput(data),
1459
+ handleMouse: (event: any) => container.handleMouse(event),
1460
+ };
1461
+ }
1462
+
1463
+ /** 二级菜单: 从若干显示文案里选一个(反查配置值由 labelToValue 负责). 光标预选在当前值那项上. */
1464
+ function chooseFrom(
1465
+ labels: string[],
1466
+ current: string,
1467
+ theme: Theme,
1468
+ rowLabel: string,
1469
+ chrome: PanelChrome,
1470
+ done: (label?: string) => void,
1471
+ ): Component {
1472
+ const items = labels.map((label) => ({ value: label, label }));
1473
+ const list = new SelectList(items, Math.min(items.length, 10), makeSelectTheme(theme));
1474
+ // 光标停在当前值那项上(当前值不在候选里--比如自定义快捷键--就停在第一项)
1475
+ const selected = items.findIndex((item) => item.label === current);
1476
+ if (selected > 0) list.setSelectedIndex(selected);
1477
+
1478
+ // 进子菜单: 标题换成这一项的名字; 选完 / 取消再换回面板名
1479
+ chrome.show(rowLabel);
1480
+ const finish = (label?: string) => {
1481
+ chrome.show();
1482
+ done(label);
1483
+ };
1484
+ list.onSelect = (item) => finish(item.label);
1485
+ list.onCancel = () => finish();
1486
+
1487
+ return wrapSubmenu(list);
1488
+ }
1489
+
1490
+ /** 一级列表的条目: 文案与当前值都从同一张 Messages 里取 */
1491
+ function buildItems(config: TimelineConfig, m: Messages, theme: Theme, chrome: PanelChrome): SettingItem[] {
1492
+ return MENU.map((def): SettingItem => {
1493
+ const text = m.settings[def.id];
1494
+ const base = {
1495
+ id: def.id,
1496
+ label: text.label,
1497
+ description: text.description,
1498
+ currentValue: def.current(config, m),
1499
+ };
1500
+ if (def.values) return { ...base, values: def.values(m) };
1501
+ return {
1502
+ ...base,
1503
+ submenu: (_current, doneChoose) =>
1504
+ chooseFrom(def.choices?.(m) ?? [], def.current(config, m), theme, text.label, chrome, doneChoose),
1505
+ };
1506
+ });
1507
+ }
1508
+ /**
1509
+ * 搜索框前面的图标统一用 ⌕(与消息列表的搜索框一致).
1510
+ *
1511
+ * `SettingsList` 的搜索框是它自己 `new Input()` 出来的, 没有公开的 prompt 选项, 所以这里改它的
1512
+ * 内部字段; pi 要是改了这个字段就保持默认提示符, 不影响功能.
1513
+ */
1514
+ function useSearchGlyph(list: SettingsList): void {
1515
+ try {
1516
+ const input = (list as unknown as { searchInput?: { prompt?: string } }).searchInput;
1517
+ if (input && typeof input.prompt === "string") input.prompt = "⌕ ";
1518
+ } catch {
1519
+ /* 版本差异: 保持默认提示符 */
1520
+ }
1521
+ }
1522
+
1523
+ async function showSettingsPanel(ctx: ExtensionContext | ExtensionCommandContext): Promise<SettingsResult | null> {
1524
+ // 原地渲染(和原生 /settings 一样替换掉编辑区), 不传 overlay 选项
1525
+ return ctx.ui.custom<SettingsResult | null>((tui, theme, _kb, done) => {
1526
+ const config = loadConfig();
1527
+ const m = messages(config.language);
1528
+
1529
+ const border = (text: string) => theme.fg("border", text);
1530
+ const container = new Container();
1531
+ container.addChild(new DynamicBorder(border));
1532
+ // 缩进 2: SettingsList 的条目与提示行就是从第 2 列开始渲染的, 这样标题跟正文对齐
1533
+ const title = new Text(theme.fg("accent", theme.bold(m.settingsTitle)), 2, 0);
1534
+ container.addChild(title);
1535
+ // 进了子菜单, 标题换成那一项的名字(出来再换回面板名)
1536
+ const chrome: PanelChrome = {
1537
+ show: (label) => title.setText(theme.fg("accent", theme.bold(label ?? m.settingsTitle))),
1538
+ };
1539
+
1540
+ const list = new SettingsList(
1541
+ buildItems(config, m, theme, chrome),
1542
+ 10,
1543
+ makeListTheme(theme),
1544
+ (id, label) => {
1545
+ const value = labelToValue(id as SettingId, label, m);
1546
+ if (value === CUSTOM_SHORTCUT) {
1547
+ done({ customShortcut: true });
1548
+ return;
1549
+ }
1550
+ applySetting(id as SettingId, value);
1551
+ // 只有语言真的换了才重开面板(选同一个值不该反复开关)
1552
+ if (id === "language" && value !== config.language) {
1553
+ done({ languageChanged: true });
1554
+ }
1555
+ },
1556
+ () => done({}),
1557
+ { enableSearch: true },
1558
+ );
1559
+ useSearchGlyph(list);
1560
+ container.addChild(list);
1561
+ // 页脚给配置文件的路径(用 ~ 缩短), 用户得能找到这个文件
1562
+ container.addChild(new Text(theme.fg("dim", shortenHomePath(configPath())), 2, 0));
1563
+ container.addChild(new DynamicBorder(border));
1564
+
1565
+ return {
1566
+ render: (width: number) => container.render(width),
1567
+ invalidate: () => container.invalidate(),
1568
+ handleInput: (data: string) => {
1569
+ list.handleInput(data);
1570
+ tui.requestRender();
1571
+ },
1572
+ dispose: () => {},
1573
+ };
1574
+ });
1575
+ }
1576
+
1577
+ /** 没有界面时给出当前设置(命令在无 UI 宿主里被调用) */
1578
+ function statusText(config: TimelineConfig): string {
1579
+ const m = messages(config.language);
1580
+ return m.status(m.jumpToLabels[config.jumpTo], shortcutText(config, m), m.languageLabels[config.language]);
1581
+ }
1582
+
1583
+ /** 自定义快捷键: 界面里给不了反馈, 所以这里用 notify 报结果. 返回是否写盘成功. */
1584
+ async function askCustomShortcut(ctx: ExtensionContext | ExtensionCommandContext): Promise<boolean> {
1585
+ const m = t();
1586
+ const answer = await ctx.ui.input(m.customShortcutTitle, m.customShortcutPlaceholder);
1587
+ const key = answer?.trim().toLowerCase();
1588
+ if (!key) return false;
1589
+ const problem = validateShortcut(key);
1590
+ if (problem) {
1591
+ ctx.ui.notify(m.notifyBadShortcut(key, problem), "warning");
1592
+ return false;
1593
+ }
1594
+ writeConfigPatch({ shortcut: key });
1595
+ ctx.ui.notify(m.notifyShortcutChanged(key), "info");
1596
+ return true;
1597
+ }
1598
+
1599
+ /** 有 UI 但没有自定义组件的宿主(RPC 等): 按同一张表逐项问, 内容和面板一一对应. */
1600
+ async function openSettingsDialog(ctx: ExtensionContext | ExtensionCommandContext): Promise<void> {
1601
+ const m = t();
1602
+ let shortcutChanged = false;
1603
+ for (const def of MENU) {
1604
+ const text = m.settings[def.id];
1605
+ // 本行是回车切换的枚举, 还是进子菜单的候选列表
1606
+ const values = def.values ? def.values(m) : (def.choices?.(m) ?? []);
1607
+ const picked = await ctx.ui.select(m.pickPrompt(m.dialogTitle(text.label), def.current(loadConfig(), m)), values);
1608
+ if (picked === undefined) return; // 用户取消: 已经改过的项保留
1609
+ const value = labelToValue(def.id, picked, m);
1610
+ if (value === CUSTOM_SHORTCUT) {
1611
+ if (await askCustomShortcut(ctx)) shortcutChanged = true;
1612
+ continue;
1613
+ }
1614
+ applySetting(def.id, value);
1615
+ if (def.id === "shortcut") shortcutChanged = true;
1616
+ }
1617
+ ctx.ui.notify(shortcutChanged ? m.notifySavedWithShortcut : m.notifySaved, "info");
1618
+ }
1619
+
1620
+ /** 打开设置界面: TUI 开面板; 只有 UI 就逐项问; 都没有就报当前设置. */
1621
+ async function openTimelineSettings(ctx: ExtensionContext | ExtensionCommandContext): Promise<void> {
1622
+ if (ctx.mode !== "tui") {
1623
+ if (ctx.hasUI) await openSettingsDialog(ctx);
1624
+ else ctx.ui.notify(statusText(loadConfig()), "info");
1625
+ return;
1626
+ }
1627
+
1628
+ for (;;) {
1629
+ const result = await showSettingsPanel(ctx);
1630
+ if (!result) return;
1631
+ // 语言换了: 用新语言重开面板(循环, 不是递归--递归会把栈越堆越深)
1632
+ if (result.languageChanged) continue;
1633
+ if (result.customShortcut) await askCustomShortcut(ctx);
1634
+ return;
1635
+ }
1636
+ }
1637
+
1638
+ async function openTimeline(ctx: ExtensionContext | ExtensionCommandContext): Promise<void> {
1639
+ if (ctx.mode !== "tui") {
1640
+ ctx.ui.notify(t().notifyTuiOnly, "warning");
1641
+ return;
1642
+ }
1643
+
1644
+ // 刷新配置(顺便刷新界面语言: 文案可能刚在设置面板里换过)
1645
+ loadConfig();
1646
+ const { items } = buildPickerData(ctx);
1647
+ if (items.length === 0) {
1648
+ ctx.ui.notify(t().notifyNoMessages, "info");
1649
+ return;
1650
+ }
1651
+
1652
+ // jumpTo 每次调用都重读, 所以在设置面板里改完立即生效, 不用 /reload
1653
+ const picked = await pickOnce(ctx, items);
1654
+ if (!picked) return;
1655
+
1656
+ const { index, action } = picked.result;
1657
+ const item = items[index];
1658
+ if (!item) return;
1659
+
1660
+ if (action === "insert") {
1661
+ ctx.ui.setEditorText(item.text);
1662
+ ctx.ui.notify(t().notifyInserted, "info");
1663
+ return;
1664
+ }
1665
+
1666
+ // 跳转时重新定位一次: 等待选择期间 transcript 可能又长了
1667
+ const { rows, contentHeight } = locateRows(items, picked.tui, picked.view, loadConfig().jumpTo);
1668
+ const row = rows[index];
1669
+ if (row === undefined) {
1670
+ ctx.ui.notify(t().notifyNotLocatable, "warning");
1671
+ return;
1672
+ }
1673
+
1674
+ jumpToRow(picked.view, row, contentHeight);
1675
+ debugDump({
1676
+ event: "jump",
1677
+ selected: { index, text: item.text.slice(0, 60) },
1678
+ row,
1679
+ viewportTop: (picked.view as { scrollTop?: number }).scrollTop,
1680
+ contentHeight: (picked.view as { contentHeight?: number }).contentHeight,
1681
+ viewportHeight: (picked.view as { viewportHeight?: number }).viewportHeight,
1682
+ ...lastLocateDebug,
1683
+ });
1684
+ (picked.tui as { requestRender?: (force?: boolean) => void }).requestRender?.();
1685
+ (picked.tui as { flash?: (message: string, durationMs?: number) => void }).flash?.(`→ #${index + 1} ${item.preview}`);
1686
+ }
1687
+
1688
+ /** 开一次列表界面, 拿到用户的选择(以及用到的 TUI / 视口). */
1689
+ async function pickOnce(
1690
+ ctx: ExtensionContext | ExtensionCommandContext,
1691
+ items: TimelineItem[],
1692
+ ): Promise<{ result: PickerResult; tui: unknown; view: ScrollViewLike } | undefined> {
1693
+ // jumpTo 每次调用都重读, 这样在设置面板里改完立即生效, 不用 /reload
1694
+ const { jumpTo } = loadConfig();
1695
+
1696
+ let capturedTui: unknown;
1697
+ let capturedView: ScrollViewLike | undefined;
1698
+ let missingViewMode: string | undefined;
1699
+ let currentIndex = -1;
1700
+
1701
+ const result = await ctx.ui.custom<PickerResult | null>(
1702
+ (tui, theme, kb, done) => {
1703
+ capturedTui = tui;
1704
+ capturedView = resolveTranscript(tui);
1705
+ if (!capturedView) {
1706
+ missingViewMode = (tui as { mode?: string }).mode ?? "";
1707
+ return buildMissingViewPanel(missingViewMode, theme, done);
1708
+ }
1709
+
1710
+ // 打开时就定位一遍: 既能标出"当前位置", 也能统计有多少条根本定位不到
1711
+ const { rows, blockRows } = locateRows(items, tui, capturedView, jumpTo);
1712
+ const top = capturedView.scrollTop;
1713
+ // "正在看哪一轮"按**整轮**算: 用户消息 + 它后面的工具和回复是一个块,
1714
+ // 视口落在这块里(哪怕在回复中间)就算正在看这条
1715
+ for (let index = 0; index < items.length; index++) {
1716
+ const row = blockRows[index];
1717
+ if (row !== undefined && row <= top) currentIndex = index;
1718
+ }
1719
+
1720
+ return buildPicker(items, rows, currentIndex, jumpTo, theme, tui as TUI, kb, done);
1721
+ },
1722
+ {
1723
+ overlay: true,
1724
+ // 拿不到视口时只是弹一段说明, 别给它撑成全屏那么大的面板
1725
+ overlayOptions: () =>
1726
+ missingViewMode === undefined
1727
+ ? { anchor: "center", width: "76%", minWidth: 52, maxHeight: "70%", margin: 1 }
1728
+ : { anchor: "center", width: "72%", minWidth: 46, margin: 1 },
1729
+ },
1730
+ );
1731
+
1732
+ if (!result) return undefined;
1733
+ if (!capturedView) return undefined;
1734
+ return { result, tui: capturedTui, view: capturedView };
1735
+ }
1736
+
1737
+ export default function timelineExtension(pi: ExtensionAPI) {
1738
+ const { shortcuts, problems } = loadConfig();
1739
+
1740
+ // 参数一律不解析: 命令就是"打开设置界面", 带了参数也不当设置项用(timeline-settings [ignored])
1741
+ // 命令描述固定成英文的 "<扩展名> Settings(<配置文件>)", 不参与 i18n(斜杠命令的约定)
1742
+ pi.registerCommand("timeline-settings", {
1743
+ description: "Timeline Settings(timeline.json)",
1744
+ handler: async (_args, ctx) => {
1745
+ await openTimelineSettings(ctx);
1746
+ },
1747
+ });
1748
+
1749
+ for (const shortcut of shortcuts) {
1750
+ pi.registerShortcut(shortcut as KeyId, {
1751
+ description: t().shortcutDescription,
1752
+ handler: async (ctx) => {
1753
+ await openTimeline(ctx);
1754
+ },
1755
+ });
1756
+ }
1757
+
1758
+ // 工厂阶段还没有 UI: 首次使用时落一份默认配置(让用户找得到这个文件),
1759
+ // 配置有问题也等会话起来再提示, 免得用户以为快捷键莫名其妙没了
1760
+ pi.on("session_start", (_event, ctx) => {
1761
+ try {
1762
+ ensureConfigFile();
1763
+ } catch {
1764
+ /* 只读目录等场景静默降级 */
1765
+ }
1766
+ for (const problem of problems) {
1767
+ ctx.ui.notify(`timeline: ${problem}`, "warning");
1768
+ }
1769
+ });
1770
+ }