pi-session-peek 0.4.1 → 0.5.1

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1
4
+
5
+ - Terminal escape sequences and control characters in message text, tool arguments and session names are stripped when parsing, so a pasted colour dump or an OSC sequence in a reply can no longer break the preview layout or reach the terminal.
6
+ - While a delete or rename is still running, `Ctrl+D`, `Ctrl+R`, `Enter`, `Ctrl+O` and double-click are ignored, so the same file cannot be deleted twice or resumed mid-delete.
7
+ - Delete: if a trash command times out but the file is already gone, the result is reported as "moved to trash" instead of falling through to a permanent delete.
8
+ - The scan follows the session directory pi is actually using (`--session-dir`, `$PI_CODING_AGENT_SESSION_DIR` or `sessionDir` in settings), and forks are written there too. With the default layout all projects are still listed.
9
+ - The parse cache is keyed on file size as well as mtime, so two quick writes within the same timestamp tick no longer serve stale content.
10
+ - Rename strips newlines from the new name, matching pi's own `/name`, so the appended record always stays on one line.
11
+
12
+ ## 0.5.0
13
+
14
+ - Mouse support in both panes. Click a session to select it, double-click to resume, wheel over the list to move the selection, wheel over the preview to scroll it (`Alt` for five times faster), click the scope label to toggle it, click the search box or rename line to move the cursor. Pi only enables mouse reporting in fullscreen mode, so in the regular mode the picker turns it on itself while open and locates itself on screen with a cursor position query; the terminal's own text selection is unavailable meanwhile (usually `Shift` restores it). `PI_SESSION_PEEK_MOUSE=0` disables it.
15
+ - Drag to select text inside one pane. The selection never crosses the divider: dragging from the list into the preview, or past either edge, keeps selecting the pane where the drag started, and lines wrap at that pane's width. Dragging below or above the preview scrolls it. Nothing is copied on release; `Ctrl+C` copies the selection (and closes the picker as before when there is none), a click anywhere clears it.
16
+
3
17
  ## 0.4.1
4
18
 
5
19
  - `Ctrl+T` shows tool calls in the preview: one dim line per call with the tool name and its main argument (command, path, URL, query…), placed under the reply that made it. Off by default. Tool results are still not stored, and the tool lines are never searched or highlighted.
package/README.md CHANGED
@@ -74,6 +74,21 @@ In TUI mode, run `/peek` and start typing. Press `Enter` on a session to continu
74
74
  | `Ctrl+D` | Delete, confirmed with `y` or `Enter`. Moves the file to the system trash whenever one is available (`trash` CLI, then the Windows Recycle Bin via PowerShell, Finder on macOS, `gio trash` / `trash-put` on Linux). Deletes permanently only if none of those work. |
75
75
  | `Esc` `Ctrl+C` | Close |
76
76
 
77
+ ## 🖱️ Mouse
78
+
79
+ | Action | Effect |
80
+ | --- | --- |
81
+ | Click a session in the list | Select it |
82
+ | Double-click a session | Resume it (same as `Enter`) |
83
+ | Wheel over the list | Move the selection |
84
+ | Wheel over the preview | Scroll the preview three lines per notch (`Alt` for five times faster) |
85
+ | Click the scope label in the header | Toggle current directory tree ↔ all projects (same as `Tab`) |
86
+ | Click the search box or the rename line | Move the cursor |
87
+ | Drag inside a pane | Select text in that pane only. Dragging across the divider or past the edge keeps the selection in the pane where it started, and lines wrap within that pane. |
88
+ | `Ctrl+C` with a selection | Copy it to the clipboard. Nothing is copied on release. Without a selection `Ctrl+C` closes as before; a click anywhere clears the selection. |
89
+
90
+ Mouse reporting is switched on only while the picker is open and switched off when it closes, so the rest of pi is unaffected. While it is on, the terminal's own text selection and scrollback are unavailable; most terminals give them back while `Shift` is held, but that selection is the terminal's and spans both panes. Set `PI_SESSION_PEEK_MOUSE=0` to keep the mouse off. In pi's fullscreen mode the picker uses pi's own mouse handling and the variable is ignored.
91
+
77
92
  ## 🔎 Search syntax
78
93
 
79
94
  The filter is case-insensitive and matches anywhere in the text. Whitespace separates terms, and every term must be satisfied.
@@ -110,10 +125,10 @@ On Windows PowerShell: `$env:PI_SESSION_PEEK_LANG = "en"` before starting pi. Th
110
125
  ## 🚧 Limitations
111
126
 
112
127
  - TUI mode only.
113
- - No mouse support. Pi only enables mouse input in its experimental fullscreen mode, and this picker does not handle it yet.
128
+ - The mouse needs a terminal that supports SGR mouse reporting and the cursor position report (`CSI 6 n`). Windows Terminal, iTerm2, kitty, WezTerm, Alacritty, GNOME Terminal and VS Code all do; the legacy Windows console does not.
114
129
  - The preview shows the last 500 messages of very long sessions. Hits in earlier messages are still counted and announced.
115
130
  - `pi --peek` without a value is rejected by pi at startup. Use `pi --rp` to open without a keyword.
116
- - Only sessions under `~/.pi/agent/sessions` (or `$PI_CODING_AGENT_DIR/sessions`) are scanned.
131
+ - With the default layout every project under `~/.pi/agent/sessions` (or `$PI_CODING_AGENT_DIR/sessions`) is scanned. If pi runs with a custom session directory (`--session-dir`, `$PI_CODING_AGENT_SESSION_DIR` or `sessionDir` in settings), only that directory is scanned and forks are written there.
117
132
 
118
133
  ## 🗂️ Package layout
119
134
 
@@ -124,6 +139,8 @@ pi-session-peek/
124
139
  │ ├── peek-component.ts # The two-pane TUI component
125
140
  │ ├── sessions.ts # Scans and parses session JSONL, rename / delete helpers
126
141
  │ ├── query.ts # Query parsing, highlighting, hit snippets
142
+ │ ├── mouse.ts # Mouse reporting for pi's regular (non-fullscreen) mode
143
+ │ ├── selection.ts # Pane-confined drag selection: ranges, highlight, text extraction
127
144
  │ ├── i18n.ts # Chinese / English UI strings and locale detection
128
145
  │ └── text.ts # Path, time and width helpers
129
146
  ├── scripts/screenshot.mjs # Optional: renders a synthetic screenshot from component output
package/README.zh-CN.md CHANGED
@@ -74,6 +74,21 @@ pi 扩展以你的用户权限运行,只装信得过的包。
74
74
  | `Ctrl+D` | 删除,按 `y` 或 `Enter` 确认。只要系统有回收站就优先进回收站(`trash` 命令、Windows 用 PowerShell 进回收站、macOS 用 Finder、Linux 用 `gio trash` / `trash-put`),全都不可用才直接删文件。 |
75
75
  | `Esc` `Ctrl+C` | 关闭 |
76
76
 
77
+ ## 🖱️ 鼠标
78
+
79
+ | 操作 | 作用 |
80
+ | --- | --- |
81
+ | 点列表里的会话 | 选中 |
82
+ | 双击会话 | 进入,和 `Enter` 一样 |
83
+ | 在列表上滚滚轮 | 换选中项 |
84
+ | 在预览上滚滚轮 | 预览每格滚三行,按住 `Alt` 快五倍 |
85
+ | 点头部的范围字样 | 当前目录树 ↔ 全部项目,和 `Tab` 一样 |
86
+ | 点搜索框或改名行 | 移动光标 |
87
+ | 在某一栏里按住拖动 | 只选这一栏的文字。拖过分隔线或拖出边界,选区仍留在起点那一栏里,按这一栏的宽度换行。 |
88
+ | 有选区时按 `Ctrl+C` | 复制到剪贴板。松开鼠标不会自动复制。没有选区时 `Ctrl+C` 仍是关闭;点一下任意位置清掉选区。 |
89
+
90
+ 鼠标上报只在选择器打开时开、关闭时关,pi 的其他界面不受影响。开着的时候终端自带的选文字和滚回看历史不能用,多数终端按住 `Shift` 可以照旧,但那是终端自己的选区,会横跨两栏。不想要鼠标就设 `PI_SESSION_PEEK_MOUSE=0`。pi 的全屏模式下走 pi 自己的鼠标分发,这个变量不起作用。
91
+
77
92
  ## 🔎 搜索语法
