pi-session-peek 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +19 -9
- package/README.zh-CN.md +19 -9
- package/package.json +1 -1
- package/src/ansi.ts +73 -0
- package/src/drag-select.ts +119 -0
- package/src/i18n.ts +4 -4
- package/src/layout.ts +18 -0
- package/src/list.ts +64 -0
- package/src/peek-component.ts +101 -303
- package/src/preview.ts +139 -0
- package/src/query.ts +16 -61
- package/src/selection.ts +7 -11
- package/src/sessions.ts +1 -5
- package/src/theme.ts +12 -0
package/src/peek-component.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Input,
|
|
3
3
|
Key,
|
|
4
|
-
Markdown,
|
|
5
4
|
matchesKey,
|
|
6
5
|
truncateToWidth,
|
|
7
6
|
visibleWidth,
|
|
@@ -12,45 +11,57 @@ import {
|
|
|
12
11
|
type TuiMouseEventResult,
|
|
13
12
|
} from "@earendil-works/pi-tui";
|
|
14
13
|
import { msg } from "./i18n.ts";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import
|
|
14
|
+
import { DragSelect } from "./drag-select.ts";
|
|
15
|
+
import { BODY_TOP, bodyRows, paneWidths } from "./layout.ts";
|
|
16
|
+
import { buildList, followSelection, listRows, visibleItems } from "./list.ts";
|
|
17
|
+
import { buildPreview, initialOffset, nextMatchTarget, type RenderCache } from "./preview.ts";
|
|
18
|
+
import { matchesSession, parseQuery, roleTerms, type ParsedQuery, type RoleTerms } from "./query.ts";
|
|
19
|
+
import { bounds, highlightColumns, rowColumns, selectionText, type Selection } from "./selection.ts";
|
|
20
|
+
import type { PeekSession } from "./sessions.ts";
|
|
18
21
|
import { fmtTime, normPath, padEndVisible } from "./text.ts";
|
|
22
|
+
import type { PeekTheme } from "./theme.ts";
|
|
19
23
|
|
|
20
|
-
// 双栏选择器。文件操作(删除 / 重命名 / 分叉)不在这里做,由 index.ts
|
|
24
|
+
// 双栏选择器。文件操作(删除 / 重命名 / 分叉)不在这里做,由 index.ts 通过回调注入。
|
|
25
|
+
// 几何常量在 layout.ts,左栏在 list.ts,右栏在 preview.ts,拖选状态机在 drag-select.ts
|
|
21
26
|
|
|
22
|
-
const BODY_TOP = 3; // 头部、搜索框、分隔线之后才是双栏主体
|
|
23
|
-
const SEP = 3; // 两栏之间 " │ " 占的列数
|
|
24
27
|
const WHEEL_LINES = 3; // 滚轮每格滚几行预览,和 Shift+↑↓ 一样
|
|
25
|
-
const
|
|
28
|
+
const WHEEL_ITEMS = 1; // 滚轮每格滚几个列表项(每项两行)。只滚视图,不换选中项,像浏览器的滚动条
|
|
26
29
|
const HINT_MS = 1500; // 底部"已复制"提示停留时间
|
|
27
30
|
|
|
28
31
|
export class PeekComponent implements Component, Focusable {
|
|
32
|
+
// 声明了它,pi 全屏模式才把 PageUp/PageDown 等视口按键交给组件(shouldDeferViewportInput),
|
|
33
|
+
// 否则被 pi 截去滚主对话记录。Ctrl+F 例外:pi 的全文搜索绑定在 defer 检查之前,永远到不了组件
|
|
34
|
+
readonly capturesViewportInput = true;
|
|
29
35
|
private input: Input;
|
|
30
36
|
private scope: "current" | "all";
|
|
31
37
|
private filtered: PeekSession[] = [];
|
|
32
38
|
private selected = 0;
|
|
33
39
|
private listOffset = 0;
|
|
40
|
+
private followSel = true; // 下次 render 把选中项带回视野:换选中项、重新过滤、删除后置真,滚轮滚列表不置
|
|
34
41
|
private previewOffset = 0;
|
|
35
42
|
private previewKey = ""; // 预览缓存的键(会话 + 关键词 + 宽度 + 工具开关),置空强制重建
|
|
36
43
|
private showTools = false; // Ctrl+T:预览里显示每次工具调用的一行摘要
|
|
37
44
|
private previewLines: string[] = [];
|
|
38
45
|
private matchLines: number[] = []; // 预览里含关键词的行(升序),Ctrl+N / Ctrl+P 用
|
|
39
|
-
private rendered = new WeakMap
|
|
40
|
-
|
|
41
|
-
private
|
|
46
|
+
private rendered: RenderCache = new WeakMap(); // 每条消息渲染好的行,按宽度缓存
|
|
47
|
+
// 三个互斥的输入状态:搜索(默认)、Ctrl+R 之后在输入新名字、Ctrl+D 之后等待确认。改名和确认只从搜索进入、退回搜索
|
|
48
|
+
private mode: "search" | "rename" | "confirmDelete" = "search";
|
|
42
49
|
private busy = false; // 删除 / 重命名的异步回调还没回来:期间不能再删、改名、进入或分叉,免得对同一个文件动两次
|
|
43
50
|
private renameInput: Input;
|
|
44
51
|
private cachedWidth = -1;
|
|
45
52
|
private cachedLines: string[] = [];
|
|
46
53
|
private layout = { lw: 0, rw: 0, H: 0 }; // 最近一次 render 的分栏尺寸,鼠标定位用
|
|
47
54
|
private scopeSpan: [number, number] = [0, 0]; // 头部"范围"字样占的列区间,点击切换范围
|
|
48
|
-
|
|
49
|
-
private
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
// 拖选状态机。分栏尺寸和滚动位置惰性从组件读,自动滚动时把新的预览偏移写回来
|
|
56
|
+
private drag = new DragSelect({
|
|
57
|
+
geometry: () => ({ ...this.layout, listOffset: this.listOffset, previewOffset: this.previewOffset }),
|
|
58
|
+
previewLength: () => this.previewLines.length,
|
|
59
|
+
setPreviewOffset: (n) => void (this.previewOffset = n),
|
|
60
|
+
changed: () => {
|
|
61
|
+
this.invalidate();
|
|
62
|
+
this.requestRender?.();
|
|
63
|
+
},
|
|
64
|
+
});
|
|
54
65
|
private hint?: { text: string; ok: boolean }; // 底部一闪而过的复制结果
|
|
55
66
|
private hintTimer?: ReturnType<typeof setTimeout>;
|
|
56
67
|
private _focused = false;
|
|
@@ -69,7 +80,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
69
80
|
constructor(
|
|
70
81
|
private all: PeekSession[],
|
|
71
82
|
private currentCwd: string,
|
|
72
|
-
private theme:
|
|
83
|
+
private theme: PeekTheme,
|
|
73
84
|
private mdTheme: MarkdownTheme,
|
|
74
85
|
private termRows: number,
|
|
75
86
|
initialQuery: string,
|
|
@@ -88,8 +99,8 @@ export class PeekComponent implements Component, Focusable {
|
|
|
88
99
|
}
|
|
89
100
|
set focused(v: boolean) {
|
|
90
101
|
this._focused = v;
|
|
91
|
-
this.input.focused = v &&
|
|
92
|
-
this.renameInput.focused = v && this.
|
|
102
|
+
this.input.focused = v && this.mode !== "rename";
|
|
103
|
+
this.renameInput.focused = v && this.mode === "rename";
|
|
93
104
|
}
|
|
94
105
|
|
|
95
106
|
getQuery(): string {
|
|
@@ -114,53 +125,42 @@ export class PeekComponent implements Component, Focusable {
|
|
|
114
125
|
let out = this.all;
|
|
115
126
|
if (this.scope === "current") out = out.filter((s) => this.inCurrentTree(s));
|
|
116
127
|
if (since) out = out.filter((s) => s.mtime >= since);
|
|
117
|
-
if (terms.length) out = out.filter((s) => matchesSession(s, terms));
|
|
128
|
+
if (terms.length) out = out.filter((s) => matchesSession(s, terms, this.scope === "all"));
|
|
118
129
|
this.filtered = out;
|
|
119
130
|
const idx = prev ? out.indexOf(prev) : -1;
|
|
120
131
|
this.selected = idx >= 0 ? idx : keepSelection ? Math.min(this.selected, Math.max(0, out.length - 1)) : 0;
|
|
121
132
|
if (!keepSelection) this.listOffset = 0;
|
|
133
|
+
this.followSel = true;
|
|
122
134
|
this.previewKey = "";
|
|
123
|
-
this.
|
|
135
|
+
this.drag.clear();
|
|
124
136
|
}
|
|
125
137
|
|
|
126
|
-
//
|
|
138
|
+
// 换选中的会话:预览重建,拖选的选区作废,视野跟过去
|
|
127
139
|
private select(idx: number): void {
|
|
128
140
|
this.selected = idx;
|
|
141
|
+
this.followSel = true;
|
|
129
142
|
this.previewKey = "";
|
|
130
|
-
this.
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
private clearSelection(): void {
|
|
134
|
-
this.sel = undefined;
|
|
135
|
-
this.dragging = false;
|
|
136
|
-
this.selMoved = false;
|
|
137
|
-
this.stopAutoScroll();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// 拖过的才算选区,只按下没拖的是单击
|
|
141
|
-
private activeSel(): Selection | undefined {
|
|
142
|
-
return this.sel && this.selMoved ? this.sel : undefined;
|
|
143
|
+
this.drag.clear();
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
dispose(): void {
|
|
146
|
-
this.
|
|
147
|
+
this.drag.dispose();
|
|
147
148
|
if (this.hintTimer) clearTimeout(this.hintTimer);
|
|
148
149
|
this.hintTimer = undefined;
|
|
149
150
|
this.onDispose?.();
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
private bodyHeight(): number {
|
|
153
|
-
|
|
154
|
-
return Math.max(8, Math.min(rows - 12, 30));
|
|
154
|
+
return bodyRows(this.termRows);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
handleInput(data: string): void {
|
|
158
158
|
// 重命名输入中:Enter 提交,Esc / Ctrl+C 取消,其他键交给输入框
|
|
159
|
-
if (this.
|
|
159
|
+
if (this.mode === "rename") {
|
|
160
160
|
if (matchesKey(data, Key.enter)) {
|
|
161
161
|
const s = this.filtered[this.selected];
|
|
162
162
|
const name = this.renameInput.getValue().trim();
|
|
163
|
-
this.
|
|
163
|
+
this.mode = "search";
|
|
164
164
|
this.focused = this._focused;
|
|
165
165
|
this.invalidate();
|
|
166
166
|
if (s && name && this.onRename && !this.busy) {
|
|
@@ -183,7 +183,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
183
183
|
return;
|
|
184
184
|
}
|
|
185
185
|
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
186
|
-
this.
|
|
186
|
+
this.mode = "search";
|
|
187
187
|
this.focused = this._focused;
|
|
188
188
|
this.invalidate();
|
|
189
189
|
return;
|
|
@@ -193,8 +193,8 @@ export class PeekComponent implements Component, Focusable {
|
|
|
193
193
|
return;
|
|
194
194
|
}
|
|
195
195
|
// 删除确认中:y / Enter 执行,其他任何键取消
|
|
196
|
-
if (this.
|
|
197
|
-
this.
|
|
196
|
+
if (this.mode === "confirmDelete") {
|
|
197
|
+
this.mode = "search";
|
|
198
198
|
this.invalidate();
|
|
199
199
|
if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
|
|
200
200
|
const s = this.filtered[this.selected];
|
|
@@ -208,8 +208,9 @@ export class PeekComponent implements Component, Focusable {
|
|
|
208
208
|
if (i >= 0) this.filtered.splice(i, 1);
|
|
209
209
|
// 选中位置留在原地
|
|
210
210
|
this.selected = Math.min(this.selected, Math.max(0, this.filtered.length - 1));
|
|
211
|
+
this.followSel = true;
|
|
211
212
|
this.previewKey = "";
|
|
212
|
-
this.
|
|
213
|
+
this.drag.clear();
|
|
213
214
|
}
|
|
214
215
|
}).catch(() => {
|
|
215
216
|
}).finally(() => {
|
|
@@ -223,7 +224,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
223
224
|
}
|
|
224
225
|
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
225
226
|
// 有拖选的选区时 Ctrl+C 是复制,和终端里的习惯一样;没有才是关闭
|
|
226
|
-
if (matchesKey(data, Key.ctrl("c")) && this.
|
|
227
|
+
if (matchesKey(data, Key.ctrl("c")) && this.drag.active()) {
|
|
227
228
|
this.copySelection();
|
|
228
229
|
return;
|
|
229
230
|
}
|
|
@@ -232,7 +233,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
232
233
|
}
|
|
233
234
|
if (matchesKey(data, Key.ctrl("d"))) {
|
|
234
235
|
if (this.filtered.length && this.onDelete && !this.busy) {
|
|
235
|
-
this.
|
|
236
|
+
this.mode = "confirmDelete";
|
|
236
237
|
this.invalidate();
|
|
237
238
|
}
|
|
238
239
|
return;
|
|
@@ -240,7 +241,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
240
241
|
if (matchesKey(data, Key.ctrl("r"))) {
|
|
241
242
|
const s = this.filtered[this.selected];
|
|
242
243
|
if (s && this.onRename && !this.busy) {
|
|
243
|
-
this.
|
|
244
|
+
this.mode = "rename";
|
|
244
245
|
this.renameInput.setValue(s.name || "");
|
|
245
246
|
this.focused = this._focused;
|
|
246
247
|
this.invalidate();
|
|
@@ -273,7 +274,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
273
274
|
this.invalidate();
|
|
274
275
|
return;
|
|
275
276
|
}
|
|
276
|
-
if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("
|
|
277
|
+
if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("y"))) {
|
|
277
278
|
const step = matchesKey(data, Key.pageDown) ? this.bodyHeight() : Math.max(1, this.bodyHeight() >> 1);
|
|
278
279
|
this.previewOffset += step;
|
|
279
280
|
this.invalidate();
|
|
@@ -292,7 +293,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
292
293
|
if (matchesKey(data, Key.ctrl("t"))) {
|
|
293
294
|
this.showTools = !this.showTools;
|
|
294
295
|
this.previewKey = "";
|
|
295
|
-
this.
|
|
296
|
+
this.drag.clear();
|
|
296
297
|
this.invalidate();
|
|
297
298
|
return;
|
|
298
299
|
}
|
|
@@ -324,7 +325,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
324
325
|
// 硬件光标在组件内的行号:搜索框在第 1 行,改名时在最后一行。常规模式的鼠标桥接靠它算组件顶行
|
|
325
326
|
cursorRow(): number | undefined {
|
|
326
327
|
if (!this._focused || !this.layout.H) return undefined;
|
|
327
|
-
return this.
|
|
328
|
+
return this.mode === "rename" ? BODY_TOP + this.layout.H + 1 : 1;
|
|
328
329
|
}
|
|
329
330
|
|
|
330
331
|
// 鼠标:左栏按下选中、双击进入、滚轮换选中项;右栏滚轮滚预览;点头部"范围"切换;点输入框移光标;
|
|
@@ -341,16 +342,15 @@ export class PeekComponent implements Component, Focusable {
|
|
|
341
342
|
if (ev.type === "wheel") {
|
|
342
343
|
const delta = ev.wheelDelta ?? 0;
|
|
343
344
|
if (!delta) return undefined;
|
|
344
|
-
|
|
345
|
-
|
|
345
|
+
// 和按键一样,滚一下就算取消确认。只退出确认,改名中滚轮不退出改名
|
|
346
|
+
let changed = this.cancelConfirm();
|
|
346
347
|
if (inList) {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
}
|
|
348
|
+
// 只滚视图,选中项不动;↑↓、点击、重新过滤会把选中项带回视野
|
|
349
|
+
const maxOff = Math.max(0, this.filtered.length - visibleItems(H));
|
|
350
|
+
const next = Math.max(0, Math.min(maxOff, this.listOffset + delta * WHEEL_ITEMS));
|
|
351
|
+
if (next !== this.listOffset) {
|
|
352
|
+
this.listOffset = next;
|
|
353
|
+
changed = true;
|
|
354
354
|
}
|
|
355
355
|
} else {
|
|
356
356
|
const maxOff = Math.max(0, this.previewLines.length - H);
|
|
@@ -364,38 +364,34 @@ export class PeekComponent implements Component, Focusable {
|
|
|
364
364
|
return { handled: true, render: changed };
|
|
365
365
|
}
|
|
366
366
|
if (ev.type === "drag") {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
//
|
|
372
|
-
if (
|
|
367
|
+
const d = this.drag;
|
|
368
|
+
if (!d.dragging || !d.sel || ev.button !== "left") return undefined;
|
|
369
|
+
const pane = d.sel.pane;
|
|
370
|
+
const changed = d.move(ev.x, bodyRow);
|
|
371
|
+
// 右栏拖出上下边就自动滚;左栏不滚
|
|
372
|
+
if (pane === "preview") d.autoScroll(bodyRow < 0 ? -1 : bodyRow >= H ? 1 : 0);
|
|
373
373
|
if (changed) this.invalidate();
|
|
374
374
|
return { handled: true, render: changed };
|
|
375
375
|
}
|
|
376
376
|
if (ev.button !== "left") return undefined;
|
|
377
377
|
if (ev.type === "release") {
|
|
378
|
-
if (!this.
|
|
379
|
-
this.dragging = false;
|
|
380
|
-
this.stopAutoScroll();
|
|
381
|
-
if (!this.selMoved) this.sel = undefined; // 没拖过就是单击
|
|
378
|
+
if (!this.drag.release()) return undefined;
|
|
382
379
|
return { handled: true, render: false };
|
|
383
380
|
}
|
|
384
381
|
if (ev.type === "click") {
|
|
385
382
|
// 单击在 press 里已经选中了,双击才进入
|
|
386
383
|
const s = this.filtered[itemAt()];
|
|
387
384
|
if (!s) return undefined;
|
|
388
|
-
if (
|
|
385
|
+
if (this.mode !== "rename" && !this.busy && (ev.clickCount ?? 1) >= 2) this.onResume?.(s);
|
|
389
386
|
return { handled: true, render: false };
|
|
390
387
|
}
|
|
391
388
|
if (ev.type !== "press") return undefined;
|
|
392
389
|
|
|
393
390
|
// 删除确认和已有的选区:点一下就没了,和按键一样
|
|
394
|
-
let changed = this.
|
|
395
|
-
this.
|
|
396
|
-
this.clearSelection();
|
|
391
|
+
let changed = this.cancelConfirm() || this.drag.active() !== undefined;
|
|
392
|
+
this.drag.clear();
|
|
397
393
|
let handled = changed;
|
|
398
|
-
if (this.
|
|
394
|
+
if (this.mode === "rename") {
|
|
399
395
|
if (ev.y === BODY_TOP + H + 1) {
|
|
400
396
|
// 改名行:点哪里光标就去哪里
|
|
401
397
|
this.renameInput.handleMouse({ ...ev, x: ev.x - visibleWidth(msg("renamePrefix")), y: 0 });
|
|
@@ -419,11 +415,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
419
415
|
}
|
|
420
416
|
if (inBody) {
|
|
421
417
|
// 两栏里按下都可能是拖选的起点,拖起来才算数;接管后面的拖动和松开
|
|
422
|
-
|
|
423
|
-
const cell = this.cellAt(pane, ev.x, bodyRow);
|
|
424
|
-
this.sel = { pane, anchor: cell, focus: { ...cell } };
|
|
425
|
-
this.dragging = true;
|
|
426
|
-
this.dragX = ev.x;
|
|
418
|
+
this.drag.begin(inList ? "list" : "preview", ev.x, bodyRow);
|
|
427
419
|
handled = true;
|
|
428
420
|
}
|
|
429
421
|
if (!handled) return undefined;
|
|
@@ -431,55 +423,13 @@ export class PeekComponent implements Component, Focusable {
|
|
|
431
423
|
return inBody ? { handled: true, capture: true, render: changed } : { handled: true, render: changed };
|
|
432
424
|
}
|
|
433
425
|
|
|
434
|
-
//
|
|
435
|
-
private
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
if (pane === "list") return { row: this.listOffset * 2 + row, col: Math.max(0, Math.min(lw - 1, x)) };
|
|
439
|
-
return { row: this.previewOffset + row, col: Math.max(0, Math.min(rw - 1, x - lw - SEP)) };
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// 拖到某个位置:焦点跟过去。返回有没有变
|
|
443
|
-
private dragTo(x: number, y: number): boolean {
|
|
444
|
-
if (!this.sel) return false;
|
|
445
|
-
const cell = this.cellAt(this.sel.pane, x, y - BODY_TOP);
|
|
446
|
-
if (cell.row === this.sel.focus.row && cell.col === this.sel.focus.col) return false;
|
|
447
|
-
this.sel.focus = cell;
|
|
426
|
+
// 鼠标滚轮 / 按下时退出删除确认(只退出确认,改名状态不动)。返回有没有退出
|
|
427
|
+
private cancelConfirm(): boolean {
|
|
428
|
+
if (this.mode !== "confirmDelete") return false;
|
|
429
|
+
this.mode = "search";
|
|
448
430
|
return true;
|
|
449
431
|
}
|
|
450
432
|
|
|
451
|
-
private setAutoScroll(dir: number): void {
|
|
452
|
-
this.autoScrollDir = dir;
|
|
453
|
-
if (!dir) {
|
|
454
|
-
this.stopAutoScroll();
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
if (this.autoScrollTimer) return;
|
|
458
|
-
this.autoScrollTimer = setInterval(() => this.autoScrollTick(), AUTO_SCROLL_MS);
|
|
459
|
-
this.autoScrollTimer.unref?.();
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// 每次滚一行,焦点跟着指针:指针在上边就是视口第一行,在下边就是最后一行
|
|
463
|
-
private autoScrollTick(): void {
|
|
464
|
-
const { H } = this.layout;
|
|
465
|
-
const maxOff = Math.max(0, this.previewLines.length - H);
|
|
466
|
-
const next = Math.max(0, Math.min(maxOff, this.previewOffset + this.autoScrollDir));
|
|
467
|
-
if (!this.sel || !this.dragging || next === this.previewOffset) {
|
|
468
|
-
this.stopAutoScroll();
|
|
469
|
-
return;
|
|
470
|
-
}
|
|
471
|
-
this.previewOffset = next;
|
|
472
|
-
if (this.dragTo(this.dragX, this.autoScrollDir < 0 ? BODY_TOP - 1 : BODY_TOP + H)) this.selMoved = true;
|
|
473
|
-
this.invalidate();
|
|
474
|
-
this.requestRender?.();
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
private stopAutoScroll(): void {
|
|
478
|
-
if (this.autoScrollTimer) clearInterval(this.autoScrollTimer);
|
|
479
|
-
this.autoScrollTimer = undefined;
|
|
480
|
-
this.autoScrollDir = 0;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
433
|
// 选区里的纯文本
|
|
484
434
|
private selectionToText(sel: Selection): string {
|
|
485
435
|
const { lw, rw } = this.layout;
|
|
@@ -487,17 +437,17 @@ export class PeekComponent implements Component, Focusable {
|
|
|
487
437
|
const { rt, hasBody } = this.listTerms();
|
|
488
438
|
return selectionText(sel, lw, (row) => {
|
|
489
439
|
const s = this.filtered[row >> 1];
|
|
490
|
-
return s &&
|
|
440
|
+
return s && listRows(s, (row >> 1) === this.selected, lw, rt, hasBody, this.theme)[row & 1];
|
|
491
441
|
});
|
|
492
442
|
}
|
|
493
443
|
|
|
494
444
|
// Ctrl+C:复制选区,清掉高亮,底部闪一下结果
|
|
495
445
|
private copySelection(): void {
|
|
496
|
-
const sel = this.
|
|
446
|
+
const sel = this.drag.active();
|
|
497
447
|
if (!sel) return;
|
|
498
448
|
const text = this.selectionToText(sel);
|
|
499
449
|
const n = text.split("\n").length;
|
|
500
|
-
this.
|
|
450
|
+
this.drag.clear();
|
|
501
451
|
this.invalidate();
|
|
502
452
|
if (!text.trim() || !this.onCopy) return;
|
|
503
453
|
void this.onCopy(text).then((ok) => this.showHint(ok ? msg("copied", { n }) : msg("copyFailed"), ok));
|
|
@@ -517,19 +467,12 @@ export class PeekComponent implements Component, Focusable {
|
|
|
517
467
|
this.requestRender?.();
|
|
518
468
|
}
|
|
519
469
|
|
|
520
|
-
// 预览里跳到当前视口之外的下一个 /
|
|
521
|
-
// 否则一段里连着几行都命中要按好几次才过得去
|
|
470
|
+
// 预览里跳到当前视口之外的下一个 / 上一个命中行,到头了回绕
|
|
522
471
|
private jumpMatch(dir: 1 | -1): void {
|
|
523
|
-
if (!this.matchLines.length) return;
|
|
524
472
|
const H = this.bodyHeight();
|
|
525
473
|
const top = this.previewOffset;
|
|
526
474
|
const bottom = Math.min(top + H, this.previewLines.length) - 1;
|
|
527
|
-
|
|
528
|
-
if (dir === 1) {
|
|
529
|
-
target = this.matchLines.find((l) => l > bottom) ?? this.matchLines[0];
|
|
530
|
-
} else {
|
|
531
|
-
target = [...this.matchLines].reverse().find((l) => l < top) ?? this.matchLines[this.matchLines.length - 1];
|
|
532
|
-
}
|
|
475
|
+
const target = nextMatchTarget(this.matchLines, top, bottom, dir);
|
|
533
476
|
if (target !== undefined) {
|
|
534
477
|
this.previewOffset = Math.max(0, target - 2);
|
|
535
478
|
this.invalidate();
|
|
@@ -542,168 +485,16 @@ export class PeekComponent implements Component, Focusable {
|
|
|
542
485
|
return { rt, hasBody: rt.user.length > 0 || rt.assistant.length > 0 };
|
|
543
486
|
}
|
|
544
487
|
|
|
545
|
-
//
|
|
546
|
-
private
|
|
547
|
-
const t = this.theme;
|
|
548
|
-
const time = fmtTime(s.time);
|
|
549
|
-
const cwdTail = s.cwd.replace(/\\/g, "/").split("/").slice(-2).join("/");
|
|
550
|
-
const hitBadge = hasBody
|
|
551
|
-
? ` ·${s.msgs.reduce((n, m) => n + (anyMatch(m.text, rt[m.role]) ? 1 : 0), 0)}`
|
|
552
|
-
: "";
|
|
553
|
-
const l1 = `${sel ? "›" : " "} ${time} ${cwdTail}${hitBadge}`;
|
|
554
|
-
const snip = hasBody ? snippet(s.msgs, rt, lw) : undefined;
|
|
555
|
-
const l2 = snip !== undefined
|
|
556
|
-
? ` ${highlight(snip.text, rt[snip.role], t)}`
|
|
557
|
-
: ` ${s.first}${s.name ? ` [${s.name}]` : ""}`;
|
|
558
|
-
if (sel) {
|
|
559
|
-
return [
|
|
560
|
-
t.bg("selectedBg", padEndVisible(t.fg("accent", truncateToWidth(l1, lw)), lw)),
|
|
561
|
-
t.bg("selectedBg", padEndVisible(truncateToWidth(l2, lw), lw)),
|
|
562
|
-
];
|
|
563
|
-
}
|
|
564
|
-
return [padEndVisible(l1, lw), padEndVisible(t.fg("dim", truncateToWidth(l2, lw)), lw)];
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// 左栏,每个会话两行
|
|
568
|
-
private buildList(height: number, lw: number): string[] {
|
|
569
|
-
const { rt, hasBody } = this.listTerms();
|
|
570
|
-
const visible = Math.max(1, Math.floor(height / 2));
|
|
571
|
-
if (this.selected < this.listOffset) this.listOffset = this.selected;
|
|
572
|
-
if (this.selected >= this.listOffset + visible) {
|
|
573
|
-
this.listOffset = this.selected - visible + 1;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
const rows: string[] = [];
|
|
577
|
-
for (let i = 0; i < visible; i++) {
|
|
578
|
-
const idx = this.listOffset + i;
|
|
579
|
-
const s = this.filtered[idx];
|
|
580
|
-
if (!s) {
|
|
581
|
-
rows.push(" ".repeat(lw), " ".repeat(lw));
|
|
582
|
-
continue;
|
|
583
|
-
}
|
|
584
|
-
rows.push(...this.listRows(s, idx === this.selected, lw, rt, hasBody));
|
|
585
|
-
}
|
|
586
|
-
// 每项两行,height 是奇数时会少一行,补空行,否则最后一行右栏会顶到左边
|
|
587
|
-
while (rows.length < height) rows.push(" ".repeat(lw));
|
|
588
|
-
return rows.slice(0, height);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// 用 pi 自己的 Markdown 组件渲染一条消息,和主界面里的对话长得一样;参数照抄 pi 的
|
|
592
|
-
// user-message / assistant-message 组件。渲染比较贵,按消息和宽度缓存,换关键词时不用重来
|
|
593
|
-
private renderMsg(m: PeekMsg, rw: number): string[] {
|
|
594
|
-
const hit = this.rendered.get(m);
|
|
595
|
-
if (hit && hit.w === rw) return hit.lines;
|
|
596
|
-
const t = this.theme;
|
|
597
|
-
const md =
|
|
598
|
-
m.role === "user"
|
|
599
|
-
? new Markdown(
|
|
600
|
-
m.text,
|
|
601
|
-
1,
|
|
602
|
-
0,
|
|
603
|
-
this.mdTheme,
|
|
604
|
-
{ color: (s) => t.fg("userMessageText", s), bgColor: (s) => t.bg("userMessageBg", s) },
|
|
605
|
-
{ preserveOrderedListMarkers: true, preserveBackslashEscapes: true },
|
|
606
|
-
)
|
|
607
|
-
: new Markdown(m.text, 1, 0, this.mdTheme);
|
|
608
|
-
const lines = md.render(rw);
|
|
609
|
-
this.rendered.set(m, { w: rw, lines });
|
|
610
|
-
return lines;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
// 右栏。有关键词时滚到第一个命中处,没有时滚到底部看最新消息
|
|
614
|
-
private buildPreview(rw: number): void {
|
|
615
|
-
const t = this.theme;
|
|
488
|
+
// 右栏。预览键(会话 + 关键词 + 宽度 + 工具开关)没变就复用;变了重建,滚动位置放到首个命中处或底部
|
|
489
|
+
private rebuildPreview(rw: number, rt: RoleTerms): void {
|
|
616
490
|
const s = this.filtered[this.selected];
|
|
617
491
|
const key = s ? `${s.path}|${this.getQuery()}|${rw}|${this.showTools ? "t" : ""}` : "none";
|
|
618
492
|
if (key === this.previewKey) return;
|
|
619
493
|
this.previewKey = key;
|
|
620
|
-
this.
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
this.previewOffset = 0;
|
|
625
|
-
return;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
const lines: string[] = [];
|
|
629
|
-
// 消息数只算有文字的,纯工具轮次不计
|
|
630
|
-
const textCount = s.msgs.reduce((n, m) => n + (m.text ? 1 : 0), 0);
|
|
631
|
-
lines.push(
|
|
632
|
-
truncateToWidth(
|
|
633
|
-
t.fg("dim", `${s.cwd} • ${msg("msgCount", { n: textCount })}${s.name ? " • " + s.name : ""}`),
|
|
634
|
-
rw,
|
|
635
|
-
),
|
|
636
|
-
);
|
|
637
|
-
lines.push("");
|
|
638
|
-
|
|
639
|
-
const rt = roleTerms(this.query().terms);
|
|
640
|
-
const hasBody = rt.user.length > 0 || rt.assistant.length > 0;
|
|
641
|
-
const MAX_MSGS = 500;
|
|
642
|
-
const truncated = s.msgs.length > MAX_MSGS;
|
|
643
|
-
const msgs = truncated ? s.msgs.slice(-MAX_MSGS) : s.msgs;
|
|
644
|
-
if (truncated) {
|
|
645
|
-
lines.push(t.fg("dim", msg("truncated", { n: MAX_MSGS })));
|
|
646
|
-
lines.push("");
|
|
647
|
-
}
|
|
648
|
-
if (hasBody && !msgs.some((m) => anyMatch(m.text, rt[m.role]))) {
|
|
649
|
-
const inTruncated = truncated && s.msgs.some((m) => anyMatch(m.text, rt[m.role]));
|
|
650
|
-
lines.push(
|
|
651
|
-
t.fg(
|
|
652
|
-
"warning",
|
|
653
|
-
inTruncated ? msg("hitInTruncated") : msg("hitOnlyMeta"),
|
|
654
|
-
),
|
|
655
|
-
);
|
|
656
|
-
lines.push("");
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// 用户消息整块背景色,AI 消息只有一条细线标签
|
|
660
|
-
const fillLabel = (label: string): string => {
|
|
661
|
-
const w = visibleWidth(label);
|
|
662
|
-
return truncateToWidth(label + " " + "─".repeat(Math.max(2, rw - w - 1)), rw);
|
|
663
|
-
};
|
|
664
|
-
|
|
665
|
-
let prevRole: PeekMsg["role"] | undefined; // 上一条画出来的消息的角色
|
|
666
|
-
for (const m of msgs) {
|
|
667
|
-
const tools = this.showTools ? (m.tools ?? []) : [];
|
|
668
|
-
if (!m.text && !tools.length) continue; // 工具隐藏时,只有工具调用的 AI 轮次整条跳过
|
|
669
|
-
const terms = rt[m.role]; // user: 的词只在用户消息里高亮,ai: 的只在 AI 回复里
|
|
670
|
-
const isMatch = terms.length > 0 && anyMatch(m.text, terms);
|
|
671
|
-
// 紧跟在 AI 文字后面、只有工具调用的轮次不重复画标签,接在上一条正文下面,看起来是同一轮
|
|
672
|
-
const continues = m.role === "assistant" && prevRole === "assistant" && !m.text;
|
|
673
|
-
if (continues) {
|
|
674
|
-
lines.pop();
|
|
675
|
-
} else if (m.role === "user") {
|
|
676
|
-
lines.push(
|
|
677
|
-
t.bg("userMessageBg", padEndVisible(t.bold(t.fg("accent", fillLabel(msg("you")))), rw)),
|
|
678
|
-
);
|
|
679
|
-
} else {
|
|
680
|
-
lines.push(t.fg("muted", fillLabel(msg("ai"))));
|
|
681
|
-
}
|
|
682
|
-
const head = lines.length - 1;
|
|
683
|
-
// 先渲染再高亮:往 markdown 源码里插转义码会把链接、代码块的语法弄坏
|
|
684
|
-
const before = this.matchLines.length;
|
|
685
|
-
if (m.text) {
|
|
686
|
-
for (const l of this.renderMsg(m, rw)) {
|
|
687
|
-
if (isMatch && lineHasMatch(l, terms)) this.matchLines.push(lines.length);
|
|
688
|
-
lines.push(isMatch ? highlight(l, terms, t) : l);
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
// 关键词被折行拆开时哪一行都找不到,退回到消息标题行,别把这条消息漏掉
|
|
692
|
-
if (isMatch && this.matchLines.length === before) this.matchLines.push(head);
|
|
693
|
-
for (const tool of tools) {
|
|
694
|
-
lines.push(t.fg("dim", truncateToWidth(` ⚙ ${tool.name}${tool.summary ? " " + tool.summary : ""}`, rw)));
|
|
695
|
-
}
|
|
696
|
-
lines.push("");
|
|
697
|
-
prevRole = m.role;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
this.previewLines = lines;
|
|
701
|
-
|
|
702
|
-
if (this.matchLines.length) {
|
|
703
|
-
this.previewOffset = Math.max(0, this.matchLines[0] - 2);
|
|
704
|
-
} else {
|
|
705
|
-
this.previewOffset = Math.max(0, lines.length - this.bodyHeight());
|
|
706
|
-
}
|
|
494
|
+
const p = buildPreview(s, rt, rw, this.showTools, this.theme, this.mdTheme, this.rendered);
|
|
495
|
+
this.previewLines = p.lines;
|
|
496
|
+
this.matchLines = p.matchLines;
|
|
497
|
+
this.previewOffset = initialOffset(p, this.bodyHeight());
|
|
707
498
|
}
|
|
708
499
|
|
|
709
500
|
render(width: number): string[] {
|
|
@@ -715,8 +506,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
715
506
|
return this.cachedLines;
|
|
716
507
|
}
|
|
717
508
|
const t = this.theme;
|
|
718
|
-
const lw
|
|
719
|
-
const rw = Math.max(20, width - lw - 3);
|
|
509
|
+
const { lw, rw } = paneWidths(width);
|
|
720
510
|
this.layout = { lw, rw, H };
|
|
721
511
|
|
|
722
512
|
const out: string[] = [];
|
|
@@ -738,8 +528,16 @@ export class PeekComponent implements Component, Focusable {
|
|
|
738
528
|
out.push(t.fg("borderMuted", "─".repeat(width)));
|
|
739
529
|
|
|
740
530
|
// 双栏
|
|
741
|
-
this.
|
|
742
|
-
|
|
531
|
+
const { rt, hasBody } = this.listTerms();
|
|
532
|
+
this.rebuildPreview(rw, rt);
|
|
533
|
+
// 选中项变了才把视野挪过去;滚轮滚出去的视野留着。列表变短、终端变高后别在底下留空白
|
|
534
|
+
const visible = visibleItems(H);
|
|
535
|
+
if (this.followSel) {
|
|
536
|
+
this.listOffset = followSelection(this.selected, this.listOffset, visible);
|
|
537
|
+
this.followSel = false;
|
|
538
|
+
}
|
|
539
|
+
this.listOffset = Math.min(this.listOffset, Math.max(0, this.filtered.length - visible));
|
|
540
|
+
const leftRows = buildList(this.filtered, this.selected, this.listOffset, H, lw, rt, hasBody, t);
|
|
743
541
|
|
|
744
542
|
const maxOff = Math.max(0, this.previewLines.length - H);
|
|
745
543
|
if (this.previewOffset > maxOff) this.previewOffset = maxOff;
|
|
@@ -754,7 +552,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
754
552
|
if (this.previewLines.length > H) info += ` (${this.previewOffset + 1}-${end}/${this.previewLines.length})`;
|
|
755
553
|
const scrollInfo = info ? t.fg("dim", info) : "";
|
|
756
554
|
|
|
757
|
-
const sel = this.
|
|
555
|
+
const sel = this.drag.active();
|
|
758
556
|
for (let i = 0; i < H; i++) {
|
|
759
557
|
let l = leftRows[i] ?? " ".repeat(lw);
|
|
760
558
|
let r = rightRows[i] ?? "";
|
|
@@ -775,11 +573,11 @@ export class PeekComponent implements Component, Focusable {
|
|
|
775
573
|
|
|
776
574
|
// 底部:重命名和删除确认时换成对应的操作行,复制结果和选区提示也在这里
|
|
777
575
|
out.push(t.fg("borderMuted", "─".repeat(width)));
|
|
778
|
-
if (this.
|
|
576
|
+
if (this.mode === "rename") {
|
|
779
577
|
const prefix = msg("renamePrefix");
|
|
780
578
|
const inputLine = this.renameInput.render(Math.max(10, width - visibleWidth(prefix)))[0] ?? "";
|
|
781
579
|
out.push(truncateToWidth(t.fg("warning", prefix) + inputLine, width));
|
|
782
|
-
} else if (this.
|
|
580
|
+
} else if (this.mode === "confirmDelete") {
|
|
783
581
|
const s = this.filtered[this.selected];
|
|
784
582
|
const desc = s ? `${fmtTime(s.time, true)} ${s.first.slice(0, 30)}` : "";
|
|
785
583
|
out.push(
|