pi-session-peek 0.1.0 → 0.2.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.
@@ -1,464 +1,489 @@
1
- import {
2
- Input,
3
- Key,
4
- matchesKey,
5
- truncateToWidth,
6
- visibleWidth,
7
- type Component,
8
- type Focusable,
9
- } from "@earendil-works/pi-tui";
10
- import { anyKw, highlight, parseQuery, snippet, type ParsedQuery } from "./query.ts";
11
- import type { PeekSession } from "./sessions.ts";
12
- import { fmtTime, normPath, padEndVisible, wrapLines } from "./text.ts";
13
-
14
- // 双栏选择器。文件操作(删除 / 重命名 / 分叉)不在这里做,由 index.ts 通过回调注入
15
-
16
- export class PeekComponent implements Component, Focusable {
17
- private input: Input;
18
- private scope: "current" | "all";
19
- private filtered: PeekSession[] = [];
20
- private selected = 0;
21
- private listOffset = 0;
22
- private previewOffset = 0;
23
- private previewKey = ""; // 预览缓存的键(会话 + 关键词 + 宽度),置空强制重建
24
- private previewLines: string[] = [];
25
- private matchLines: number[] = []; // 预览里命中消息所在的行,Ctrl+N / Ctrl+P 用
26
- private confirmingDelete = false; // Ctrl+D 之后等待确认
27
- private renaming = false; // Ctrl+R 之后正在输入名字
28
- private renameInput: Input;
29
- private cachedWidth = -1;
30
- private cachedLines: string[] = [];
31
- private _focused = false;
32
-
33
- // 由 index.ts 注入
34
- onResume?: (s: PeekSession) => void;
35
- onFork?: (s: PeekSession) => void;
36
- onCancel?: () => void;
37
- onDelete?: (s: PeekSession) => Promise<boolean>;
38
- onRename?: (s: PeekSession, name: string) => Promise<boolean>;
39
- requestRender?: () => void; // 异步操作完成后让 TUI 重画
40
-
41
- constructor(
42
- private all: PeekSession[],
43
- private currentCwd: string,
44
- private theme: any,
45
- private termRows: number,
46
- initialQuery: string,
47
- ) {
48
- this.input = new Input({ placeholder: "关键词过滤(空格分隔=同时命中;@7d 限定近 7 天)" });
49
- this.renameInput = new Input({ placeholder: "新会话名(Enter 确认 / Esc 取消,留空取消)" });
50
- if (initialQuery) this.input.setValue(initialQuery);
51
- // 当前目录树下有会话就默认当前目录,否则全局
52
- this.scope = all.some((s) => this.inCurrentTree(s)) ? "current" : "all";
53
- this.refilter();
54
- }
55
-
56
- // 焦点要落到真正在用的那个输入框上,否则 IME 候选框位置不对
57
- get focused(): boolean {
58
- return this._focused;
59
- }
60
- set focused(v: boolean) {
61
- this._focused = v;
62
- this.input.focused = v && !this.renaming;
63
- this.renameInput.focused = v && this.renaming;
64
- }
65
-
66
- getQuery(): string {
67
- return this.input.getValue().trim();
68
- }
69
-
70
- private query(): ParsedQuery {
71
- return parseQuery(this.getQuery());
72
- }
73
-
74
- // 等于当前目录,或在它的子目录下
75
- private inCurrentTree(s: PeekSession): boolean {
76
- const cur = normPath(this.currentCwd);
77
- const c = normPath(s.cwd);
78
- return c === cur || c.startsWith(cur + "/");
79
- }
80
-
81
- // keepSelection:尽量保持原来选中的会话,重命名后用
82
- private refilter(keepSelection = false): void {
83
- const prev = keepSelection ? this.filtered[this.selected] : undefined;
84
- const { kws, since } = this.query();
85
- let out = this.all;
86
- if (this.scope === "current") out = out.filter((s) => this.inCurrentTree(s));
87
- if (since) out = out.filter((s) => s.mtime >= since);
88
- if (kws.length) out = out.filter((s) => kws.every((k) => s.searchText.includes(k)));
89
- this.filtered = out;
90
- const idx = prev ? out.indexOf(prev) : -1;
91
- this.selected = idx >= 0 ? idx : keepSelection ? Math.min(this.selected, Math.max(0, out.length - 1)) : 0;
92
- if (!keepSelection) this.listOffset = 0;
93
- this.previewKey = "";
94
- }
95
-
96
- private bodyHeight(): number {
97
- const rows = process.stdout.rows || this.termRows || 24; // 每次都读,终端拉伸后布局跟着变
98
- return Math.max(8, Math.min(rows - 12, 30));
99
- }
100
-
101
- handleInput(data: string): void {
102
- // 重命名输入中:Enter 提交,Esc / Ctrl+C 取消,其他键交给输入框
103
- if (this.renaming) {
104
- if (matchesKey(data, Key.enter)) {
105
- const s = this.filtered[this.selected];
106
- const name = this.renameInput.getValue().trim();
107
- this.renaming = false;
108
- this.focused = this._focused;
109
- this.invalidate();
110
- if (s && name && this.onRename) {
111
- void this.onRename(s, name).then((ok) => {
112
- if (ok) {
113
- s.name = name;
114
- // 名字参与搜索,改完可能就不再匹配当前关键词了
115
- s.searchText = (s.msgs.map((m) => m.text).join(" ") + " " + name + " " + s.cwd).toLowerCase();
116
- this.refilter(true);
117
- }
118
- this.invalidate();
119
- this.requestRender?.();
120
- });
121
- }
122
- return;
123
- }
124
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
125
- this.renaming = false;
126
- this.focused = this._focused;
127
- this.invalidate();
128
- return;
129
- }
130
- this.renameInput.handleInput(data);
131
- this.invalidate();
132
- return;
133
- }
134
- // 删除确认中:y / Enter 执行,其他任何键取消
135
- if (this.confirmingDelete) {
136
- this.confirmingDelete = false;
137
- this.invalidate();
138
- if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
139
- const s = this.filtered[this.selected];
140
- if (s && this.onDelete) {
141
- void this.onDelete(s).then((ok) => {
142
- if (ok) {
143
- let i = this.all.indexOf(s);
144
- if (i >= 0) this.all.splice(i, 1);
145
- i = this.filtered.indexOf(s);
146
- if (i >= 0) this.filtered.splice(i, 1);
147
- // 选中位置留在原地
148
- this.selected = Math.min(this.selected, Math.max(0, this.filtered.length - 1));
149
- this.previewKey = "";
150
- }
151
- this.invalidate();
152
- this.requestRender?.();
153
- });
154
- }
155
- }
156
- return;
157
- }
158
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
159
- this.onCancel?.();
160
- return;
161
- }
162
- if (matchesKey(data, Key.ctrl("d"))) {
163
- if (this.filtered.length && this.onDelete) {
164
- this.confirmingDelete = true;
165
- this.invalidate();
166
- }
167
- return;
168
- }
169
- if (matchesKey(data, Key.ctrl("r"))) {
170
- const s = this.filtered[this.selected];
171
- if (s && this.onRename) {
172
- this.renaming = true;
173
- this.renameInput.setValue(s.name || "");
174
- this.focused = this._focused;
175
- this.invalidate();
176
- }
177
- return;
178
- }
179
- if (matchesKey(data, Key.tab)) {
180
- this.scope = this.scope === "all" ? "current" : "all";
181
- this.refilter();
182
- this.invalidate();
183
- return;
184
- }
185
- if (matchesKey(data, Key.up)) {
186
- if (this.selected > 0) {
187
- this.selected--;
188
- this.previewKey = "";
189
- this.invalidate();
190
- }
191
- return;
192
- }
193
- if (matchesKey(data, Key.down)) {
194
- if (this.selected < this.filtered.length - 1) {
195
- this.selected++;
196
- this.previewKey = "";
197
- this.invalidate();
198
- }
199
- return;
200
- }
201
- if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("u"))) {
202
- const step = matchesKey(data, Key.pageUp) ? this.bodyHeight() : Math.max(1, this.bodyHeight() >> 1);
203
- this.previewOffset = Math.max(0, this.previewOffset - step);
204
- this.invalidate();
205
- return;
206
- }
207
- if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) {
208
- const step = matchesKey(data, Key.pageDown) ? this.bodyHeight() : Math.max(1, this.bodyHeight() >> 1);
209
- this.previewOffset += step;
210
- this.invalidate();
211
- return;
212
- }
213
- if (matchesKey(data, Key.shift("up"))) {
214
- this.previewOffset = Math.max(0, this.previewOffset - 3);
215
- this.invalidate();
216
- return;
217
- }
218
- if (matchesKey(data, Key.shift("down"))) {
219
- this.previewOffset += 3;
220
- this.invalidate();
221
- return;
222
- }
223
- if (matchesKey(data, Key.ctrl("n"))) {
224
- this.jumpMatch(1);
225
- return;
226
- }
227
- if (matchesKey(data, Key.ctrl("p"))) {
228
- this.jumpMatch(-1);
229
- return;
230
- }
231
- if (matchesKey(data, Key.enter)) {
232
- const s = this.filtered[this.selected];
233
- if (s) this.onResume?.(s);
234
- return;
235
- }
236
- if (matchesKey(data, Key.ctrl("o"))) {
237
- const s = this.filtered[this.selected];
238
- if (s) this.onFork?.(s);
239
- return;
240
- }
241
- // 其余按键交给搜索框
242
- const before = this.input.getValue();
243
- this.input.handleInput(data);
244
- if (this.input.getValue() !== before) this.refilter(); // 光标移动不算输入,别重置列表
245
- this.invalidate();
246
- }
247
-
248
- // 预览里跳到下一个 / 上一个命中,到头了回绕
249
- private jumpMatch(dir: 1 | -1): void {
250
- if (!this.matchLines.length) return;
251
- const cur = this.previewOffset + 2;
252
- let target: number | undefined;
253
- if (dir === 1) {
254
- target = this.matchLines.find((l) => l > cur) ?? this.matchLines[0];
255
- } else {
256
- target = [...this.matchLines].reverse().find((l) => l < cur) ?? this.matchLines[this.matchLines.length - 1];
257
- }
258
- if (target !== undefined) {
259
- this.previewOffset = Math.max(0, target - 2);
260
- this.invalidate();
261
- }
262
- }
263
-
264
- // 左栏,每个会话两行
265
- private buildList(height: number, lw: number): string[] {
266
- const t = this.theme;
267
- const { kws } = this.query();
268
- const visible = Math.max(1, Math.floor(height / 2));
269
- if (this.selected < this.listOffset) this.listOffset = this.selected;
270
- if (this.selected >= this.listOffset + visible) {
271
- this.listOffset = this.selected - visible + 1;
272
- }
273
-
274
- const rows: string[] = [];
275
- for (let i = 0; i < visible; i++) {
276
- const idx = this.listOffset + i;
277
- const s = this.filtered[idx];
278
- if (!s) {
279
- rows.push(" ".repeat(lw), " ".repeat(lw));
280
- continue;
281
- }
282
- const sel = idx === this.selected;
283
- const time = fmtTime(s.time);
284
- const cwdTail = s.cwd.replace(/\\/g, "/").split("/").slice(-2).join("/");
285
- // 有关键词时显示命中的消息数
286
- const hitBadge = kws.length
287
- ? ` ·${s.msgs.reduce((n, m) => n + (anyKw(m.text.toLowerCase(), kws) ? 1 : 0), 0)}`
288
- : "";
289
- const l1 = `${sel ? "›" : " "} ${time} ${cwdTail}${hitBadge}`;
290
- // 第二行:有关键词时显示命中片段,否则显示首条消息
291
- const snip = snippet(s.msgs, kws, lw);
292
- const l2 = snip !== undefined
293
- ? ` ${highlight(snip, kws, t)}`
294
- : ` ${s.first}${s.name ? ` [${s.name}]` : ""}`;
295
- if (sel) {
296
- rows.push(t.bg("selectedBg", padEndVisible(t.fg("accent", truncateToWidth(l1, lw)), lw)));
297
- rows.push(t.bg("selectedBg", padEndVisible(truncateToWidth(l2, lw), lw)));
298
- } else {
299
- rows.push(padEndVisible(l1, lw));
300
- rows.push(padEndVisible(t.fg("dim", truncateToWidth(l2, lw)), lw));
301
- }
302
- }
303
- return rows.slice(0, height);
304
- }
305
-
306
- // 右栏。有关键词时滚到第一个命中处,没有时滚到底部看最新消息
307
- private buildPreview(rw: number): void {
308
- const t = this.theme;
309
- const s = this.filtered[this.selected];
310
- const key = s ? `${s.path}|${this.getQuery()}|${rw}` : "none";
311
- if (key === this.previewKey) return;
312
- this.previewKey = key;
313
- this.matchLines = [];
314
-
315
- if (!s) {
316
- this.previewLines = [t.fg("dim", "没有匹配的会话")];
317
- this.previewOffset = 0;
318
- return;
319
- }
320
-
321
- const lines: string[] = [];
322
- lines.push(
323
- truncateToWidth(
324
- t.fg("dim", `${s.cwd} • ${s.msgs.length} 条消息${s.name ? " • " + s.name : ""}`),
325
- rw,
326
- ),
327
- );
328
- lines.push("");
329
-
330
- const { kws } = this.query();
331
- const MAX_MSGS = 500;
332
- const truncated = s.msgs.length > MAX_MSGS;
333
- const msgs = truncated ? s.msgs.slice(-MAX_MSGS) : s.msgs;
334
- if (truncated) {
335
- lines.push(t.fg("dim", `(会话过长,仅显示最后 ${MAX_MSGS} 条)`));
336
- lines.push("");
337
- }
338
- if (kws.length && !msgs.some((m) => anyKw(m.text.toLowerCase(), kws))) {
339
- const inTruncated = truncated && s.msgs.some((m) => anyKw(m.text.toLowerCase(), kws));
340
- lines.push(
341
- t.fg(
342
- "warning",
343
- inTruncated ? "关键词命中在未显示的更早消息中" : "关键词仅命中会话名或目录,对话正文无匹配",
344
- ),
345
- );
346
- lines.push("");
347
- }
348
-
349
- // 用户消息整块背景色,AI 消息只有一条细线标签
350
- const fillLabel = (label: string): string => {
351
- const w = visibleWidth(label);
352
- return truncateToWidth(label + " " + "─".repeat(Math.max(2, rw - w - 1)), rw);
353
- };
354
-
355
- for (const m of msgs) {
356
- const isMatch = kws.length > 0 && anyKw(m.text.toLowerCase(), kws);
357
- if (isMatch) this.matchLines.push(lines.length);
358
- const body = isMatch ? highlight(m.text, kws, t) : m.text;
359
-
360
- if (m.role === "user") {
361
- lines.push(
362
- t.bg("userMessageBg", padEndVisible(t.bold(t.fg("accent", fillLabel("👤 你"))), rw)),
363
- );
364
- for (const wl of wrapLines(body, rw - 2)) {
365
- lines.push(t.bg("userMessageBg", padEndVisible(" " + wl, rw)));
366
- }
367
- } else {
368
- lines.push(t.fg("muted", fillLabel("🤖 AI")));
369
- for (const wl of wrapLines(body, rw - 2)) lines.push(" " + wl);
370
- }
371
- lines.push("");
372
- }
373
-
374
- this.previewLines = lines;
375
-
376
- if (this.matchLines.length) {
377
- this.previewOffset = Math.max(0, this.matchLines[0] - 2);
378
- } else {
379
- this.previewOffset = Math.max(0, lines.length - this.bodyHeight());
380
- }
381
- }
382
-
383
- render(width: number): string[] {
384
- const H = this.bodyHeight();
385
- const cacheKey = width * 1000 + H; // 宽或高变了都要重画
386
- if (this.cachedWidth === cacheKey) return this.cachedLines;
387
- const t = this.theme;
388
- const lw = Math.max(26, Math.min(56, Math.floor(width * 0.4)));
389
- const rw = Math.max(20, width - lw - 3);
390
-
391
- const out: string[] = [];
392
-
393
- // 头部:标题、范围、时间过滤、匹配数,然后是搜索框
394
- const scopeLabel = this.scope === "all" ? "全局" : "当前目录树";
395
- const { sinceLabel } = this.query();
396
- const head =
397
- t.fg("accent", t.bold("🔍 会话搜索预览")) +
398
- t.fg("dim", " 范围[Tab]: ") +
399
- t.fg("warning", scopeLabel) +
400
- (sinceLabel ? t.fg("dim", " 时间: ") + t.fg("warning", sinceLabel) : "") +
401
- t.fg("dim", ` 匹配 ${this.filtered.length}/${this.all.length}`);
402
- out.push(truncateToWidth(head, width));
403
- out.push(this.input.render(width)[0] ?? "");
404
- out.push(t.fg("borderMuted", "─".repeat(width)));
405
-
406
- // 双栏
407
- this.buildPreview(rw);
408
- const leftRows = this.buildList(H, lw);
409
-
410
- const maxOff = Math.max(0, this.previewLines.length - H);
411
- if (this.previewOffset > maxOff) this.previewOffset = maxOff;
412
- const rightRows = this.previewLines.slice(this.previewOffset, this.previewOffset + H);
413
- const scrollInfo =
414
- this.previewLines.length > H
415
- ? t.fg(
416
- "dim",
417
- ` (${this.previewOffset + 1}-${Math.min(this.previewOffset + H, this.previewLines.length)}/${this.previewLines.length})`,
418
- )
419
- : "";
420
-
421
- for (let i = 0; i < H; i++) {
422
- const l = leftRows[i] ?? "";
423
- let r = rightRows[i] ?? "";
424
- if (i === 0 && scrollInfo) {
425
- r = truncateToWidth(r, Math.max(0, rw - visibleWidth(scrollInfo))) + scrollInfo;
426
- }
427
- // 左栏在 buildList 里已经 pad 到 lw,这里再 pad 会把带样式的行截坏
428
- out.push(l + t.fg("borderMuted", " │ ") + padEndVisible(r, rw));
429
- }
430
-
431
- // 底部:重命名和删除确认时换成对应的操作行
432
- out.push(t.fg("borderMuted", "─".repeat(width)));
433
- if (this.renaming) {
434
- const prefix = "✏ 重命名: ";
435
- const inputLine = this.renameInput.render(Math.max(10, width - visibleWidth(prefix)))[0] ?? "";
436
- out.push(truncateToWidth(t.fg("warning", prefix) + inputLine, width));
437
- } else if (this.confirmingDelete) {
438
- const s = this.filtered[this.selected];
439
- const desc = s ? `${fmtTime(s.time, true)} ${s.first.slice(0, 30)}` : "";
440
- out.push(
441
- truncateToWidth(
442
- t.fg("error", t.bold(`⚠ 删除会话 [${desc}]?按 y / Enter 确认,任意其他键取消`)),
443
- width,
444
- ),
445
- );
446
- } else {
447
- out.push(
448
- truncateToWidth(
449
- t.fg("dim", "↑↓ 选择 PgUp/Dn·^U/^F·⇧↑↓ 滚动 ^N/^P 命中 Tab 范围 ^D 删除 ^R 改名 ^O 分叉 Enter 进入 Esc/^C 关闭"),
450
- width,
451
- ),
452
- );
453
- }
454
-
455
- this.cachedWidth = cacheKey;
456
- this.cachedLines = out;
457
- return out;
458
- }
459
-
460
- invalidate(): void {
461
- this.cachedWidth = -1;
462
- this.input.invalidate();
463
- }
464
- }
1
+ import {
2
+ Input,
3
+ Key,
4
+ Markdown,
5
+ matchesKey,
6
+ truncateToWidth,
7
+ visibleWidth,
8
+ type Component,
9
+ type Focusable,
10
+ type MarkdownTheme,
11
+ } from "@earendil-works/pi-tui";
12
+ import { msg } from "./i18n.ts";
13
+ import { anyKw, highlight, parseQuery, snippet, type ParsedQuery } from "./query.ts";
14
+ import type { PeekMsg, PeekSession } from "./sessions.ts";
15
+ import { fmtTime, normPath, padEndVisible } from "./text.ts";
16
+
17
+ // 双栏选择器。文件操作(删除 / 重命名 / 分叉)不在这里做,由 index.ts 通过回调注入
18
+
19
+ export class PeekComponent implements Component, Focusable {
20
+ private input: Input;
21
+ private scope: "current" | "all";
22
+ private filtered: PeekSession[] = [];
23
+ private selected = 0;
24
+ private listOffset = 0;
25
+ private previewOffset = 0;
26
+ private previewKey = ""; // 预览缓存的键(会话 + 关键词 + 宽度),置空强制重建
27
+ private previewLines: string[] = [];
28
+ private matchLines: number[] = []; // 预览里命中消息所在的行,Ctrl+N / Ctrl+P 用
29
+ private rendered = new WeakMap<PeekMsg, { w: number; lines: string[] }>(); // 每条消息渲染好的行,按宽度缓存
30
+ private confirmingDelete = false; // Ctrl+D 之后等待确认
31
+ private renaming = false; // Ctrl+R 之后正在输入名字
32
+ private renameInput: Input;
33
+ private cachedWidth = -1;
34
+ private cachedLines: string[] = [];
35
+ private _focused = false;
36
+
37
+ // 由 index.ts 注入
38
+ onResume?: (s: PeekSession) => void;
39
+ onFork?: (s: PeekSession) => void;
40
+ onCancel?: () => void;
41
+ onDelete?: (s: PeekSession) => Promise<boolean>;
42
+ onRename?: (s: PeekSession, name: string) => Promise<boolean>;
43
+ requestRender?: () => void; // 异步操作完成后让 TUI 重画
44
+
45
+ constructor(
46
+ private all: PeekSession[],
47
+ private currentCwd: string,
48
+ private theme: any,
49
+ private mdTheme: MarkdownTheme,
50
+ private termRows: number,
51
+ initialQuery: string,
52
+ ) {
53
+ this.input = new Input({ placeholder: msg("searchPlaceholder") });
54
+ this.renameInput = new Input({ placeholder: msg("renamePlaceholder") });
55
+ if (initialQuery) this.input.setValue(initialQuery);
56
+ // 当前目录树下有会话就默认当前目录,否则全局
57
+ this.scope = all.some((s) => this.inCurrentTree(s)) ? "current" : "all";
58
+ this.refilter();
59
+ }
60
+
61
+ // 焦点要落到真正在用的那个输入框上,否则 IME 候选框位置不对
62
+ get focused(): boolean {
63
+ return this._focused;
64
+ }
65
+ set focused(v: boolean) {
66
+ this._focused = v;
67
+ this.input.focused = v && !this.renaming;
68
+ this.renameInput.focused = v && this.renaming;
69
+ }
70
+
71
+ getQuery(): string {
72
+ return this.input.getValue().trim();
73
+ }
74
+
75
+ private query(): ParsedQuery {
76
+ return parseQuery(this.getQuery());
77
+ }
78
+
79
+ // 等于当前目录,或在它的子目录下
80
+ private inCurrentTree(s: PeekSession): boolean {
81
+ const cur = normPath(this.currentCwd);
82
+ const c = normPath(s.cwd);
83
+ return c === cur || c.startsWith(cur + "/");
84
+ }
85
+
86
+ // keepSelection:尽量保持原来选中的会话,重命名后用
87
+ private refilter(keepSelection = false): void {
88
+ const prev = keepSelection ? this.filtered[this.selected] : undefined;
89
+ const { kws, since } = this.query();
90
+ let out = this.all;
91
+ if (this.scope === "current") out = out.filter((s) => this.inCurrentTree(s));
92
+ if (since) out = out.filter((s) => s.mtime >= since);
93
+ if (kws.length) out = out.filter((s) => kws.every((k) => s.searchText.includes(k)));
94
+ this.filtered = out;
95
+ const idx = prev ? out.indexOf(prev) : -1;
96
+ this.selected = idx >= 0 ? idx : keepSelection ? Math.min(this.selected, Math.max(0, out.length - 1)) : 0;
97
+ if (!keepSelection) this.listOffset = 0;
98
+ this.previewKey = "";
99
+ }
100
+
101
+ private bodyHeight(): number {
102
+ const rows = process.stdout.rows || this.termRows || 24; // 每次都读,终端拉伸后布局跟着变
103
+ return Math.max(8, Math.min(rows - 12, 30));
104
+ }
105
+
106
+ handleInput(data: string): void {
107
+ // 重命名输入中:Enter 提交,Esc / Ctrl+C 取消,其他键交给输入框
108
+ if (this.renaming) {
109
+ if (matchesKey(data, Key.enter)) {
110
+ const s = this.filtered[this.selected];
111
+ const name = this.renameInput.getValue().trim();
112
+ this.renaming = false;
113
+ this.focused = this._focused;
114
+ this.invalidate();
115
+ if (s && name && this.onRename) {
116
+ void this.onRename(s, name).then((ok) => {
117
+ if (ok) {
118
+ s.name = name;
119
+ // 名字参与搜索,改完可能就不再匹配当前关键词了
120
+ s.searchText = (s.msgs.map((m) => m.text).join(" ") + " " + name + " " + s.cwd).toLowerCase();
121
+ this.refilter(true);
122
+ }
123
+ this.invalidate();
124
+ this.requestRender?.();
125
+ });
126
+ }
127
+ return;
128
+ }
129
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
130
+ this.renaming = false;
131
+ this.focused = this._focused;
132
+ this.invalidate();
133
+ return;
134
+ }
135
+ this.renameInput.handleInput(data);
136
+ this.invalidate();
137
+ return;
138
+ }
139
+ // 删除确认中:y / Enter 执行,其他任何键取消
140
+ if (this.confirmingDelete) {
141
+ this.confirmingDelete = false;
142
+ this.invalidate();
143
+ if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
144
+ const s = this.filtered[this.selected];
145
+ if (s && this.onDelete) {
146
+ void this.onDelete(s).then((ok) => {
147
+ if (ok) {
148
+ let i = this.all.indexOf(s);
149
+ if (i >= 0) this.all.splice(i, 1);
150
+ i = this.filtered.indexOf(s);
151
+ if (i >= 0) this.filtered.splice(i, 1);
152
+ // 选中位置留在原地
153
+ this.selected = Math.min(this.selected, Math.max(0, this.filtered.length - 1));
154
+ this.previewKey = "";
155
+ }
156
+ this.invalidate();
157
+ this.requestRender?.();
158
+ });
159
+ }
160
+ }
161
+ return;
162
+ }
163
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
164
+ this.onCancel?.();
165
+ return;
166
+ }
167
+ if (matchesKey(data, Key.ctrl("d"))) {
168
+ if (this.filtered.length && this.onDelete) {
169
+ this.confirmingDelete = true;
170
+ this.invalidate();
171
+ }
172
+ return;
173
+ }
174
+ if (matchesKey(data, Key.ctrl("r"))) {
175
+ const s = this.filtered[this.selected];
176
+ if (s && this.onRename) {
177
+ this.renaming = true;
178
+ this.renameInput.setValue(s.name || "");
179
+ this.focused = this._focused;
180
+ this.invalidate();
181
+ }
182
+ return;
183
+ }
184
+ if (matchesKey(data, Key.tab)) {
185
+ this.scope = this.scope === "all" ? "current" : "all";
186
+ this.refilter();
187
+ this.invalidate();
188
+ return;
189
+ }
190
+ if (matchesKey(data, Key.up)) {
191
+ if (this.selected > 0) {
192
+ this.selected--;
193
+ this.previewKey = "";
194
+ this.invalidate();
195
+ }
196
+ return;
197
+ }
198
+ if (matchesKey(data, Key.down)) {
199
+ if (this.selected < this.filtered.length - 1) {
200
+ this.selected++;
201
+ this.previewKey = "";
202
+ this.invalidate();
203
+ }
204
+ return;
205
+ }
206
+ if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("u"))) {
207
+ const step = matchesKey(data, Key.pageUp) ? this.bodyHeight() : Math.max(1, this.bodyHeight() >> 1);
208
+ this.previewOffset = Math.max(0, this.previewOffset - step);
209
+ this.invalidate();
210
+ return;
211
+ }
212
+ if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) {
213
+ const step = matchesKey(data, Key.pageDown) ? this.bodyHeight() : Math.max(1, this.bodyHeight() >> 1);
214
+ this.previewOffset += step;
215
+ this.invalidate();
216
+ return;
217
+ }
218
+ if (matchesKey(data, Key.shift("up"))) {
219
+ this.previewOffset = Math.max(0, this.previewOffset - 3);
220
+ this.invalidate();
221
+ return;
222
+ }
223
+ if (matchesKey(data, Key.shift("down"))) {
224
+ this.previewOffset += 3;
225
+ this.invalidate();
226
+ return;
227
+ }
228
+ if (matchesKey(data, Key.ctrl("n"))) {
229
+ this.jumpMatch(1);
230
+ return;
231
+ }
232
+ if (matchesKey(data, Key.ctrl("p"))) {
233
+ this.jumpMatch(-1);
234
+ return;
235
+ }
236
+ if (matchesKey(data, Key.enter)) {
237
+ const s = this.filtered[this.selected];
238
+ if (s) this.onResume?.(s);
239
+ return;
240
+ }
241
+ if (matchesKey(data, Key.ctrl("o"))) {
242
+ const s = this.filtered[this.selected];
243
+ if (s) this.onFork?.(s);
244
+ return;
245
+ }
246
+ // 其余按键交给搜索框
247
+ const before = this.input.getValue();
248
+ this.input.handleInput(data);
249
+ if (this.input.getValue() !== before) this.refilter(); // 光标移动不算输入,别重置列表
250
+ this.invalidate();
251
+ }
252
+
253
+ // 预览里跳到下一个 / 上一个命中,到头了回绕
254
+ private jumpMatch(dir: 1 | -1): void {
255
+ if (!this.matchLines.length) return;
256
+ const cur = this.previewOffset + 2;
257
+ let target: number | undefined;
258
+ if (dir === 1) {
259
+ target = this.matchLines.find((l) => l > cur) ?? this.matchLines[0];
260
+ } else {
261
+ target = [...this.matchLines].reverse().find((l) => l < cur) ?? this.matchLines[this.matchLines.length - 1];
262
+ }
263
+ if (target !== undefined) {
264
+ this.previewOffset = Math.max(0, target - 2);
265
+ this.invalidate();
266
+ }
267
+ }
268
+
269
+ // 左栏,每个会话两行
270
+ private buildList(height: number, lw: number): string[] {
271
+ const t = this.theme;
272
+ const { kws } = this.query();
273
+ const visible = Math.max(1, Math.floor(height / 2));
274
+ if (this.selected < this.listOffset) this.listOffset = this.selected;
275
+ if (this.selected >= this.listOffset + visible) {
276
+ this.listOffset = this.selected - visible + 1;
277
+ }
278
+
279
+ const rows: string[] = [];
280
+ for (let i = 0; i < visible; i++) {
281
+ const idx = this.listOffset + i;
282
+ const s = this.filtered[idx];
283
+ if (!s) {
284
+ rows.push(" ".repeat(lw), " ".repeat(lw));
285
+ continue;
286
+ }
287
+ const sel = idx === this.selected;
288
+ const time = fmtTime(s.time);
289
+ const cwdTail = s.cwd.replace(/\\/g, "/").split("/").slice(-2).join("/");
290
+ // 有关键词时显示命中的消息数
291
+ const hitBadge = kws.length
292
+ ? ` ·${s.msgs.reduce((n, m) => n + (anyKw(m.text.toLowerCase(), kws) ? 1 : 0), 0)}`
293
+ : "";
294
+ const l1 = `${sel ? "›" : " "} ${time} ${cwdTail}${hitBadge}`;
295
+ // 第二行:有关键词时显示命中片段,否则显示首条消息
296
+ const snip = snippet(s.msgs, kws, lw);
297
+ const l2 = snip !== undefined
298
+ ? ` ${highlight(snip, kws, t)}`
299
+ : ` ${s.first}${s.name ? ` [${s.name}]` : ""}`;
300
+ if (sel) {
301
+ rows.push(t.bg("selectedBg", padEndVisible(t.fg("accent", truncateToWidth(l1, lw)), lw)));
302
+ rows.push(t.bg("selectedBg", padEndVisible(truncateToWidth(l2, lw), lw)));
303
+ } else {
304
+ rows.push(padEndVisible(l1, lw));
305
+ rows.push(padEndVisible(t.fg("dim", truncateToWidth(l2, lw)), lw));
306
+ }
307
+ }
308
+ // 每项两行,height 是奇数时会少一行,补空行,否则最后一行右栏会顶到左边
309
+ while (rows.length < height) rows.push(" ".repeat(lw));
310
+ return rows.slice(0, height);
311
+ }
312
+
313
+ // 用 pi 自己的 Markdown 组件渲染一条消息,和主界面里的对话长得一样;参数照抄 pi 的
314
+ // user-message / assistant-message 组件。渲染比较贵,按消息和宽度缓存,换关键词时不用重来
315
+ private renderMsg(m: PeekMsg, rw: number): string[] {
316
+ const hit = this.rendered.get(m);
317
+ if (hit && hit.w === rw) return hit.lines;
318
+ const t = this.theme;
319
+ const md =
320
+ m.role === "user"
321
+ ? new Markdown(
322
+ m.text,
323
+ 1,
324
+ 0,
325
+ this.mdTheme,
326
+ { color: (s) => t.fg("userMessageText", s), bgColor: (s) => t.bg("userMessageBg", s) },
327
+ { preserveOrderedListMarkers: true, preserveBackslashEscapes: true },
328
+ )
329
+ : new Markdown(m.text, 1, 0, this.mdTheme);
330
+ const lines = md.render(rw);
331
+ this.rendered.set(m, { w: rw, lines });
332
+ return lines;
333
+ }
334
+
335
+ // 右栏。有关键词时滚到第一个命中处,没有时滚到底部看最新消息
336
+ private buildPreview(rw: number): void {
337
+ const t = this.theme;
338
+ const s = this.filtered[this.selected];
339
+ const key = s ? `${s.path}|${this.getQuery()}|${rw}` : "none";
340
+ if (key === this.previewKey) return;
341
+ this.previewKey = key;
342
+ this.matchLines = [];
343
+
344
+ if (!s) {
345
+ this.previewLines = [t.fg("dim", msg("noMatch"))];
346
+ this.previewOffset = 0;
347
+ return;
348
+ }
349
+
350
+ const lines: string[] = [];
351
+ lines.push(
352
+ truncateToWidth(
353
+ t.fg("dim", `${s.cwd} • ${msg("msgCount", { n: s.msgs.length })}${s.name ? " • " + s.name : ""}`),
354
+ rw,
355
+ ),
356
+ );
357
+ lines.push("");
358
+
359
+ const { kws } = this.query();
360
+ const MAX_MSGS = 500;
361
+ const truncated = s.msgs.length > MAX_MSGS;
362
+ const msgs = truncated ? s.msgs.slice(-MAX_MSGS) : s.msgs;
363
+ if (truncated) {
364
+ lines.push(t.fg("dim", msg("truncated", { n: MAX_MSGS })));
365
+ lines.push("");
366
+ }
367
+ if (kws.length && !msgs.some((m) => anyKw(m.text.toLowerCase(), kws))) {
368
+ const inTruncated = truncated && s.msgs.some((m) => anyKw(m.text.toLowerCase(), kws));
369
+ lines.push(
370
+ t.fg(
371
+ "warning",
372
+ inTruncated ? msg("hitInTruncated") : msg("hitOnlyMeta"),
373
+ ),
374
+ );
375
+ lines.push("");
376
+ }
377
+
378
+ // 用户消息整块背景色,AI 消息只有一条细线标签
379
+ const fillLabel = (label: string): string => {
380
+ const w = visibleWidth(label);
381
+ return truncateToWidth(label + " " + "─".repeat(Math.max(2, rw - w - 1)), rw);
382
+ };
383
+
384
+ for (const m of msgs) {
385
+ const isMatch = kws.length > 0 && anyKw(m.text.toLowerCase(), kws);
386
+ if (isMatch) this.matchLines.push(lines.length);
387
+ if (m.role === "user") {
388
+ lines.push(
389
+ t.bg("userMessageBg", padEndVisible(t.bold(t.fg("accent", fillLabel(msg("you")))), rw)),
390
+ );
391
+ } else {
392
+ lines.push(t.fg("muted", fillLabel(msg("ai"))));
393
+ }
394
+ // 先渲染再高亮:往 markdown 源码里插转义码会把链接、代码块的语法弄坏
395
+ for (const l of this.renderMsg(m, rw)) lines.push(isMatch ? highlight(l, kws, t) : l);
396
+ lines.push("");
397
+ }
398
+
399
+ this.previewLines = lines;
400
+
401
+ if (this.matchLines.length) {
402
+ this.previewOffset = Math.max(0, this.matchLines[0] - 2);
403
+ } else {
404
+ this.previewOffset = Math.max(0, lines.length - this.bodyHeight());
405
+ }
406
+ }
407
+
408
+ render(width: number): string[] {
409
+ const H = this.bodyHeight();
410
+ const cacheKey = width * 1000 + H; // 宽或高变了都要重画
411
+ if (this.cachedWidth === cacheKey) return this.cachedLines;
412
+ const t = this.theme;
413
+ const lw = Math.max(26, Math.min(56, Math.floor(width * 0.4)));
414
+ const rw = Math.max(20, width - lw - 3);
415
+
416
+ const out: string[] = [];
417
+
418
+ // 头部:标题、范围、时间过滤、匹配数,然后是搜索框
419
+ const scopeLabel = this.scope === "all" ? msg("scopeAll") : msg("scopeCurrent");
420
+ const { sinceLabel } = this.query();
421
+ const head =
422
+ t.fg("accent", t.bold(msg("title"))) +
423
+ t.fg("dim", msg("scopeLabel")) +
424
+ t.fg("warning", scopeLabel) +
425
+ (sinceLabel ? t.fg("dim", msg("timeLabel")) + t.fg("warning", sinceLabel) : "") +
426
+ t.fg("dim", msg("matchCount", { n: this.filtered.length, total: this.all.length }));
427
+ out.push(truncateToWidth(head, width));
428
+ out.push(this.input.render(width)[0] ?? "");
429
+ out.push(t.fg("borderMuted", "─".repeat(width)));
430
+
431
+ // 双栏
432
+ this.buildPreview(rw);
433
+ const leftRows = this.buildList(H, lw);
434
+
435
+ const maxOff = Math.max(0, this.previewLines.length - H);
436
+ if (this.previewOffset > maxOff) this.previewOffset = maxOff;
437
+ const rightRows = this.previewLines.slice(this.previewOffset, this.previewOffset + H);
438
+ const scrollInfo =
439
+ this.previewLines.length > H
440
+ ? t.fg(
441
+ "dim",
442
+ ` (${this.previewOffset + 1}-${Math.min(this.previewOffset + H, this.previewLines.length)}/${this.previewLines.length})`,
443
+ )
444
+ : "";
445
+
446
+ for (let i = 0; i < H; i++) {
447
+ const l = leftRows[i] ?? " ".repeat(lw);
448
+ let r = rightRows[i] ?? "";
449
+ if (i === 0 && scrollInfo) {
450
+ r = truncateToWidth(r, Math.max(0, rw - visibleWidth(scrollInfo))) + scrollInfo;
451
+ }
452
+ // 左栏在 buildList 里已经 pad 到 lw,这里再 pad 会把带样式的行截坏
453
+ out.push(l + t.fg("borderMuted", " │ ") + padEndVisible(r, rw));
454
+ }
455
+
456
+ // 底部:重命名和删除确认时换成对应的操作行
457
+ out.push(t.fg("borderMuted", "─".repeat(width)));
458
+ if (this.renaming) {
459
+ const prefix = msg("renamePrefix");
460
+ const inputLine = this.renameInput.render(Math.max(10, width - visibleWidth(prefix)))[0] ?? "";
461
+ out.push(truncateToWidth(t.fg("warning", prefix) + inputLine, width));
462
+ } else if (this.confirmingDelete) {
463
+ const s = this.filtered[this.selected];
464
+ const desc = s ? `${fmtTime(s.time, true)} ${s.first.slice(0, 30)}` : "";
465
+ out.push(
466
+ truncateToWidth(
467
+ t.fg("error", t.bold(msg("confirmDelete", { desc }))),
468
+ width,
469
+ ),
470
+ );
471
+ } else {
472
+ out.push(
473
+ truncateToWidth(
474
+ t.fg("dim", msg("footer")),
475
+ width,
476
+ ),
477
+ );
478
+ }
479
+
480
+ this.cachedWidth = cacheKey;
481
+ this.cachedLines = out;
482
+ return out;
483
+ }
484
+
485
+ invalidate(): void {
486
+ this.cachedWidth = -1;
487
+ this.input.invalidate();
488
+ }
489
+ }