78
93
 
79
94
  大小写不敏感,匹配文本中任意位置。空白分隔多个词,每个词都要满足。
@@ -110,10 +125,10 @@ Windows PowerShell 里先执行 `$env:PI_SESSION_PEEK_LANG = "en"` 再启动 pi
110
125
  ## 🚧 限制
111
126
 
112
127
  - 只支持 TUI 模式。
113
- - 不支持鼠标。pi 只在实验性的全屏模式里开鼠标,这个选择器还没处理鼠标事件。
128
+ - 鼠标需要终端支持 SGR 鼠标上报和光标位置回报(`CSI 6 n`)。Windows Terminal、iTerm2、kitty、WezTerm、Alacritty、GNOME Terminal、VS Code 都支持,老式 Windows 控制台不支持。
114
129
  - 特别长的会话预览只显示最后 500 条消息,更早的命中仍会计数并提示。
115
130
  - `pi --peek` 不带值会被 pi 在启动时拒绝,不带关键词请用 `pi --rp`。
116
- - 只扫描 `~/.pi/agent/sessions`(或 `$PI_CODING_AGENT_DIR/sessions`)下的会话。
131
+ - pi 默认布局下扫描 `~/.pi/agent/sessions`(或 `$PI_CODING_AGENT_DIR/sessions`)下所有项目的会话。pi 用了自定义会话目录(`--session-dir`、`$PI_CODING_AGENT_SESSION_DIR` 或 settings 里的 `sessionDir`)时只扫描那个目录,分叉也写到那里。
117
132
 
118
133
  ## 🗂️ 目录结构
119
134
 
@@ -124,6 +139,8 @@ pi-session-peek/
124
139
  │ ├── peek-component.ts # 双栏 TUI 组件
125
140
  │ ├── sessions.ts # 扫描解析会话 JSONL,重命名 / 删除
126
141
  │ ├── query.ts # 查询解析、高亮、命中片段
142
+ │ ├── mouse.ts # 常规(非全屏)模式下的鼠标上报
143
+ │ ├── selection.ts # 限定在一栏内的拖选:区间、反显、取文字
127
144
  │ ├── i18n.ts # 中英文界面文案和语言检测
128
145
  │ └── text.ts # 路径、时间、宽度工具
129
146
  ├── scripts/screenshot.mjs # 可选:用组件渲染输出生成一张合成截图
