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.
@@ -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 { anyMatch, highlight, lineHasMatch, matchesSession, parseQuery, roleTerms, snippet, type ParsedQuery, type RoleTerms } from "./query.ts";
16
- import { bounds, highlightColumns, rowColumns, selectionText, type Cell, type Pane, type Selection } from "./selection.ts";
17
- import type { PeekMsg, PeekSession } from "./sessions.ts";
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 AUTO_SCROLL_MS = 50; // 拖选拖出预览上下边时自动滚动的节奏,和 pi-tui 一样
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<PeekMsg, { w: number; lines: string[] }>(); // 每条消息渲染好的行,按宽度缓存
40
- private confirmingDelete = false; // Ctrl+D 之后等待确认
41
- private renaming = false; // Ctrl+R 之后正在输入名字
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
- private sel?: Selection; // 鼠标拖出来的选区,只在一栏里
49
- private dragging = false; // 左键还按着
50
- private selMoved = false; // 按下之后拖过没有;没拖过的选区不显示也不算数(那是单击)
51
- private dragX = 0; // 最近一次拖动的列,自动滚动时焦点跟着它
52
- private autoScrollDir = 0; // 拖出预览上下边:-1 往上 1 往下
53
- private autoScrollTimer?: ReturnType<typeof setInterval>;
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: any,
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 && !this.renaming;
92
- this.renameInput.focused = v && this.renaming;
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.clearSelection();
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.clearSelection();
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.stopAutoScroll();
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
- const rows = process.stdout.rows || this.termRows || 24; // 每次都读,终端拉伸后布局跟着变
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.renaming) {
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.renaming = false;
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.renaming = false;
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.confirmingDelete) {
197
- this.confirmingDelete = false;
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.clearSelection();
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.activeSel()) {
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.confirmingDelete = true;
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.renaming = true;
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("f"))) {
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.clearSelection();
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.renaming ? BODY_TOP + this.layout.H + 1 : 1;
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
- let changed = this.confirmingDelete; // 和按键一样,滚一下就算取消确认,免得删错换过去的那条
345
- this.confirmingDelete = false;
345
+ // 和按键一样,滚一下就算取消确认。只退出确认,改名中滚轮不退出改名
346
+ let changed = this.cancelConfirm();
346
347
  if (inList) {
347
- if (!this.renaming) {
348
- // 改名中换了会话就改错对象了
349
- const next = Math.max(0, Math.min(this.filtered.length - 1, this.selected + delta));
350
- if (next !== this.selected) {
351
- this.select(next);
352
- changed = true;
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
- if (!this.dragging || !this.sel || ev.button !== "left") return undefined;
368
- this.dragX = ev.x;
369
- const changed = this.dragTo(ev.x, ev.y);
370
- if (changed) this.selMoved = true;
371
- // 右栏拖出上下边就自动滚;左栏不滚,列表是跟着选中项走的
372
- if (this.sel.pane === "preview") this.setAutoScroll(bodyRow < 0 ? -1 : bodyRow >= H ? 1 : 0);
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.dragging) return undefined;
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 (!this.renaming && !this.busy && (ev.clickCount ?? 1) >= 2) this.onResume?.(s);
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.confirmingDelete || this.activeSel() !== undefined;
395
- this.confirmingDelete = false;
396
- this.clearSelection();
391
+ let changed = this.cancelConfirm() || this.drag.active() !== undefined;
392
+ this.drag.clear();
397
393
  let handled = changed;
398
- if (this.renaming) {
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
- const pane: Pane = inList ? "list" : "preview";
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 cellAt(pane: Pane, x: number, bodyRow: number): Cell {
436
- const { lw, rw, H } = this.layout;
437
- const row = Math.max(0, Math.min(H - 1, bodyRow));
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 && this.listRows(s, (row >> 1) === this.selected, lw, rt, hasBody)[row & 1];
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.activeSel();
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.clearSelection();
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
- let target: number | undefined;
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 listRows(s: PeekSession, sel: boolean, lw: number, rt: RoleTerms, hasBody: boolean): [string, string] {
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.matchLines = [];
621
-
622
- if (!s) {
623
- this.previewLines = [t.fg("dim", msg("noMatch"))];
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 = Math.max(26, Math.min(56, Math.floor(width * 0.4)));
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.buildPreview(rw);
742
- const leftRows = this.buildList(H, lw);
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.activeSel();
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.renaming) {
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.confirmingDelete) {
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(