pi-session-peek 0.5.1 → 0.5.2

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,17 +11,21 @@ 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 {
@@ -31,26 +34,31 @@ export class PeekComponent implements Component, Focusable {
31
34
  private filtered: PeekSession[] = [];
32
35
  private selected = 0;
33
36
  private listOffset = 0;
37
+ private followSel = true; // 下次 render 把选中项带回视野:换选中项、重新过滤、删除后置真,滚轮滚列表不置
34
38
  private previewOffset = 0;
35
39
  private previewKey = ""; // 预览缓存的键(会话 + 关键词 + 宽度 + 工具开关),置空强制重建
36
40
  private showTools = false; // Ctrl+T:预览里显示每次工具调用的一行摘要
37
41
  private previewLines: string[] = [];
38
42
  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 之后正在输入名字
43
+ private rendered: RenderCache = new WeakMap(); // 每条消息渲染好的行,按宽度缓存
44
+ // 三个互斥的输入状态:搜索(默认)、Ctrl+R 之后在输入新名字、Ctrl+D 之后等待确认。改名和确认只从搜索进入、退回搜索
45
+ private mode: "search" | "rename" | "confirmDelete" = "search";
42
46
  private busy = false; // 删除 / 重命名的异步回调还没回来:期间不能再删、改名、进入或分叉,免得对同一个文件动两次
43
47
  private renameInput: Input;
44
48
  private cachedWidth = -1;
45
49
  private cachedLines: string[] = [];
46
50
  private layout = { lw: 0, rw: 0, H: 0 }; // 最近一次 render 的分栏尺寸,鼠标定位用
47
51
  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>;
52
+ // 拖选状态机。分栏尺寸和滚动位置惰性从组件读,自动滚动时把新的预览偏移写回来
53
+ private drag = new DragSelect({
54
+ geometry: () => ({ ...this.layout, listOffset: this.listOffset, previewOffset: this.previewOffset }),
55
+ previewLength: () => this.previewLines.length,
56
+ setPreviewOffset: (n) => void (this.previewOffset = n),
57
+ changed: () => {
58
+ this.invalidate();
59
+ this.requestRender?.();
60
+ },
61
+ });
54
62
  private hint?: { text: string; ok: boolean }; // 底部一闪而过的复制结果
55
63
  private hintTimer?: ReturnType<typeof setTimeout>;
56
64
  private _focused = false;
@@ -69,7 +77,7 @@ export class PeekComponent implements Component, Focusable {
69
77
  constructor(
70
78
  private all: PeekSession[],
71
79
  private currentCwd: string,
72
- private theme: any,
80
+ private theme: PeekTheme,
73
81
  private mdTheme: MarkdownTheme,
74
82
  private termRows: number,
75
83
  initialQuery: string,
@@ -88,8 +96,8 @@ export class PeekComponent implements Component, Focusable {
88
96
  }
89
97
  set focused(v: boolean) {
90
98
  this._focused = v;
91
- this.input.focused = v && !this.renaming;
92
- this.renameInput.focused = v && this.renaming;
99
+ this.input.focused = v && this.mode !== "rename";
100
+ this.renameInput.focused = v && this.mode === "rename";
93
101
  }
94
102
 
95
103
  getQuery(): string {
@@ -119,48 +127,37 @@ export class PeekComponent implements Component, Focusable {
119
127
  const idx = prev ? out.indexOf(prev) : -1;
120
128
  this.selected = idx >= 0 ? idx : keepSelection ? Math.min(this.selected, Math.max(0, out.length - 1)) : 0;
121
129
  if (!keepSelection) this.listOffset = 0;
130
+ this.followSel = true;
122
131
  this.previewKey = "";
123
- this.clearSelection();
132
+ this.drag.clear();
124
133
  }
125
134
 
126
- // 换选中的会话:预览重建,拖选的选区作废
135
+ // 换选中的会话:预览重建,拖选的选区作废,视野跟过去
127
136
  private select(idx: number): void {
128
137
  this.selected = idx;
138
+ this.followSel = true;
129
139
  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;
140
+ this.drag.clear();
143
141
  }
144
142
 
145
143
  dispose(): void {
146
- this.stopAutoScroll();
144
+ this.drag.dispose();
147
145
  if (this.hintTimer) clearTimeout(this.hintTimer);
148
146
  this.hintTimer = undefined;
149
147
  this.onDispose?.();
150
148
  }
151
149
 
152
150
  private bodyHeight(): number {
153
- const rows = process.stdout.rows || this.termRows || 24; // 每次都读,终端拉伸后布局跟着变
154
- return Math.max(8, Math.min(rows - 12, 30));
151
+ return bodyRows(this.termRows);
155
152
  }
156
153
 
157
154
  handleInput(data: string): void {
158
155
  // 重命名输入中:Enter 提交,Esc / Ctrl+C 取消,其他键交给输入框
159
- if (this.renaming) {
156
+ if (this.mode === "rename") {
160
157
  if (matchesKey(data, Key.enter)) {
161
158
  const s = this.filtered[this.selected];
162
159
  const name = this.renameInput.getValue().trim();
163
- this.renaming = false;
160
+ this.mode = "search";
164
161
  this.focused = this._focused;
165
162
  this.invalidate();
166
163
  if (s && name && this.onRename && !this.busy) {
@@ -183,7 +180,7 @@ export class PeekComponent implements Component, Focusable {
183
180
  return;
184
181
  }
185
182
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
186
- this.renaming = false;
183
+ this.mode = "search";
187
184
  this.focused = this._focused;
188
185
  this.invalidate();
189
186
  return;
@@ -193,8 +190,8 @@ export class PeekComponent implements Component, Focusable {
193
190
  return;
194
191
  }
195
192
  // 删除确认中:y / Enter 执行,其他任何键取消
196
- if (this.confirmingDelete) {
197
- this.confirmingDelete = false;
193
+ if (this.mode === "confirmDelete") {
194
+ this.mode = "search";
198
195
  this.invalidate();
199
196
  if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
200
197
  const s = this.filtered[this.selected];
@@ -208,8 +205,9 @@ export class PeekComponent implements Component, Focusable {
208
205
  if (i >= 0) this.filtered.splice(i, 1);
209
206
  // 选中位置留在原地
210
207
  this.selected = Math.min(this.selected, Math.max(0, this.filtered.length - 1));
208
+ this.followSel = true;
211
209
  this.previewKey = "";
212
- this.clearSelection();
210
+ this.drag.clear();
213
211
  }
214
212
  }).catch(() => {
215
213
  }).finally(() => {
@@ -223,7 +221,7 @@ export class PeekComponent implements Component, Focusable {
223
221
  }
224
222
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
225
223
  // 有拖选的选区时 Ctrl+C 是复制,和终端里的习惯一样;没有才是关闭
226
- if (matchesKey(data, Key.ctrl("c")) && this.activeSel()) {
224
+ if (matchesKey(data, Key.ctrl("c")) && this.drag.active()) {
227
225
  this.copySelection();
228
226
  return;
229
227
  }
@@ -232,7 +230,7 @@ export class PeekComponent implements Component, Focusable {
232
230
  }
233
231
  if (matchesKey(data, Key.ctrl("d"))) {
234
232
  if (this.filtered.length && this.onDelete && !this.busy) {
235
- this.confirmingDelete = true;
233
+ this.mode = "confirmDelete";
236
234
  this.invalidate();
237
235
  }
238
236
  return;
@@ -240,7 +238,7 @@ export class PeekComponent implements Component, Focusable {
240
238
  if (matchesKey(data, Key.ctrl("r"))) {
241
239
  const s = this.filtered[this.selected];
242
240
  if (s && this.onRename && !this.busy) {
243
- this.renaming = true;
241
+ this.mode = "rename";
244
242
  this.renameInput.setValue(s.name || "");
245
243
  this.focused = this._focused;
246
244
  this.invalidate();
@@ -292,7 +290,7 @@ export class PeekComponent implements Component, Focusable {
292
290
  if (matchesKey(data, Key.ctrl("t"))) {
293
291
  this.showTools = !this.showTools;
294
292
  this.previewKey = "";
295
- this.clearSelection();
293
+ this.drag.clear();
296
294
  this.invalidate();
297
295
  return;
298
296
  }
@@ -324,7 +322,7 @@ export class PeekComponent implements Component, Focusable {
324
322
  // 硬件光标在组件内的行号:搜索框在第 1 行,改名时在最后一行。常规模式的鼠标桥接靠它算组件顶行
325
323
  cursorRow(): number | undefined {
326
324
  if (!this._focused || !this.layout.H) return undefined;
327
- return this.renaming ? BODY_TOP + this.layout.H + 1 : 1;
325
+ return this.mode === "rename" ? BODY_TOP + this.layout.H + 1 : 1;
328
326
  }
329
327
 
330
328
  // 鼠标:左栏按下选中、双击进入、滚轮换选中项;右栏滚轮滚预览;点头部"范围"切换;点输入框移光标;
@@ -341,16 +339,15 @@ export class PeekComponent implements Component, Focusable {
341
339
  if (ev.type === "wheel") {
342
340
  const delta = ev.wheelDelta ?? 0;
343
341
  if (!delta) return undefined;
344
- let changed = this.confirmingDelete; // 和按键一样,滚一下就算取消确认,免得删错换过去的那条
345
- this.confirmingDelete = false;
342
+ // 和按键一样,滚一下就算取消确认。只退出确认,改名中滚轮不退出改名
343
+ let changed = this.cancelConfirm();
346
344
  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
- }
345
+ // 只滚视图,选中项不动;↑↓、点击、重新过滤会把选中项带回视野
346
+ const maxOff = Math.max(0, this.filtered.length - visibleItems(H));
347
+ const next = Math.max(0, Math.min(maxOff, this.listOffset + delta * WHEEL_ITEMS));
348
+ if (next !== this.listOffset) {
349
+ this.listOffset = next;
350
+ changed = true;
354
351
  }
355
352
  } else {
356
353
  const maxOff = Math.max(0, this.previewLines.length - H);
@@ -364,38 +361,34 @@ export class PeekComponent implements Component, Focusable {
364
361
  return { handled: true, render: changed };
365
362
  }
366
363
  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);
364
+ const d = this.drag;
365
+ if (!d.dragging || !d.sel || ev.button !== "left") return undefined;
366
+ const pane = d.sel.pane;
367
+ const changed = d.move(ev.x, bodyRow);
368
+ // 右栏拖出上下边就自动滚;左栏不滚
369
+ if (pane === "preview") d.autoScroll(bodyRow < 0 ? -1 : bodyRow >= H ? 1 : 0);
373
370
  if (changed) this.invalidate();
374
371
  return { handled: true, render: changed };
375
372
  }
376
373
  if (ev.button !== "left") return undefined;
377
374
  if (ev.type === "release") {
378
- if (!this.dragging) return undefined;
379
- this.dragging = false;
380
- this.stopAutoScroll();
381
- if (!this.selMoved) this.sel = undefined; // 没拖过就是单击
375
+ if (!this.drag.release()) return undefined;
382
376
  return { handled: true, render: false };
383
377
  }
384
378
  if (ev.type === "click") {
385
379
  // 单击在 press 里已经选中了,双击才进入
386
380
  const s = this.filtered[itemAt()];
387
381
  if (!s) return undefined;
388
- if (!this.renaming && !this.busy && (ev.clickCount ?? 1) >= 2) this.onResume?.(s);
382
+ if (this.mode !== "rename" && !this.busy && (ev.clickCount ?? 1) >= 2) this.onResume?.(s);
389
383
  return { handled: true, render: false };
390
384
  }
391
385
  if (ev.type !== "press") return undefined;
392
386
 
393
387
  // 删除确认和已有的选区:点一下就没了,和按键一样
394
- let changed = this.confirmingDelete || this.activeSel() !== undefined;
395
- this.confirmingDelete = false;
396
- this.clearSelection();
388
+ let changed = this.cancelConfirm() || this.drag.active() !== undefined;
389
+ this.drag.clear();
397
390
  let handled = changed;
398
- if (this.renaming) {
391
+ if (this.mode === "rename") {
399
392
  if (ev.y === BODY_TOP + H + 1) {
400
393
  // 改名行:点哪里光标就去哪里
401
394
  this.renameInput.handleMouse({ ...ev, x: ev.x - visibleWidth(msg("renamePrefix")), y: 0 });
@@ -419,11 +412,7 @@ export class PeekComponent implements Component, Focusable {
419
412
  }
420
413
  if (inBody) {
421
414
  // 两栏里按下都可能是拖选的起点,拖起来才算数;接管后面的拖动和松开
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;
415
+ this.drag.begin(inList ? "list" : "preview", ev.x, bodyRow);
427
416
  handled = true;
428
417
  }
429
418
  if (!handled) return undefined;
@@ -431,55 +420,13 @@ export class PeekComponent implements Component, Focusable {
431
420
  return inBody ? { handled: true, capture: true, render: changed } : { handled: true, render: changed };
432
421
  }
433
422
 
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;
423
+ // 鼠标滚轮 / 按下时退出删除确认(只退出确认,改名状态不动)。返回有没有退出
424
+ private cancelConfirm(): boolean {
425
+ if (this.mode !== "confirmDelete") return false;
426
+ this.mode = "search";
448
427
  return true;
449
428
  }
450
429
 
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
430
  // 选区里的纯文本
484
431
  private selectionToText(sel: Selection): string {
485
432
  const { lw, rw } = this.layout;
@@ -487,17 +434,17 @@ export class PeekComponent implements Component, Focusable {
487
434
  const { rt, hasBody } = this.listTerms();
488
435
  return selectionText(sel, lw, (row) => {
489
436
  const s = this.filtered[row >> 1];
490
- return s && this.listRows(s, (row >> 1) === this.selected, lw, rt, hasBody)[row & 1];
437
+ return s && listRows(s, (row >> 1) === this.selected, lw, rt, hasBody, this.theme)[row & 1];
491
438
  });
492
439
  }
493
440
 
494
441
  // Ctrl+C:复制选区,清掉高亮,底部闪一下结果
495
442
  private copySelection(): void {
496
- const sel = this.activeSel();
443
+ const sel = this.drag.active();
497
444
  if (!sel) return;
498
445
  const text = this.selectionToText(sel);
499
446
  const n = text.split("\n").length;
500
- this.clearSelection();
447
+ this.drag.clear();
501
448
  this.invalidate();
502
449
  if (!text.trim() || !this.onCopy) return;
503
450
  void this.onCopy(text).then((ok) => this.showHint(ok ? msg("copied", { n }) : msg("copyFailed"), ok));
@@ -517,19 +464,12 @@ export class PeekComponent implements Component, Focusable {
517
464
  this.requestRender?.();
518
465
  }
519
466
 
520
- // 预览里跳到当前视口之外的下一个 / 上一个命中行,到头了回绕。同一屏里的多个命中算一处,
521
- // 否则一段里连着几行都命中要按好几次才过得去
467
+ // 预览里跳到当前视口之外的下一个 / 上一个命中行,到头了回绕
522
468
  private jumpMatch(dir: 1 | -1): void {
523
- if (!this.matchLines.length) return;
524
469
  const H = this.bodyHeight();
525
470
  const top = this.previewOffset;
526
471
  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
- }
472
+ const target = nextMatchTarget(this.matchLines, top, bottom, dir);
533
473
  if (target !== undefined) {
534
474
  this.previewOffset = Math.max(0, target - 2);
535
475
  this.invalidate();
@@ -542,168 +482,16 @@ export class PeekComponent implements Component, Focusable {
542
482
  return { rt, hasBody: rt.user.length > 0 || rt.assistant.length > 0 };
543
483
  }
544
484
 
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;
485
+ // 右栏。预览键(会话 + 关键词 + 宽度 + 工具开关)没变就复用;变了重建,滚动位置放到首个命中处或底部
486
+ private rebuildPreview(rw: number, rt: RoleTerms): void {
616
487
  const s = this.filtered[this.selected];
617
488
  const key = s ? `${s.path}|${this.getQuery()}|${rw}|${this.showTools ? "t" : ""}` : "none";
618
489
  if (key === this.previewKey) return;
619
490
  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
- }
491
+ const p = buildPreview(s, rt, rw, this.showTools, this.theme, this.mdTheme, this.rendered);
492
+ this.previewLines = p.lines;
493
+ this.matchLines = p.matchLines;
494
+ this.previewOffset = initialOffset(p, this.bodyHeight());
707
495
  }
708
496
 
709
497
  render(width: number): string[] {
@@ -715,8 +503,7 @@ export class PeekComponent implements Component, Focusable {
715
503
  return this.cachedLines;
716
504
  }
717
505
  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);
506
+ const { lw, rw } = paneWidths(width);
720
507
  this.layout = { lw, rw, H };
721
508
 
722
509
  const out: string[] = [];
@@ -738,8 +525,16 @@ export class PeekComponent implements Component, Focusable {
738
525
  out.push(t.fg("borderMuted", "─".repeat(width)));
739
526
 
740
527
  // 双栏
741
- this.buildPreview(rw);
742
- const leftRows = this.buildList(H, lw);
528
+ const { rt, hasBody } = this.listTerms();
529
+ this.rebuildPreview(rw, rt);
530
+ // 选中项变了才把视野挪过去;滚轮滚出去的视野留着。列表变短、终端变高后别在底下留空白
531
+ const visible = visibleItems(H);
532
+ if (this.followSel) {
533
+ this.listOffset = followSelection(this.selected, this.listOffset, visible);
534
+ this.followSel = false;
535
+ }
536
+ this.listOffset = Math.min(this.listOffset, Math.max(0, this.filtered.length - visible));
537
+ const leftRows = buildList(this.filtered, this.selected, this.listOffset, H, lw, rt, hasBody, t);
743
538
 
744
539
  const maxOff = Math.max(0, this.previewLines.length - H);
745
540
  if (this.previewOffset > maxOff) this.previewOffset = maxOff;
@@ -754,7 +549,7 @@ export class PeekComponent implements Component, Focusable {
754
549
  if (this.previewLines.length > H) info += ` (${this.previewOffset + 1}-${end}/${this.previewLines.length})`;
755
550
  const scrollInfo = info ? t.fg("dim", info) : "";
756
551
 
757
- const sel = this.activeSel();
552
+ const sel = this.drag.active();
758
553
  for (let i = 0; i < H; i++) {
759
554
  let l = leftRows[i] ?? " ".repeat(lw);
760
555
  let r = rightRows[i] ?? "";
@@ -775,11 +570,11 @@ export class PeekComponent implements Component, Focusable {
775
570
 
776
571
  // 底部:重命名和删除确认时换成对应的操作行,复制结果和选区提示也在这里
777
572
  out.push(t.fg("borderMuted", "─".repeat(width)));
778
- if (this.renaming) {
573
+ if (this.mode === "rename") {
779
574
  const prefix = msg("renamePrefix");
780
575
  const inputLine = this.renameInput.render(Math.max(10, width - visibleWidth(prefix)))[0] ?? "";
781
576
  out.push(truncateToWidth(t.fg("warning", prefix) + inputLine, width));
782
- } else if (this.confirmingDelete) {
577
+ } else if (this.mode === "confirmDelete") {
783
578
  const s = this.filtered[this.selected];
784
579
  const desc = s ? `${fmtTime(s.time, true)} ${s.first.slice(0, 30)}` : "";
785
580
  out.push(