package/index.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { getMarkdownTheme, SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import { copyToClipboard, getMarkdownTheme, SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { basename } from "node:path";
3
3
  import { msg } from "./src/i18n.ts";
4
+ import { attachMouse } from "./src/mouse.ts";
4
5
  import { PeekComponent } from "./src/peek-component.ts";
5
- import { deleteSession, renameSession, scanSessions, type PeekSession } from "./src/sessions.ts";
6
+ import { deleteSession, renameSession, scanRoot, scanSessions, sessionsDir, type PeekSession } from "./src/sessions.ts";
6
7
 
7
8
  // 这里只做注册:/peek 命令、--peek / --rp 启动参数,以及把删除 / 重命名 / 分叉注入给组件
8
9
 
@@ -54,7 +55,10 @@ export default function (pi: ExtensionAPI) {
54
55
 
55
56
  // 当前正在用的会话不列出来
56
57
  const currentFile = ctx.sessionManager.getSessionFile();
57
- const all = (await scanSessions()).filter((s) => s.path !== currentFile);
58
+ // 扫描范围跟着 pi 实际在用的会话目录走(--session-dir / 环境变量 / settings.json 都在里面)
59
+ const root = scanRoot(ctx.sessionManager.getSessionDir());
60
+ const customDir = root !== sessionsDir() ? root : undefined;
61
+ const all = (await scanSessions(root)).filter((s) => s.path !== currentFile);
58
62
  if (!all.length) {
59
63
  ctx.ui.notify(msg("noSessions"), "info");
60
64
  return;
@@ -65,6 +69,18 @@ export default function (pi: ExtensionAPI) {
65
69
  const picked = await ctx.ui.custom<Picked>((tui, theme, _kb, done) => {
66
70
  const comp = new PeekComponent(all, ctx.cwd, theme, getMarkdownTheme(), process.stdout.rows || 24, initial);
67
71
  comp.requestRender = () => tui.requestRender();
72
+ // 常规模式 pi 不开鼠标,自己开;全屏模式 pi-tui 会直接调 comp.handleMouse
73
+ const mouse = attachMouse(comp, tui);
74
+ comp.onDispose = () => mouse.dispose();
75
+ // 鼠标拖选后 Ctrl+C 复制。用 pi 自己的复制:原生剪贴板,不行再退到 OSC 52
76
+ comp.onCopy = async (text) => {
77
+ try {
78
+ await copyToClipboard(text);
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ };
68
84
  comp.onResume = (s) => {
69
85
  lastQuery = comp.getQuery();
70
86
  done({ s, action: "resume" });
@@ -105,7 +121,8 @@ export default function (pi: ExtensionAPI) {
105
121
  if (picked.action === "fork") {
106
122
  // 和 pi --fork 一样:把全部记录复制到新文件,header 里记下 parentSession
107
123
  try {
108
- const forked = SessionManager.forkFrom(picked.s.path, ctx.cwd);
124
+ // 自定义了会话目录时分叉也放进去,别写回默认目录
125
+ const forked = SessionManager.forkFrom(picked.s.path, ctx.cwd, customDir);
109
126
  target = forked.getSessionFile() ?? target;
110
127
  ctx.ui.notify(msg("forked", { file: basename(target) }), "info");
111
128
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-session-peek",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Pi extension: search session history in a two-pane picker with full conversation preview, then resume or fork.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/i18n.ts CHANGED
@@ -32,7 +32,10 @@ const zh = {
32
32
  ai: "🤖 AI",
33
33
  renamePrefix: "✏ 重命名: ",
34
34
  confirmDelete: "⚠ 删除会话 [{desc}]?按 y / Enter 确认,任意其他键取消",
35
- footer: "↑↓ 选择 PgUp/Dn·^U/^F·⇧↑↓ 滚动 ^N/^P 命中 ^T 工具 Tab 范围 ^D 删除 ^R 改名 ^O 分叉 Enter 进入 Esc/^C 关闭",
35
+ footer: "↑↓ 选择 PgUp/Dn·^U/^F·⇧↑↓ 滚动 ^N/^P 命中 ^T 工具 Tab 范围 ^D 删除 ^R 改名 ^O 分叉 Enter 进入 Esc/^C 关闭 🖱 点选·双击进入·拖选·滚轮",
36
+ selHint: "▭ 已选 {n} 行 ^C 复制 点击别处取消",
37
+ copied: "✓ 已复制 {n} 行",
38
+ copyFailed: "✗ 复制失败",
36
39
  since: "近{n}{unit}",
37
40
  unitH: "小时",
38
41
  unitD: "天",
@@ -72,7 +75,10 @@ const en: Record<keyof typeof zh, string> = {
72
75
  ai: "🤖 AI",
73
76
  renamePrefix: "✏ Rename: ",
74
77
  confirmDelete: "⚠ Delete session [{desc}]? y / Enter to confirm, any other key to cancel",
75
- footer: "↑↓ select PgUp/Dn·^U/^F·⇧↑↓ scroll ^N/^P hits ^T tools Tab scope ^D del ^R rename ^O fork Enter open Esc close",
78
+ footer: "↑↓ select PgUp/Dn·^U/^F·⇧↑↓ scroll ^N/^P hits ^T tools Tab scope ^D del ^R rename ^O fork Enter open Esc close 🖱 click·dbl-click·drag·wheel",
79
+ selHint: "▭ {n} lines selected ^C copy click elsewhere to clear",
80
+ copied: "✓ Copied {n} lines",
81
+ copyFailed: "✗ Copy failed",
76
82
  since: "last {n}{unit}",
77
83
  unitH: "h",
78
84
  unitD: "d",
package/src/mouse.ts ADDED
@@ -0,0 +1,207 @@
1
+ import type { TuiInputListenerResult, TuiMouseButton, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
2
+
3
+ // pi 只在全屏模式开鼠标上报,常规模式要自己开:选择器打开时让终端用 SGR 格式上报鼠标(CSI ?1000h ?1006h),
4
+ // 在 TUI 的输入监听里截下 \x1b[<b;x;yM / m,换算成组件内的行列交给 handleMouse,关闭时再关掉。
5
+ //
6
+ // 组件在屏幕上的顶行 pi 自己也不知道:常规模式只做相对光标移动,起始行取决于启动时 shell 的光标停在哪。
7
+ // 所以每次重画之后发一个光标位置查询(CSI 6 n),终端回 \x1b[row;colR。硬件光标停在组件里带焦点的
8
+ // 输入框那一行,减掉这一行在组件里的偏移就是顶行。全屏模式下 pi-tui 自带鼠标分发,这里什么都不做。
9
+ //
10
+ // 开了鼠标上报之后终端自己的选文字 / 滚回看历史就不能用了(多数终端按住 Shift 仍可以),
11
+ // 不想要的话设 PI_SESSION_PEEK_MOUSE=0。
12
+
13
+ // 1002:按着键移动也上报,拖选要用;不认 1002 的终端退到 1000 只报按下松开
14
+ const ENABLE_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
15
+ const DISABLE_MOUSE = "\x1b[?1006l\x1b[?1002l\x1b[?1000l";
16
+ const CURSOR_QUERY = "\x1b[6n";
17
+ const DOUBLE_CLICK_MS = 500; // 和 pi-tui 全屏模式一样
18
+ const QUERY_TIMEOUT_MS = 1000; // 终端不回复就当丢了,允许下次重画再问
19
+ const ALT_WHEEL_MULTIPLIER = 5; // 和 pi-tui 一样,按住 Alt 滚快五倍
20
+ export const DETACH_GRACE_MS = 150; // 关闭后再听一小会儿:终端可能还有已经发出、在路上的鼠标序列
21
+
22
+ const SGR_MOUSE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
23
+ const CURSOR_REPORT = /^\x1b\[(\d+);(\d+)R$/;
24
+
25
+ export interface MouseTarget {
26
+ handleMouse(ev: TuiMouseEvent): TuiMouseEventResult | undefined;
27
+ /** 硬件光标在组件内的行号(0 起);没有焦点时返回 undefined,此时光标不在组件上,测不准 */
28
+ cursorRow(): number | undefined;
29
+ /** 由 attachMouse 赋值,组件每次 render 之后调用 */
30
+ afterRender?: () => void;
31
+ }
32
+
33
+ /** 用到的 TUI 子集,测试里好造假 */
34
+ export interface MouseHost {
35
+ readonly mode: string;
36
+ terminal: { write(data: string): void; readonly columns: number; readonly rows: number };
37
+ addInputListener(listener: (data: string) => TuiInputListenerResult): () => void;
38
+ requestRender(): void;
39
+ }
40
+
41
+ export interface MouseBridge {
42
+ /** 常规模式下为 true;全屏模式 pi-tui 自己分发鼠标,这里不介入 */
43
+ readonly active: boolean;
44
+ dispose(): void;
45
+ }
46
+
47
+ function decodeButton(b: number): TuiMouseButton {
48
+ switch (b & 3) {
49
+ case 0:
50
+ return "left";
51
+ case 1:
52
+ return "middle";
53
+ case 2:
54
+ return "right";
55
+ default:
56
+ return "none";
57
+ }
58
+ }
59
+
60
+ export function attachMouse(target: MouseTarget, tui: MouseHost, env: NodeJS.ProcessEnv = process.env): MouseBridge {
61
+ if (tui.mode !== "regular" || !tui.terminal || env.PI_SESSION_PEEK_MOUSE === "0") {
62
+ return { active: false, dispose() {} };
63
+ }
64
+
65
+ let top: number | undefined; // 组件顶行在屏幕上的行号(0 起),没测到之前鼠标事件只吞不发
66
+ let inflight: number | undefined; // 已发出、还没收到回复的查询:发出时光标所在的组件内行号
67
+ let dirty = false; // 查询在途时又重画了,回复之后要再问一次
68
+ let scheduled = false;
69
+ let timer: ReturnType<typeof setTimeout> | undefined;
70
+ let pressed: { x: number; y: number } | undefined;
71
+ let moved = false; // 按下之后指针换过格没有;换过就不算单击
72
+ let lastClick: { x: number; y: number; at: number; count: number } | undefined;
73
+ let disposed = false;
74
+ let off: (() => void) | undefined;
75
+
76
+ const clearTimer = (): void => {
77
+ if (timer) clearTimeout(timer);
78
+ timer = undefined;
79
+ };
80
+ const detach = (): void => {
81
+ off?.();
82
+ off = undefined;
83
+ };
84
+ // 关闭后不马上摘监听:查询的回复和路上的鼠标序列都要吃掉,别漏进 pi 的编辑器
85
+ const scheduleDetach = (): void => {
86
+ const t = setTimeout(detach, DETACH_GRACE_MS);
87
+ t.unref?.();
88
+ };
89
+ // 查询结束(收到回复或超时)
90
+ const finishQuery = (): void => {
91
+ inflight = undefined;
92
+ clearTimer();
93
+ if (disposed) scheduleDetach();
94
+ else if (dirty) schedule();
95
+ };
96
+ const query = (): void => {
97
+ scheduled = false;
98
+ if (disposed || inflight !== undefined) return;
99
+ const row = target.cursorRow();
100
+ if (row === undefined) return;
101
+ dirty = false;
102
+ inflight = row;
103
+ tui.terminal.write(CURSOR_QUERY);
104
+ timer = setTimeout(finishQuery, QUERY_TIMEOUT_MS);
105
+ timer.unref?.();
106
+ };
107
+ // 等 pi 把这一帧写完、光标摆好再问
108
+ const schedule = (): void => {
109
+ if (scheduled || disposed) return;
110
+ scheduled = true;
111
+ process.nextTick(query);
112
+ };
113
+ target.afterRender = () => {
114
+ dirty = true;
115
+ schedule();
116
+ };
117
+
118
+ off = tui.addInputListener((data): TuiInputListenerResult => {
119
+ const cpr = CURSOR_REPORT.exec(data);
120
+ if (cpr) {
121
+ if (inflight === undefined) return undefined; // 不是我们问的
122
+ top = Number(cpr[1]) - 1 - inflight;
123
+ finishQuery();
124
+ return { consume: true };
125
+ }
126
+ const m = SGR_MOUSE.exec(data);
127
+ if (!m) return undefined;
128
+ if (top === undefined || disposed) return { consume: true };
129
+ const b = Number(m[1]);
130
+ const sx = Number(m[2]) - 1;
131
+ const sy = Number(m[3]) - 1;
132
+ const release = m[4] === "m";
133
+ const base = {
134
+ x: sx,
135
+ y: sy - top,
136
+ screenX: sx,
137
+ screenY: sy,
138
+ width: tui.terminal.columns,
139
+ height: tui.terminal.rows,
140
+ shift: (b & 4) !== 0,
141
+ alt: (b & 8) !== 0,
142
+ ctrl: (b & 16) !== 0,
143
+ };
144
+ let render = false;
145
+ const dispatch = (ev: TuiMouseEvent): void => {
146
+ const r = target.handleMouse(ev);
147
+ if (!r || !(r.handled || r.capture || r.focus)) return;
148
+ // 和 pi-tui 一样:按下、点击、滚轮默认重画,松开默认不重画
149
+ render ||= r.render ?? ev.type !== "release";
150
+ };
151
+ if (b & 64) {
152
+ const dir = (b & 3) === 0 ? -1 : (b & 3) === 1 ? 1 : 0; // 66 / 67 是横向滚轮,不管
153
+ if (!release && dir) {
154
+ dispatch({ ...base, type: "wheel", button: "none", wheelDelta: dir * (base.alt ? ALT_WHEEL_MULTIPLIER : 1) });
155
+ }
156
+ } else {
157
+ const button = decodeButton(b);
158
+ if (b & 32) {
159
+ // 按着键移动。没开 1003,不带键的纯移动不该来,来了也不管
160
+ if (button === "none" || !pressed) return { consume: true };
161
+ if (sx !== pressed.x || sy !== pressed.y) moved = true;
162
+ dispatch({ ...base, type: "drag", button });
163
+ } else if (!release) {
164
+ pressed = { x: sx, y: sy };
165
+ moved = false;
166
+ dispatch({ ...base, type: "press", button });
167
+ } else {
168
+ if (pressed && (sx !== pressed.x || sy !== pressed.y)) moved = true;
169
+ dispatch({ ...base, type: "release", button });
170
+ // 按下松开之间没动过算一次点击;短时间内在同一格连点,次数 1 → 2 → 3 循环
171
+ if (pressed && !moved) {
172
+ const now = Date.now();
173
+ const again = lastClick && now - lastClick.at <= DOUBLE_CLICK_MS && lastClick.x === sx && lastClick.y === sy;
174
+ const count = again && lastClick ? (lastClick.count % 3) + 1 : 1;
175
+ lastClick = { x: sx, y: sy, at: now, count };
176
+ dispatch({ ...base, type: "click", button, clickCount: count });
177
+ }
178
+ pressed = undefined;
179
+ }
180
+ }
181
+ if (render) tui.requestRender();
182
+ return { consume: true };
183
+ });
184
+
185
+ // pi 崩了或被杀时终端会留在鼠标模式,之后点一下 shell 里就冒乱码,退出时尽量关掉
186
+ const restore = (): void => {
187
+ try {
188
+ tui.terminal.write(DISABLE_MOUSE);
189
+ } catch {
190
+ // 退出时 stdout 可能已经关了
191
+ }
192
+ };
193
+ process.once("exit", restore);
194
+
195
+ const dispose = (): void => {
196
+ if (disposed) return;
197
+ disposed = true;
198
+ process.off("exit", restore);
199
+ target.afterRender = undefined;
200
+ // 选择器开着时切到了全屏模式的话,鼠标已经归 pi-tui 管,别去关
201
+ if (tui.mode === "regular") restore();
202
+ if (inflight === undefined) scheduleDetach();
203
+ };
204
+
205
+ tui.terminal.write(ENABLE_MOUSE);
206
+ return { active: true, dispose };
207
+ }
@@ -8,14 +8,23 @@ import {
8
8
  type Component,
9
9
  type Focusable,
10
10
  type MarkdownTheme,
11
+ type TuiMouseEvent,
12
+ type TuiMouseEventResult,
11
13
  } from "@earendil-works/pi-tui";
12
14
  import { msg } from "./i18n.ts";
13
- import { anyMatch, highlight, lineHasMatch, matchesSession, parseQuery, roleTerms, snippet, type ParsedQuery } from "./query.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";
14
17
  import type { PeekMsg, PeekSession } from "./sessions.ts";
15
18
  import { fmtTime, normPath, padEndVisible } from "./text.ts";
16
19
 
17
20
  // 双栏选择器。文件操作(删除 / 重命名 / 分叉)不在这里做,由 index.ts 通过回调注入
18
21
 
22
+ const BODY_TOP = 3; // 头部、搜索框、分隔线之后才是双栏主体
23
+ const SEP = 3; // 两栏之间 " │ " 占的列数
24
+ const WHEEL_LINES = 3; // 滚轮每格滚几行预览,和 Shift+↑↓ 一样
25
+ const AUTO_SCROLL_MS = 50; // 拖选拖出预览上下边时自动滚动的节奏,和 pi-tui 一样
26
+ const HINT_MS = 1500; // 底部"已复制"提示停留时间
27
+
19
28
  export class PeekComponent implements Component, Focusable {
20
29
  private input: Input;
21
30
  private scope: "current" | "all";
@@ -30,9 +39,20 @@ export class PeekComponent implements Component, Focusable {
30
39
  private rendered = new WeakMap<PeekMsg, { w: number; lines: string[] }>(); // 每条消息渲染好的行,按宽度缓存
31
40
  private confirmingDelete = false; // Ctrl+D 之后等待确认
32
41
  private renaming = false; // Ctrl+R 之后正在输入名字
42
+ private busy = false; // 删除 / 重命名的异步回调还没回来:期间不能再删、改名、进入或分叉,免得对同一个文件动两次
33
43
  private renameInput: Input;
34
44
  private cachedWidth = -1;
35
45
  private cachedLines: string[] = [];
46
+ private layout = { lw: 0, rw: 0, H: 0 }; // 最近一次 render 的分栏尺寸,鼠标定位用
47
+ 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>;
54
+ private hint?: { text: string; ok: boolean }; // 底部一闪而过的复制结果
55
+ private hintTimer?: ReturnType<typeof setTimeout>;
36
56
  private _focused = false;
37
57
 
38
58
  // 由 index.ts 注入
@@ -41,7 +61,10 @@ export class PeekComponent implements Component, Focusable {
41
61
  onCancel?: () => void;
42
62
  onDelete?: (s: PeekSession) => Promise<boolean>;
43
63
  onRename?: (s: PeekSession, name: string) => Promise<boolean>;
64
+ onCopy?: (text: string) => Promise<boolean>; // 拖选后 Ctrl+C
65
+ onDispose?: () => void; // 关闭时
44
66
  requestRender?: () => void; // 异步操作完成后让 TUI 重画
67
+ afterRender?: () => void; // 每次 render 之后调用;常规模式的鼠标桥接靠它重新测组件在屏幕上的位置
45
68
 
46
69
  constructor(
47
70
  private all: PeekSession[],
@@ -97,6 +120,33 @@ export class PeekComponent implements Component, Focusable {
97
120
  this.selected = idx >= 0 ? idx : keepSelection ? Math.min(this.selected, Math.max(0, out.length - 1)) : 0;
98
121
  if (!keepSelection) this.listOffset = 0;
99
122
  this.previewKey = "";
123
+ this.clearSelection();
124
+ }
125
+
126
+ // 换选中的会话:预览重建,拖选的选区作废
127
+ private select(idx: number): void {
128
+ this.selected = idx;
129
+ 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
+ }
144
+
145
+ dispose(): void {
146
+ this.stopAutoScroll();
147
+ if (this.hintTimer) clearTimeout(this.hintTimer);
148
+ this.hintTimer = undefined;
149
+ this.onDispose?.();
100
150
  }
101
151
 
102
152
  private bodyHeight(): number {
@@ -113,13 +163,19 @@ export class PeekComponent implements Component, Focusable {
113
163
  this.renaming = false;
114
164
  this.focused = this._focused;
115
165
  this.invalidate();
116
- if (s && name && this.onRename) {
166
+ if (s && name && this.onRename && !this.busy) {
167
+ this.busy = true;
117
168
  void this.onRename(s, name).then((ok) => {
118
169
  if (ok) {
119
170
  s.name = name;
120
171
  // 名字参与搜索,改完可能就不再匹配当前关键词了
121
172
  this.refilter(true);
122
173
  }
174
+ }).catch(() => {
175
+ // 回调自己负责提示;这里吞掉,别变成 unhandled rejection
176
+ }).finally(() => {
177
+ // 放在 finally:回调抛了错也要解锁,否则删除 / 改名 / 进入全部永久失效
178
+ this.busy = false;
123
179
  this.invalidate();
124
180
  this.requestRender?.();
125
181
  });
@@ -142,7 +198,8 @@ export class PeekComponent implements Component, Focusable {
142
198
  this.invalidate();
143
199
  if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
144
200
  const s = this.filtered[this.selected];
145
- if (s && this.onDelete) {
201
+ if (s && this.onDelete && !this.busy) {
202
+ this.busy = true;
146
203
  void this.onDelete(s).then((ok) => {
147
204
  if (ok) {
148
205
  let i = this.all.indexOf(s);
@@ -152,7 +209,11 @@ export class PeekComponent implements Component, Focusable {
152
209
  // 选中位置留在原地
153
210
  this.selected = Math.min(this.selected, Math.max(0, this.filtered.length - 1));
154
211
  this.previewKey = "";
212
+ this.clearSelection();
155
213
  }
214
+ }).catch(() => {
215
+ }).finally(() => {
216
+ this.busy = false;
156
217
  this.invalidate();
157
218
  this.requestRender?.();
158
219
  });
@@ -161,11 +222,16 @@ export class PeekComponent implements Component, Focusable {
161
222
  return;
162
223
  }
163
224
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
225
+ // 有拖选的选区时 Ctrl+C 是复制,和终端里的习惯一样;没有才是关闭
226
+ if (matchesKey(data, Key.ctrl("c")) && this.activeSel()) {
227
+ this.copySelection();
228
+ return;
229
+ }
164
230
  this.onCancel?.();
165
231
  return;
166
232
  }
167
233
  if (matchesKey(data, Key.ctrl("d"))) {
168
- if (this.filtered.length && this.onDelete) {
234
+ if (this.filtered.length && this.onDelete && !this.busy) {
169
235
  this.confirmingDelete = true;
170
236
  this.invalidate();
171
237
  }
@@ -173,7 +239,7 @@ export class PeekComponent implements Component, Focusable {
173
239
  }
174
240
  if (matchesKey(data, Key.ctrl("r"))) {
175
241
  const s = this.filtered[this.selected];
176
- if (s && this.onRename) {
242
+ if (s && this.onRename && !this.busy) {
177
243
  this.renaming = true;
178
244
  this.renameInput.setValue(s.name || "");
179
245
  this.focused = this._focused;
@@ -189,16 +255,14 @@ export class PeekComponent implements Component, Focusable {
189
255
  }
190
256
  if (matchesKey(data, Key.up)) {
191
257
  if (this.selected > 0) {
192
- this.selected--;
193
- this.previewKey = "";
258
+ this.select(this.selected - 1);
194
259
  this.invalidate();
195
260
  }
196
261
  return;
197
262
  }
198
263
  if (matchesKey(data, Key.down)) {
199
264
  if (this.selected < this.filtered.length - 1) {
200
- this.selected++;
201
- this.previewKey = "";
265
+ this.select(this.selected + 1);
202
266
  this.invalidate();
203
267
  }
204
268
  return;
@@ -228,6 +292,7 @@ export class PeekComponent implements Component, Focusable {
228
292
  if (matchesKey(data, Key.ctrl("t"))) {
229
293
  this.showTools = !this.showTools;
230
294
  this.previewKey = "";
295
+ this.clearSelection();
231
296
  this.invalidate();
232
297
  return;
233
298
  }
@@ -241,12 +306,12 @@ export class PeekComponent implements Component, Focusable {
241
306
  }
242
307
  if (matchesKey(data, Key.enter)) {
243
308
  const s = this.filtered[this.selected];
244
- if (s) this.onResume?.(s);
309
+ if (s && !this.busy) this.onResume?.(s);
245
310
  return;
246
311
  }
247
312
  if (matchesKey(data, Key.ctrl("o"))) {
248
313
  const s = this.filtered[this.selected];
249
- if (s) this.onFork?.(s);
314
+ if (s && !this.busy) this.onFork?.(s);
250
315
  return;
251
316
  }
252
317
  // 其余按键交给搜索框
@@ -256,6 +321,202 @@ export class PeekComponent implements Component, Focusable {
256
321
  this.invalidate();
257
322
  }
258
323
 
324
+ // 硬件光标在组件内的行号:搜索框在第 1 行,改名时在最后一行。常规模式的鼠标桥接靠它算组件顶行
325
+ cursorRow(): number | undefined {
326
+ if (!this._focused || !this.layout.H) return undefined;
327
+ return this.renaming ? BODY_TOP + this.layout.H + 1 : 1;
328
+ }
329
+
330
+ // 鼠标:左栏按下选中、双击进入、滚轮换选中项;右栏滚轮滚预览;点头部"范围"切换;点输入框移光标;
331
+ // 在任一栏里按住拖动是选文字,只选这一栏,Ctrl+C 复制。
332
+ // 全屏模式由 pi-tui 按布局直接调,常规模式由 mouse.ts 换算成组件内坐标后调。坐标以组件左上角为原点
333
+ handleMouse(ev: TuiMouseEvent): TuiMouseEventResult | undefined {
334
+ const { lw, H } = this.layout;
335
+ if (!H) return undefined; // 还没画过,不知道分栏在哪
336
+ const inList = ev.x < lw;
337
+ const bodyRow = ev.y - BODY_TOP;
338
+ const inBody = bodyRow >= 0 && bodyRow < H;
339
+ const itemAt = (): number => (inList && inBody ? this.listOffset + (bodyRow >> 1) : -1); // 每项两行
340
+
341
+ if (ev.type === "wheel") {
342
+ const delta = ev.wheelDelta ?? 0;
343
+ if (!delta) return undefined;
344
+ let changed = this.confirmingDelete; // 和按键一样,滚一下就算取消确认,免得删错换过去的那条
345
+ this.confirmingDelete = false;
346
+ 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
+ }
354
+ }
355
+ } else {
356
+ const maxOff = Math.max(0, this.previewLines.length - H);
357
+ const next = Math.max(0, Math.min(maxOff, this.previewOffset + delta * WHEEL_LINES));
358
+ if (next !== this.previewOffset) {
359
+ this.previewOffset = next;
360
+ changed = true;
361
+ }
362
+ }
363
+ if (changed) this.invalidate();
364
+ return { handled: true, render: changed };
365
+ }
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);
373
+ if (changed) this.invalidate();
374
+ return { handled: true, render: changed };
375
+ }
376
+ if (ev.button !== "left") return undefined;
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; // 没拖过就是单击
382
+ return { handled: true, render: false };
383
+ }
384
+ if (ev.type === "click") {
385
+ // 单击在 press 里已经选中了,双击才进入
386
+ const s = this.filtered[itemAt()];
387
+ if (!s) return undefined;
388
+ if (!this.renaming && !this.busy && (ev.clickCount ?? 1) >= 2) this.onResume?.(s);
389
+ return { handled: true, render: false };
390
+ }
391
+ if (ev.type !== "press") return undefined;
392
+
393
+ // 删除确认和已有的选区:点一下就没了,和按键一样
394
+ let changed = this.confirmingDelete || this.activeSel() !== undefined;
395
+ this.confirmingDelete = false;
396
+ this.clearSelection();
397
+ let handled = changed;
398
+ if (this.renaming) {
399
+ if (ev.y === BODY_TOP + H + 1) {
400
+ // 改名行:点哪里光标就去哪里
401
+ this.renameInput.handleMouse({ ...ev, x: ev.x - visibleWidth(msg("renamePrefix")), y: 0 });
402
+ handled = changed = true;
403
+ }
404
+ } else if (ev.y === 1) {
405
+ this.input.handleMouse({ ...ev, y: 0 });
406
+ handled = changed = true;
407
+ } else if (ev.y === 0) {
408
+ if (ev.x >= this.scopeSpan[0] && ev.x < this.scopeSpan[1]) {
409
+ this.scope = this.scope === "all" ? "current" : "all";
410
+ this.refilter();
411
+ handled = changed = true;
412
+ }
413
+ } else {
414
+ const idx = itemAt();
415
+ if (idx >= 0 && idx < this.filtered.length && idx !== this.selected) {
416
+ this.select(idx);
417
+ changed = true;
418
+ }
419
+ }
420
+ if (inBody) {
421
+ // 两栏里按下都可能是拖选的起点,拖起来才算数;接管后面的拖动和松开
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;
427
+ handled = true;
428
+ }
429
+ if (!handled) return undefined;
430
+ if (changed) this.invalidate();
431
+ return inBody ? { handled: true, capture: true, render: changed } : { handled: true, render: changed };
432
+ }
433
+
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;
448
+ return true;
449
+ }
450
+
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
+ // 选区里的纯文本
484
+ private selectionToText(sel: Selection): string {
485
+ const { lw, rw } = this.layout;
486
+ if (sel.pane === "preview") return selectionText(sel, rw, (row) => this.previewLines[row]);
487
+ const { rt, hasBody } = this.listTerms();
488
+ return selectionText(sel, lw, (row) => {
489
+ const s = this.filtered[row >> 1];
490
+ return s && this.listRows(s, (row >> 1) === this.selected, lw, rt, hasBody)[row & 1];
491
+ });
492
+ }
493
+
494
+ // Ctrl+C:复制选区,清掉高亮,底部闪一下结果
495
+ private copySelection(): void {
496
+ const sel = this.activeSel();
497
+ if (!sel) return;
498
+ const text = this.selectionToText(sel);
499
+ const n = text.split("\n").length;
500
+ this.clearSelection();
501
+ this.invalidate();
502
+ if (!text.trim() || !this.onCopy) return;
503
+ void this.onCopy(text).then((ok) => this.showHint(ok ? msg("copied", { n }) : msg("copyFailed"), ok));
504
+ }
505
+
506
+ private showHint(text: string, ok: boolean): void {
507
+ this.hint = { text, ok };
508
+ if (this.hintTimer) clearTimeout(this.hintTimer);
509
+ this.hintTimer = setTimeout(() => {
510
+ this.hintTimer = undefined;
511
+ this.hint = undefined;
512
+ this.invalidate();
513
+ this.requestRender?.();
514
+ }, HINT_MS);
515
+ this.hintTimer.unref?.();
516
+ this.invalidate();
517
+ this.requestRender?.();
518
+ }
519
+
259
520
  // 预览里跳到当前视口之外的下一个 / 上一个命中行,到头了回绕。同一屏里的多个命中算一处,
260
521
  // 否则一段里连着几行都命中要按好几次才过得去
261
522
  private jumpMatch(dir: 1 | -1): void {
@@ -275,11 +536,37 @@ export class PeekComponent implements Component, Focusable {
275
536
  }
276
537
  }
277
538
 
539
+ // 左栏用的关键词:排除词和 name: / cwd: 不在正文里高亮
540
+ private listTerms(): { rt: RoleTerms; hasBody: boolean } {
541
+ const rt = roleTerms(this.query().terms);
542
+ return { rt, hasBody: rt.user.length > 0 || rt.assistant.length > 0 };
543
+ }
544
+
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
+
278
567
  // 左栏,每个会话两行
279
568
  private buildList(height: number, lw: number): string[] {
280
- const t = this.theme;
281
- const rt = roleTerms(this.query().terms); // 排除词和 name: / cwd: 不在正文里高亮
282
- const hasBody = rt.user.length > 0 || rt.assistant.length > 0;
569
+ const { rt, hasBody } = this.listTerms();
283
570
  const visible = Math.max(1, Math.floor(height / 2));
284
571
  if (this.selected < this.listOffset) this.listOffset = this.selected;
285
572
  if (this.selected >= this.listOffset + visible) {
@@ -294,26 +581,7 @@ export class PeekComponent implements Component, Focusable {
294
581
  rows.push(" ".repeat(lw), " ".repeat(lw));
295
582
  continue;
296
583
  }
297
- const sel = idx === this.selected;
298
- const time = fmtTime(s.time);
299
- const cwdTail = s.cwd.replace(/\\/g, "/").split("/").slice(-2).join("/");
300
- // 有正文关键词时显示命中的消息数
301
- const hitBadge = hasBody
302
- ? ` ·${s.msgs.reduce((n, m) => n + (anyMatch(m.text, rt[m.role]) ? 1 : 0), 0)}`
303
- : "";
304
- const l1 = `${sel ? "›" : " "} ${time} ${cwdTail}${hitBadge}`;
305
- // 第二行:有正文关键词时显示命中片段,否则显示首条消息
306
- const snip = hasBody ? snippet(s.msgs, rt, lw) : undefined;
307
- const l2 = snip !== undefined
308
- ? ` ${highlight(snip.text, rt[snip.role], t)}`
309
- : ` ${s.first}${s.name ? ` [${s.name}]` : ""}`;
310
- if (sel) {
311
- rows.push(t.bg("selectedBg", padEndVisible(t.fg("accent", truncateToWidth(l1, lw)), lw)));
312
- rows.push(t.bg("selectedBg", padEndVisible(truncateToWidth(l2, lw), lw)));
313
- } else {
314
- rows.push(padEndVisible(l1, lw));
315
- rows.push(padEndVisible(t.fg("dim", truncateToWidth(l2, lw)), lw));
316
- }
584
+ rows.push(...this.listRows(s, idx === this.selected, lw, rt, hasBody));
317
585
  }
318
586
  // 每项两行,height 是奇数时会少一行,补空行,否则最后一行右栏会顶到左边
319
587
  while (rows.length < height) rows.push(" ".repeat(lw));
@@ -441,16 +709,24 @@ export class PeekComponent implements Component, Focusable {
441
709
  render(width: number): string[] {
442
710
  const H = this.bodyHeight();
443
711
  const cacheKey = width * 1000 + H; // 宽或高变了都要重画
444
- if (this.cachedWidth === cacheKey) return this.cachedLines;
712
+ if (this.cachedWidth === cacheKey) {
713
+ // 缓存命中时组件在屏幕上的位置也可能变了(上面多了一行状态提示),照样通知一次,桥接那边自己去重
714
+ this.afterRender?.();
715
+ return this.cachedLines;
716
+ }
445
717
  const t = this.theme;
446
718
  const lw = Math.max(26, Math.min(56, Math.floor(width * 0.4)));
447
719
  const rw = Math.max(20, width - lw - 3);
720
+ this.layout = { lw, rw, H };
448
721
 
449
722
  const out: string[] = [];
450
723
 
451
724
  // 头部:标题、范围、时间过滤、匹配数,然后是搜索框
452
725
  const scopeLabel = this.scope === "all" ? msg("scopeAll") : msg("scopeCurrent");
453
726
  const { sinceLabel } = this.query();
727
+ // 「范围[Tab]: 当前目录树」整段都能点
728
+ const scopeStart = visibleWidth(msg("title"));
729
+ this.scopeSpan = [scopeStart, scopeStart + visibleWidth(msg("scopeLabel") + scopeLabel)];
454
730
  const head =
455
731
  t.fg("accent", t.bold(msg("title"))) +
456
732
  t.fg("dim", msg("scopeLabel")) +
@@ -478,17 +754,26 @@ export class PeekComponent implements Component, Focusable {
478
754
  if (this.previewLines.length > H) info += ` (${this.previewOffset + 1}-${end}/${this.previewLines.length})`;
479
755
  const scrollInfo = info ? t.fg("dim", info) : "";
480
756
 
757
+ const sel = this.activeSel();
481
758
  for (let i = 0; i < H; i++) {
482
- const l = leftRows[i] ?? " ".repeat(lw);
759
+ let l = leftRows[i] ?? " ".repeat(lw);
483
760
  let r = rightRows[i] ?? "";
484
- if (i === 0 && scrollInfo) {
485
- r = truncateToWidth(r, Math.max(0, rw - visibleWidth(scrollInfo))) + scrollInfo;
761
+ const infoW = i === 0 && scrollInfo ? visibleWidth(scrollInfo) : 0;
762
+ if (infoW) r = truncateToWidth(r, Math.max(0, rw - infoW));
763
+ r = padEndVisible(r, rw - infoW) + (infoW ? scrollInfo : "");
764
+ // 拖选的高亮最后叠上去,反显整格;右栏第一行别盖到右上角的命中信息
765
+ if (sel?.pane === "list") {
766
+ const cols = rowColumns(sel, this.listOffset * 2 + i, l, lw);
767
+ if (cols) l = highlightColumns(l, cols[0], cols[1]);
768
+ } else if (sel?.pane === "preview") {
769
+ const cols = rowColumns(sel, this.previewOffset + i, r, rw - infoW);
770
+ if (cols) r = highlightColumns(r, cols[0], cols[1]);
486
771
  }
487
772
  // 左栏在 buildList 里已经 pad 到 lw,这里再 pad 会把带样式的行截坏
488
- out.push(l + t.fg("borderMuted", " │ ") + padEndVisible(r, rw));
773
+ out.push(l + t.fg("borderMuted", " │ ") + r);
489
774
  }
490
775
 
491
- // 底部:重命名和删除确认时换成对应的操作行
776
+ // 底部:重命名和删除确认时换成对应的操作行,复制结果和选区提示也在这里
492
777
  out.push(t.fg("borderMuted", "─".repeat(width)));
493
778
  if (this.renaming) {
494
779
  const prefix = msg("renamePrefix");
@@ -503,6 +788,11 @@ export class PeekComponent implements Component, Focusable {
503
788
  width,
504
789
  ),
505
790
  );
791
+ } else if (this.hint) {
792
+ out.push(truncateToWidth(t.fg(this.hint.ok ? "success" : "error", this.hint.text), width));
793
+ } else if (sel) {
794
+ const [s, e] = bounds(sel);
795
+ out.push(truncateToWidth(t.fg("warning", msg("selHint", { n: e.row - s.row + 1 })), width));
506
796
  } else {
507
797
  out.push(
508
798
  truncateToWidth(
@@ -514,6 +804,7 @@ export class PeekComponent implements Component, Focusable {
514
804
 
515
805
  this.cachedWidth = cacheKey;
516
806
  this.cachedLines = out;
807
+ this.afterRender?.();
517
808
  return out;
518
809
  }
519
810
 
@@ -0,0 +1,96 @@
1
+ import { sliceByColumn, stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ // 鼠标拖出来的选区。只在一个栏里:行号是这一栏内容的绝对行号(左栏每个会话两行,右栏是预览行),
4
+ // 列号是栏内列号。起止格都算在内,中间的行整行选中,所以从左栏拖到右栏也只会选左栏的字
5
+
6
+ export type Pane = "list" | "preview";
7
+
8
+ export interface Cell {
9
+ row: number;
10
+ col: number;
11
+ }
12
+
13
+ export interface Selection {
14
+ pane: Pane;
15
+ anchor: Cell; // 按下的格
16
+ focus: Cell; // 现在拖到的格
17
+ }
18
+
19
+ // CSI / OSC / APC 序列,反显时要原样保留
20
+ const SEQ = /\x1b\[[0-9;?<>=!]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b_[^\x07\x1b]*(?:\x07|\x1b\\)/g;
21
+ const TAIL = new RegExp(`(?:${SEQ.source})+$`); // 行尾的样式收尾码
22
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
23
+
24
+ /** 起止格,按阅读顺序 */
25
+ export function bounds(sel: Selection): [Cell, Cell] {
26
+ const a = sel.anchor;
27
+ const f = sel.focus;
28
+ const ordered = a.row < f.row || (a.row === f.row && a.col <= f.col);
29
+ return ordered ? [a, f] : [f, a];
30
+ }
31
+
32
+ // 每个字符占的列区间,宽字符两列
33
+ function cells(line: string): { start: number; end: number }[] {
34
+ const out: { start: number; end: number }[] = [];
35
+ let col = 0;
36
+ for (const { segment } of graphemes.segment(stripTerminalSequences(line))) {
37
+ const w = visibleWidth(segment);
38
+ if (w > 0) out.push({ start: col, end: col + w });
39
+ col += w;
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /**
45
+ * 某一行被选中的列区间 [c0, c1)。边界吸附到整个字符,宽字符不会被切掉一半;
46
+ * 这一行不在选区里返回 undefined。width 是这一栏的宽度
47
+ */
48
+ export function rowColumns(sel: Selection, row: number, line: string, width: number): [number, number] | undefined {
49
+ const [s, e] = bounds(sel);
50
+ if (row < s.row || row > e.row) return undefined;
51
+ let c0 = row === s.row ? s.col : 0;
52
+ let c1 = row === e.row ? e.col + 1 : width;
53
+ const cs = cells(line);
54
+ const g0 = cs.find((g) => c0 >= g.start && c0 < g.end);
55
+ if (g0) c0 = g0.start;
56
+ const g1 = cs.find((g) => c1 - 1 >= g.start && c1 - 1 < g.end);
57
+ if (g1) c1 = g1.end;
58
+ c0 = Math.max(0, Math.min(c0, width));
59
+ c1 = Math.max(c0, Math.min(c1, width));
60
+ return c1 > c0 ? [c0, c1] : undefined;
61
+ }
62
+
63
+ // 整段反显。段里原有的样式码照留,每个样式码后面补一次反显,免得被 0m 之类的重置掉
64
+ function inverse(text: string): string {
65
+ let out = "\x1b[7m";
66
+ let last = 0;
67
+ for (const m of text.matchAll(SEQ)) {
68
+ out += text.slice(last, m.index) + m[0];
69
+ if (m[0].startsWith("\x1b[") && m[0].endsWith("m")) out += "\x1b[7m";
70
+ last = m.index + m[0].length;
71
+ }
72
+ return out + text.slice(last) + "\x1b[27m";
73
+ }
74
+
75
+ /** 给一行的 [c0, c1) 列加反显,其余部分原样 */
76
+ export function highlightColumns(line: string, c0: number, c1: number): string {
77
+ const w = visibleWidth(line);
78
+ const before = sliceByColumn(line, 0, c0, true);
79
+ const mid = sliceByColumn(line, c0, c1 - c0, true);
80
+ const after = sliceByColumn(line, c1, Math.max(0, w - c1), true);
81
+ // sliceByColumn 切到行尾时会丢掉最后那些收尾码(39m、49m 之类),补回去,否则背景色会漏到右边
82
+ const tail = TAIL.exec(line)?.[0] ?? "";
83
+ return before + inverse(mid) + after + tail;
84
+ }
85
+
86
+ /** 选区里的纯文本:每行去掉样式和尾部空白,行间用换行。lineAt 给不出的行当空行 */
87
+ export function selectionText(sel: Selection, width: number, lineAt: (row: number) => string | undefined): string {
88
+ const [s, e] = bounds(sel);
89
+ const lines: string[] = [];
90
+ for (let row = s.row; row <= e.row; row++) {
91
+ const line = lineAt(row) ?? "";
92
+ const cols = rowColumns(sel, row, line, width);
93
+ lines.push(cols ? stripTerminalSequences(sliceByColumn(line, cols[0], cols[1] - cols[0], true)).trimEnd() : "");
94
+ }
95
+ return lines.join("\n");
96
+ }
package/src/sessions.ts CHANGED
@@ -2,7 +2,8 @@ import { appendFileSync, existsSync, readFileSync, rmSync } from "node:fs";
2
2
  import { readdir, readFile, stat } from "node:fs/promises";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
- import { join } from "node:path";
5
+ import { dirname, join } from "node:path";
6
+ import { normPath } from "./text.ts";
6
7
 
7
8
  // 读 ~/.pi/agent/sessions 下的会话 JSONL,按 mtime 缓存;重命名和删除也在这里。
8
9
  // 搜索不预存小写全文:会话正文只在 msgs 里放一份,匹配时用不区分大小写的正则直接扫(见 query.ts 的 matchesSession)
@@ -26,18 +27,29 @@ export interface PeekSession {
26
27
  first: string; // 首条消息,列表里显示用
27
28
  msgs: PeekMsg[];
28
29
  mtime: number;
30
+ size: number; // 和 mtime 一起当缓存键:mtime 粒度粗的文件系统上连续两次写入可能同一个 mtime
29
31
  }
30
32
 
31
33
  const sessionCache = new Map<string, PeekSession>();
32
34
  const READ_CONCURRENCY = 16; // 同时读的文件数,太高会把 libuv 线程池排满
33
35
 
34
- // 和 pi 一样认 PI_CODING_AGENT_DIR
35
- function sessionsDir(): string {
36
- const env = process.env.PI_CODING_AGENT_DIR;
37
- const agent = env ? (env.startsWith("~") ? join(homedir(), env.slice(1)) : env) : join(homedir(), ".pi", "agent");
36
+ const expandTilde = (p: string) => (p.startsWith("~") ? join(homedir(), p.slice(1)) : p);
37
+
38
+ // 默认布局下所有项目的会话根目录:和 pi 一样认 PI_CODING_AGENT_DIR,下面每个 cwd 一个子目录
39
+ export function sessionsDir(env: NodeJS.ProcessEnv = process.env): string {
40
+ const agent = env.PI_CODING_AGENT_DIR ? expandTilde(env.PI_CODING_AGENT_DIR) : join(homedir(), ".pi", "agent");
38
41
  return join(agent, "sessions");
39
42
  }
40
43
 
44
+ /** 要扫描的根目录。pi 的 getSessionDir() 是参数、环境变量、settings.json 合并后的结果:
45
+ * 默认布局时它是 <root>/sessions/<按 cwd 编码的子目录>,取上一级才能看到所有项目;
46
+ * 用户自定义了目录(--session-dir 等)时就是那个目录本身,直接扫它 */
47
+ export function scanRoot(currentSessionDir: string | undefined, env: NodeJS.ProcessEnv = process.env): string {
48
+ const root = sessionsDir(env);
49
+ if (!currentSessionDir) return root;
50
+ return normPath(dirname(currentSessionDir)) === normPath(root) ? root : currentSessionDir;
51
+ }
52
+
41
53
  async function walkJsonl(dir: string, out: string[]): Promise<void> {
42
54
  let entries;
43
55
  try {
@@ -52,13 +64,20 @@ async function walkJsonl(dir: string, out: string[]): Promise<void> {
52
64
  }
53
65
  }
54
66
 
67
+ // 终端转义序列(CSI / OSC / DCS / APC 等)和除 \t \n 外的 C0 控制字符。正文里带这些会原样写到屏幕上:
68
+ // 排版错乱、宽度算错,OSC 52 之类的还能改剪贴板 / 窗口标题
69
+ const CONTROL_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|[\]P_^X][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-Z\\-_])|[\x00-\x08\x0b-\x1f\x7f]/g;
70
+ export const sanitizeText = (s: string) => s.replace(/\r\n?/g, "\n").replace(CONTROL_RE, "");
71
+
55
72
  function extractText(content: unknown): string {
56
- if (typeof content === "string") return content;
73
+ if (typeof content === "string") return sanitizeText(content);
57
74
  if (!Array.isArray(content)) return "";
58
- return content
59
- .filter((c: any) => c?.type === "text" && typeof c.text === "string")
60
- .map((c: any) => c.text)
61
- .join("\n");
75
+ return sanitizeText(
76
+ content
77
+ .filter((c: any) => c?.type === "text" && typeof c.text === "string")
78
+ .map((c: any) => c.text)
79
+ .join("\n"),
80
+ );
62
81
  }
63
82
 
64
83
  // 工具调用的主参数:按常见键名找第一个字符串,都没有就取第一个字符串参数。不存结果,bash 输出动辄几十 KB
@@ -71,7 +90,7 @@ export function toolSummary(args: unknown): string {
71
90
  let v = TOOL_ARG_KEYS.map((k) => o[k]).find((x) => typeof x === "string" && x.trim());
72
91
  if (typeof v !== "string") v = Object.values(o).find((x) => typeof x === "string" && (x as string).trim());
73
92
  if (typeof v !== "string") return "";
74
- return v.replace(/\s+/g, " ").trim().slice(0, TOOL_SUMMARY_MAX);
93
+ return sanitizeText(v).replace(/\s+/g, " ").trim().slice(0, TOOL_SUMMARY_MAX);
75
94
  }
76
95
 
77
96
  function extractTools(content: unknown): PeekTool[] {
@@ -81,7 +100,7 @@ function extractTools(content: unknown): PeekTool[] {
81
100
  .map((c: any) => ({ name: c.name, summary: toolSummary(c.arguments) }));
82
101
  }
83
102
 
84
- function parseSession(path: string, raw: string, mtime: number): PeekSession | null {
103
+ function parseSession(path: string, raw: string, mtime: number, size: number): PeekSession | null {
85
104
  let cwd = "";
86
105
  let time = "";
87
106
  let name = "";
@@ -98,7 +117,7 @@ function parseSession(path: string, raw: string, mtime: number): PeekSession | n
98
117
  }
99
118
  if (line.includes('"type":"session_info"')) {
100
119
  const o = JSON.parse(line);
101
- if (typeof o.name === "string" && o.name) name = o.name;
120
+ if (typeof o.name === "string") name = sanitizeText(o.name).trim(); // 空名字是显式清掉
102
121
  continue;
103
122
  }
104
123
  // 先用子串粗筛,省掉大部分行的 JSON.parse
@@ -127,6 +146,7 @@ function parseSession(path: string, raw: string, mtime: number): PeekSession | n
127
146
  name,
128
147
  msgs,
129
148
  mtime,
149
+ size,
130
150
  first: firstText.text.replace(/\s+/g, " ").slice(0, 80),
131
151
  };
132
152
  }
@@ -134,29 +154,32 @@ function parseSession(path: string, raw: string, mtime: number): PeekSession | n
134
154
  // 单个文件:mtime 没变直接用缓存,否则重新读和解析
135
155
  async function loadSession(file: string): Promise<PeekSession | null> {
136
156
  let mtime: number;
157
+ let size: number;
137
158
  try {
138
- mtime = (await stat(file)).mtimeMs;
159
+ const st = await stat(file);
160
+ mtime = st.mtimeMs;
161
+ size = st.size;
139
162
  } catch {
140
163
  return null;
141
164
  }
142
165
  const hit = sessionCache.get(file);
143
- if (hit && hit.mtime === mtime) return hit;
166
+ if (hit && hit.mtime === mtime && hit.size === size) return hit;
144
167
  let raw: string;
145
168
  try {
146
169
  raw = await readFile(file, "utf8");
147
170
  } catch {
148
171
  return null;
149
172
  }
150
- const parsed = parseSession(file, raw, mtime);
173
+ const parsed = parseSession(file, raw, mtime, size);
151
174
  if (parsed) sessionCache.set(file, parsed);
152
175
  return parsed;
153
176
  }
154
177
 
155
178
  /** 全部会话,按最后修改时间倒序;文件没变的直接用缓存。
156
179
  * 目录遍历和文件读取都走异步 I/O,几百个会话也不会把 TUI 卡住 */
157
- export async function scanSessions(): Promise<PeekSession[]> {
180
+ export async function scanSessions(root: string = sessionsDir()): Promise<PeekSession[]> {
158
181
  const files: string[] = [];
159
- await walkJsonl(sessionsDir(), files);
182
+ await walkJsonl(root, files);
160
183
 
161
184
  const out: PeekSession[] = [];
162
185
  let next = 0;
@@ -179,6 +202,7 @@ export async function scanSessions(): Promise<PeekSession[]> {
179
202
 
180
203
  // 和 pi 自带的 /name 写同一种 session_info 记录,parentId 用文件里最后一条记录的 id
181
204
  export function renameSession(path: string, name: string): void {
205
+ name = sanitizeText(name).replace(/\n+/g, " ").trim(); // 和 pi 的 appendSessionInfo 一样,换行不能进 JSONL 的一行
182
206
  const raw = readFileSync(path, "utf8");
183
207
  const lines = raw.split("\n");
184
208
  let parentId: string | null = null;
@@ -227,18 +251,21 @@ export function trashCommands(path: string, platform: NodeJS.Platform = process.
227
251
  }
228
252
 
229
253
  // 优先进回收站(能恢复),每个途径都不行再直接删。exec 由调用方传入,方便测试;
230
- // 命令退出码为 0 但文件还在也当失败,继续试下一个
254
+ // 命令退出码为 0 但文件还在也当失败,继续试下一个。命令超时被杀但其实已经把文件移走了的,
255
+ // 事后再看一眼文件,别把"已进回收站"报成"永久删除"
231
256
  export async function deleteSession(
232
257
  path: string,
233
258
  exec: (cmd: string, args: string[]) => Promise<{ code: number }>,
234
259
  platform: NodeJS.Platform = process.platform,
235
260
  ): Promise<DeleteResult> {
261
+ const wasThere = existsSync(path);
236
262
  for (const [cmd, args] of trashCommands(path, platform)) {
237
263
  try {
238
264
  const r = await exec(cmd, args);
239
265
  if (r.code === 0 && !existsSync(path)) return "trash";
240
266
  } catch {
241
267
  }
268
+ if (wasThere && !existsSync(path)) return "trash";
242
269
  }
243
270
  try {
244
271
  rmSync(path, { force: true });