pi-session-peek 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - 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.
6
+ - 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.
7
+
8
+ ## 0.4.1
9
+
10
+ - `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.
11
+ - Assistant turns that only call tools (no text) are now kept when parsing, so with `Ctrl+T` on the preview no longer jumps from "let me check" straight to the conclusion. With it off they are skipped as before.
12
+
3
13
  ## 0.4.0
4
14
 
5
15
  - Search syntax: `"exact phrase"` (whitespace inside the quotes matches any whitespace, including line breaks), `a|b` for either, `-word` to exclude, `name:` / `dir:` (`cwd:`) / `user:` / `ai:` (`assistant:`) to limit a term to one field, and `/regex/` for JavaScript regular expressions. Prefixes stack (`-user:"not now"`), quotes escape everything else, and an invalid regex falls back to plain text.
package/README.md CHANGED
@@ -12,11 +12,12 @@ Search your [pi](https://pi.dev) session history by what was actually said, read
12
12
 
13
13
  - Opens a two-pane picker with `/peek` or `/peek <keyword>`: sessions on the left, the full conversation on the right.
14
14
  - Renders the conversation with pi's own Markdown renderer, so headings, code blocks with syntax highlighting, tables and lists look the same as in the main transcript.
15
- - Filters as you type across conversation text, session name and working directory. Tool call arguments and results are excluded, so a keyword only matches sessions that actually discussed it.
15
+ - Filters as you type across conversation text, session name and working directory. Tool call arguments and results are excluded from the search, so a keyword only matches sessions that actually discussed it.
16
16
  - Requires every space-separated keyword to match, and shows a snippet around the first hit in the list.
17
17
  - Understands `"exact phrases"`, `either|or`, `-exclude`, `name:` / `dir:` / `user:` / `ai:` prefixes and `/regex/`.
18
18
  - Limits results to recently active sessions with `@7d`, `@24h`, `@2w` or `@1m`.
19
19
  - Highlights every hit in the preview and jumps between them with `Ctrl+N` / `Ctrl+P`.
20
+ - Shows what the assistant actually did with `Ctrl+T`: one dim line per tool call (`⚙ bash git status`, `⚙ edit src/query.ts`) under the reply that made it. Off by default.
20
21
  - Resumes with `Enter`, forks into a new session with `Ctrl+O`, renames with `Ctrl+R`, deletes with `Ctrl+D`.
21
22
  - Toggles between the current directory tree and all projects with `Tab`.
22
23
  - Starts straight into the picker with `pi --rp` or `pi --peek=<keyword>`.
@@ -66,12 +67,28 @@ In TUI mode, run `/peek` and start typing. Press `Enter` on a session to continu
66
67
  | `Ctrl+U` `Ctrl+F` | Scroll preview by half a page |
67
68
  | `Shift+↑` `Shift+↓` | Scroll preview by three lines |
68
69
  | `Ctrl+N` `Ctrl+P` | Next / previous hit line outside the current view; the top-right corner shows `hit k/n` |
70
+ | `Ctrl+T` | Show / hide tool calls in the preview. Each call is one line with the tool name and its main argument (command, path, URL, query). Results are never shown. |
69
71
  | `Enter` | Resume the session |
70
72
  | `Ctrl+O` | Fork the session and open the fork |
71
73
  | `Ctrl+R` | Rename. Writes the same `session_info` entry as `/name`. |
72
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. |
73
75
  | `Esc` `Ctrl+C` | Close |
74
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
+
75
92
  ## 🔎 Search syntax
76
93
 
77
94
  The filter is case-insensitive and matches anywhere in the text. Whitespace separates terms, and every term must be satisfied.
@@ -90,7 +107,7 @@ The filter is case-insensitive and matches anywhere in the text. Whitespace sepa
90
107
 
91
108
  Quotes are the escape hatch: `"-foo"`, `"a|b"`, `"name:x"` and `"/x/"` search for those characters literally. A regex that fails to compile is searched as plain text. Only positive terms are highlighted in the preview, and `name:` / `dir:` terms never touch the conversation, so `name:auth` alone shows the full conversation with no highlights.
92
109
 
93
- The search index holds user and assistant messages, the session name and the working directory. It does not hold tool call arguments or tool results.
110
+ The search index holds user and assistant messages, the session name and the working directory. It does not hold tool call arguments or tool results, and the tool lines shown by `Ctrl+T` are never matched or highlighted.
94
111
 
95
112
  ## 🌐 Language
96
113
 
@@ -108,7 +125,7 @@ On Windows PowerShell: `$env:PI_SESSION_PEEK_LANG = "en"` before starting pi. Th
108
125
  ## 🚧 Limitations
109
126
 
110
127
  - TUI mode only.
111
- - 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.
112
129
  - The preview shows the last 500 messages of very long sessions. Hits in earlier messages are still counted and announced.
113
130
  - `pi --peek` without a value is rejected by pi at startup. Use `pi --rp` to open without a keyword.
114
131
  - Only sessions under `~/.pi/agent/sessions` (or `$PI_CODING_AGENT_DIR/sessions`) are scanned.
@@ -122,6 +139,8 @@ pi-session-peek/
122
139
  │ ├── peek-component.ts # The two-pane TUI component
123
140
  │ ├── sessions.ts # Scans and parses session JSONL, rename / delete helpers
124
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
125
144
  │ ├── i18n.ts # Chinese / English UI strings and locale detection
126
145
  │ └── text.ts # Path, time and width helpers
127
146
  ├── scripts/screenshot.mjs # Optional: renders a synthetic screenshot from component output
package/README.zh-CN.md CHANGED
@@ -12,11 +12,12 @@
12
12
 
13
13
  - `/peek` 或 `/peek 关键词` 打开左右分栏的选择器:左边是会话列表,右边是完整对话。
14
14
  - 右栏用 pi 自己的 Markdown 渲染器画,标题、带语法高亮的代码块、表格、列表和主界面里的对话一个样子。
15
- - 边打字边过滤,搜的是对话正文、会话名和工作目录。工具调用的参数和结果不进索引,所以只会命中真正讨论过这个词的会话。
15
+ - 边打字边过滤,搜的是对话正文、会话名和工作目录。工具调用的参数和结果不进搜索索引,所以只会命中真正讨论过这个词的会话。
16
16
  - 空格分隔多个关键词,全部命中才显示;列表里直接显示第一个命中处的片段。
17
17
  - 支持 `"精确短语"`、`a|b` 任一命中、`-排除`、`name:` / `dir:` / `user:` / `ai:` 限定范围,以及 `/正则/`。
18
18
  - `@7d`、`@24h`、`@2w`、`@1m` 只看最近活动过的会话。
19
19
  - 预览里所有命中都高亮,`Ctrl+N` / `Ctrl+P` 在命中间跳。
20
+ - `Ctrl+T` 显示 AI 实际做了什么:每次工具调用在对应回复下面占一行灰字(`⚙ bash git status`、`⚙ edit src/query.ts`)。默认关闭。
20
21
  - `Enter` 进入会话,`Ctrl+O` 分叉成新会话,`Ctrl+R` 重命名,`Ctrl+D` 删除。
21
22
  - `Tab` 在当前目录树和全部项目之间切换。
22
23
  - `pi --rp` 或 `pi --peek=关键词` 启动时直接打开。
@@ -66,12 +67,28 @@ pi 扩展以你的用户权限运行,只装信得过的包。
66
67
  | `Ctrl+U` `Ctrl+F` | 预览翻半页 |
67
68
  | `Shift+↑` `Shift+↓` | 预览滚三行 |
68
69
  | `Ctrl+N` `Ctrl+P` | 跳到当前屏幕外的下一处 / 上一处命中行,右上角显示「命中 k/n」 |
70
+ | `Ctrl+T` | 预览里显示 / 隐藏工具调用。每次调用一行:工具名和主参数(命令、路径、URL、查询词)。不显示结果。 |
69
71
  | `Enter` | 进入会话 |
70
72
  | `Ctrl+O` | 分叉出新会话并进入 |
71
73
  | `Ctrl+R` | 重命名,写入的记录和 `/name` 一样 |
72
74
  | `Ctrl+D` | 删除,按 `y` 或 `Enter` 确认。只要系统有回收站就优先进回收站(`trash` 命令、Windows 用 PowerShell 进回收站、macOS 用 Finder、Linux 用 `gio trash` / `trash-put`),全都不可用才直接删文件。 |
73
75
  | `Esc` `Ctrl+C` | 关闭 |
74
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
+
75
92
  ## 🔎 搜索语法
76
93
 
77
94
  大小写不敏感,匹配文本中任意位置。空白分隔多个词,每个词都要满足。
@@ -90,7 +107,7 @@ pi 扩展以你的用户权限运行,只装信得过的包。
90
107
 
91
108
  引号是万能转义:`"-foo"`、`"a|b"`、`"name:x"`、`"/x/"` 都按字面搜这些字符。正则写错了就当普通文字搜。预览里只高亮正向的词,`name:` / `dir:` 的词不碰对话正文,所以只输入 `name:auth` 时右栏显示完整对话、没有高亮。
92
109
 
93
- 索引里有用户和助手的消息、会话名、工作目录。没有工具调用的参数和结果。
110
+ 索引里有用户和助手的消息、会话名、工作目录。没有工具调用的参数和结果,`Ctrl+T` 显示的工具行也不参与匹配和高亮。
94
111
 
95
112
  ## 🌐 界面语言
96
113
 
@@ -108,7 +125,7 @@ Windows PowerShell 里先执行 `$env:PI_SESSION_PEEK_LANG = "en"` 再启动 pi
108
125
  ## 🚧 限制
109
126
 
110
127
  - 只支持 TUI 模式。
111
- - 不支持鼠标。pi 只在实验性的全屏模式里开鼠标,这个选择器还没处理鼠标事件。
128
+ - 鼠标需要终端支持 SGR 鼠标上报和光标位置回报(`CSI 6 n`)。Windows Terminal、iTerm2、kitty、WezTerm、Alacritty、GNOME Terminal、VS Code 都支持,老式 Windows 控制台不支持。
112
129
  - 特别长的会话预览只显示最后 500 条消息,更早的命中仍会计数并提示。
113
130
  - `pi --peek` 不带值会被 pi 在启动时拒绝,不带关键词请用 `pi --rp`。
114
131
  - 只扫描 `~/.pi/agent/sessions`(或 `$PI_CODING_AGENT_DIR/sessions`)下的会话。
@@ -122,6 +139,8 @@ pi-session-peek/
122
139
  │ ├── peek-component.ts # 双栏 TUI 组件
123
140
  │ ├── sessions.ts # 扫描解析会话 JSONL,重命名 / 删除
124
141
  │ ├── query.ts # 查询解析、高亮、命中片段
142
+ │ ├── mouse.ts # 常规(非全屏)模式下的鼠标上报
143
+ │ ├── selection.ts # 限定在一栏内的拖选:区间、反显、取文字
125
144
  │ ├── i18n.ts # 中英文界面文案和语言检测
126
145
  │ └── text.ts # 路径、时间、宽度工具
127
146
  ├── scripts/screenshot.mjs # 可选:用组件渲染输出生成一张合成截图
package/index.ts CHANGED
@@ -1,6 +1,7 @@
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
6
  import { deleteSession, renameSession, scanSessions, type PeekSession } from "./src/sessions.ts";
6
7
 
@@ -65,6 +66,18 @@ export default function (pi: ExtensionAPI) {
65
66
  const picked = await ctx.ui.custom<Picked>((tui, theme, _kb, done) => {
66
67
  const comp = new PeekComponent(all, ctx.cwd, theme, getMarkdownTheme(), process.stdout.rows || 24, initial);
67
68
  comp.requestRender = () => tui.requestRender();
69
+ // 常规模式 pi 不开鼠标,自己开;全屏模式 pi-tui 会直接调 comp.handleMouse
70
+ const mouse = attachMouse(comp, tui);
71
+ comp.onDispose = () => mouse.dispose();
72
+ // 鼠标拖选后 Ctrl+C 复制。用 pi 自己的复制:原生剪贴板,不行再退到 OSC 52
73
+ comp.onCopy = async (text) => {
74
+ try {
75
+ await copyToClipboard(text);
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ };
68
81
  comp.onResume = (s) => {
69
82
  lastQuery = comp.getQuery();
70
83
  done({ s, action: "resume" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-session-peek",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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 命中 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 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";
@@ -23,7 +32,8 @@ export class PeekComponent implements Component, Focusable {
23
32
  private selected = 0;
24
33
  private listOffset = 0;
25
34
  private previewOffset = 0;
26
- private previewKey = ""; // 预览缓存的键(会话 + 关键词 + 宽度),置空强制重建
35
+ private previewKey = ""; // 预览缓存的键(会话 + 关键词 + 宽度 + 工具开关),置空强制重建
36
+ private showTools = false; // Ctrl+T:预览里显示每次工具调用的一行摘要
27
37
  private previewLines: string[] = [];
28
38
  private matchLines: number[] = []; // 预览里含关键词的行(升序),Ctrl+N / Ctrl+P 用
29
39
  private rendered = new WeakMap<PeekMsg, { w: number; lines: string[] }>(); // 每条消息渲染好的行,按宽度缓存
@@ -32,6 +42,16 @@ export class PeekComponent implements Component, Focusable {
32
42
  private renameInput: Input;
33
43
  private cachedWidth = -1;
34
44
  private cachedLines: string[] = [];
45
+ private layout = { lw: 0, rw: 0, H: 0 }; // 最近一次 render 的分栏尺寸,鼠标定位用
46
+ private scopeSpan: [number, number] = [0, 0]; // 头部"范围"字样占的列区间,点击切换范围
47
+ private sel?: Selection; // 鼠标拖出来的选区,只在一栏里
48
+ private dragging = false; // 左键还按着
49
+ private selMoved = false; // 按下之后拖过没有;没拖过的选区不显示也不算数(那是单击)
50
+ private dragX = 0; // 最近一次拖动的列,自动滚动时焦点跟着它
51
+ private autoScrollDir = 0; // 拖出预览上下边:-1 往上 1 往下
52
+ private autoScrollTimer?: ReturnType<typeof setInterval>;
53
+ private hint?: { text: string; ok: boolean }; // 底部一闪而过的复制结果
54
+ private hintTimer?: ReturnType<typeof setTimeout>;
35
55
  private _focused = false;
36
56
 
37
57
  // 由 index.ts 注入
@@ -40,7 +60,10 @@ export class PeekComponent implements Component, Focusable {
40
60
  onCancel?: () => void;
41
61
  onDelete?: (s: PeekSession) => Promise<boolean>;
42
62
  onRename?: (s: PeekSession, name: string) => Promise<boolean>;
63
+ onCopy?: (text: string) => Promise<boolean>; // 拖选后 Ctrl+C
64
+ onDispose?: () => void; // 关闭时
43
65
  requestRender?: () => void; // 异步操作完成后让 TUI 重画
66
+ afterRender?: () => void; // 每次 render 之后调用;常规模式的鼠标桥接靠它重新测组件在屏幕上的位置
44
67
 
45
68
  constructor(
46
69
  private all: PeekSession[],
@@ -96,6 +119,33 @@ export class PeekComponent implements Component, Focusable {
96
119
  this.selected = idx >= 0 ? idx : keepSelection ? Math.min(this.selected, Math.max(0, out.length - 1)) : 0;
97
120
  if (!keepSelection) this.listOffset = 0;
98
121
  this.previewKey = "";
122
+ this.clearSelection();
123
+ }
124
+
125
+ // 换选中的会话:预览重建,拖选的选区作废
126
+ private select(idx: number): void {
127
+ this.selected = idx;
128
+ this.previewKey = "";
129
+ this.clearSelection();
130
+ }
131
+
132
+ private clearSelection(): void {
133
+ this.sel = undefined;
134
+ this.dragging = false;
135
+ this.selMoved = false;
136
+ this.stopAutoScroll();
137
+ }
138
+
139
+ // 拖过的才算选区,只按下没拖的是单击
140
+ private activeSel(): Selection | undefined {
141
+ return this.sel && this.selMoved ? this.sel : undefined;
142
+ }
143
+
144
+ dispose(): void {
145
+ this.stopAutoScroll();
146
+ if (this.hintTimer) clearTimeout(this.hintTimer);
147
+ this.hintTimer = undefined;
148
+ this.onDispose?.();
99
149
  }
100
150
 
101
151
  private bodyHeight(): number {
@@ -151,6 +201,7 @@ export class PeekComponent implements Component, Focusable {
151
201
  // 选中位置留在原地
152
202
  this.selected = Math.min(this.selected, Math.max(0, this.filtered.length - 1));
153
203
  this.previewKey = "";
204
+ this.clearSelection();
154
205
  }
155
206
  this.invalidate();
156
207
  this.requestRender?.();
@@ -160,6 +211,11 @@ export class PeekComponent implements Component, Focusable {
160
211
  return;
161
212
  }
162
213
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
214
+ // 有拖选的选区时 Ctrl+C 是复制,和终端里的习惯一样;没有才是关闭
215
+ if (matchesKey(data, Key.ctrl("c")) && this.activeSel()) {
216
+ this.copySelection();
217
+ return;
218
+ }
163
219
  this.onCancel?.();
164
220
  return;
165
221
  }
@@ -188,16 +244,14 @@ export class PeekComponent implements Component, Focusable {
188
244
  }
189
245
  if (matchesKey(data, Key.up)) {
190
246
  if (this.selected > 0) {
191
- this.selected--;
192
- this.previewKey = "";
247
+ this.select(this.selected - 1);
193
248
  this.invalidate();
194
249
  }
195
250
  return;
196
251
  }
197
252
  if (matchesKey(data, Key.down)) {
198
253
  if (this.selected < this.filtered.length - 1) {
199
- this.selected++;
200
- this.previewKey = "";
254
+ this.select(this.selected + 1);
201
255
  this.invalidate();
202
256
  }
203
257
  return;
@@ -224,6 +278,13 @@ export class PeekComponent implements Component, Focusable {
224
278
  this.invalidate();
225
279
  return;
226
280
  }
281
+ if (matchesKey(data, Key.ctrl("t"))) {
282
+ this.showTools = !this.showTools;
283
+ this.previewKey = "";
284
+ this.clearSelection();
285
+ this.invalidate();
286
+ return;
287
+ }
227
288
  if (matchesKey(data, Key.ctrl("n"))) {
228
289
  this.jumpMatch(1);
229
290
  return;
@@ -249,6 +310,202 @@ export class PeekComponent implements Component, Focusable {
249
310
  this.invalidate();
250
311
  }
251
312
 
313
+ // 硬件光标在组件内的行号:搜索框在第 1 行,改名时在最后一行。常规模式的鼠标桥接靠它算组件顶行
314
+ cursorRow(): number | undefined {
315
+ if (!this._focused || !this.layout.H) return undefined;
316
+ return this.renaming ? BODY_TOP + this.layout.H + 1 : 1;
317
+ }
318
+
319
+ // 鼠标:左栏按下选中、双击进入、滚轮换选中项;右栏滚轮滚预览;点头部"范围"切换;点输入框移光标;
320
+ // 在任一栏里按住拖动是选文字,只选这一栏,Ctrl+C 复制。
321
+ // 全屏模式由 pi-tui 按布局直接调,常规模式由 mouse.ts 换算成组件内坐标后调。坐标以组件左上角为原点
322
+ handleMouse(ev: TuiMouseEvent): TuiMouseEventResult | undefined {
323
+ const { lw, H } = this.layout;
324
+ if (!H) return undefined; // 还没画过,不知道分栏在哪
325
+ const inList = ev.x < lw;
326
+ const bodyRow = ev.y - BODY_TOP;
327
+ const inBody = bodyRow >= 0 && bodyRow < H;
328
+ const itemAt = (): number => (inList && inBody ? this.listOffset + (bodyRow >> 1) : -1); // 每项两行
329
+
330
+ if (ev.type === "wheel") {
331
+ const delta = ev.wheelDelta ?? 0;
332
+ if (!delta) return undefined;
333
+ let changed = this.confirmingDelete; // 和按键一样,滚一下就算取消确认,免得删错换过去的那条
334
+ this.confirmingDelete = false;
335
+ if (inList) {
336
+ if (!this.renaming) {
337
+ // 改名中换了会话就改错对象了
338
+ const next = Math.max(0, Math.min(this.filtered.length - 1, this.selected + delta));
339
+ if (next !== this.selected) {
340
+ this.select(next);
341
+ changed = true;
342
+ }
343
+ }
344
+ } else {
345
+ const maxOff = Math.max(0, this.previewLines.length - H);
346
+ const next = Math.max(0, Math.min(maxOff, this.previewOffset + delta * WHEEL_LINES));
347
+ if (next !== this.previewOffset) {
348
+ this.previewOffset = next;
349
+ changed = true;
350
+ }
351
+ }
352
+ if (changed) this.invalidate();
353
+ return { handled: true, render: changed };
354
+ }
355
+ if (ev.type === "drag") {
356
+ if (!this.dragging || !this.sel || ev.button !== "left") return undefined;
357
+ this.dragX = ev.x;
358
+ const changed = this.dragTo(ev.x, ev.y);
359
+ if (changed) this.selMoved = true;
360
+ // 右栏拖出上下边就自动滚;左栏不滚,列表是跟着选中项走的
361
+ if (this.sel.pane === "preview") this.setAutoScroll(bodyRow < 0 ? -1 : bodyRow >= H ? 1 : 0);
362
+ if (changed) this.invalidate();
363
+ return { handled: true, render: changed };
364
+ }
365
+ if (ev.button !== "left") return undefined;
366
+ if (ev.type === "release") {
367
+ if (!this.dragging) return undefined;
368
+ this.dragging = false;
369
+ this.stopAutoScroll();
370
+ if (!this.selMoved) this.sel = undefined; // 没拖过就是单击
371
+ return { handled: true, render: false };
372
+ }
373
+ if (ev.type === "click") {
374
+ // 单击在 press 里已经选中了,双击才进入
375
+ const s = this.filtered[itemAt()];
376
+ if (!s) return undefined;
377
+ if (!this.renaming && (ev.clickCount ?? 1) >= 2) this.onResume?.(s);
378
+ return { handled: true, render: false };
379
+ }
380
+ if (ev.type !== "press") return undefined;
381
+
382
+ // 删除确认和已有的选区:点一下就没了,和按键一样
383
+ let changed = this.confirmingDelete || this.activeSel() !== undefined;
384
+ this.confirmingDelete = false;
385
+ this.clearSelection();
386
+ let handled = changed;
387
+ if (this.renaming) {
388
+ if (ev.y === BODY_TOP + H + 1) {
389
+ // 改名行:点哪里光标就去哪里
390
+ this.renameInput.handleMouse({ ...ev, x: ev.x - visibleWidth(msg("renamePrefix")), y: 0 });
391
+ handled = changed = true;
392
+ }
393
+ } else if (ev.y === 1) {
394
+ this.input.handleMouse({ ...ev, y: 0 });
395
+ handled = changed = true;
396
+ } else if (ev.y === 0) {
397
+ if (ev.x >= this.scopeSpan[0] && ev.x < this.scopeSpan[1]) {
398
+ this.scope = this.scope === "all" ? "current" : "all";
399
+ this.refilter();
400
+ handled = changed = true;
401
+ }
402
+ } else {
403
+ const idx = itemAt();
404
+ if (idx >= 0 && idx < this.filtered.length && idx !== this.selected) {
405
+ this.select(idx);
406
+ changed = true;
407
+ }
408
+ }
409
+ if (inBody) {
410
+ // 两栏里按下都可能是拖选的起点,拖起来才算数;接管后面的拖动和松开
411
+ const pane: Pane = inList ? "list" : "preview";
412
+ const cell = this.cellAt(pane, ev.x, bodyRow);
413
+ this.sel = { pane, anchor: cell, focus: { ...cell } };
414
+ this.dragging = true;
415
+ this.dragX = ev.x;
416
+ handled = true;
417
+ }
418
+ if (!handled) return undefined;
419
+ if (changed) this.invalidate();
420
+ return inBody ? { handled: true, capture: true, render: changed } : { handled: true, render: changed };
421
+ }
422
+
423
+ // 屏幕位置换成某一栏的内容坐标:行是内容的绝对行号,列夹在这一栏里,拖到栏外也只选栏内的字
424
+ private cellAt(pane: Pane, x: number, bodyRow: number): Cell {
425
+ const { lw, rw, H } = this.layout;
426
+ const row = Math.max(0, Math.min(H - 1, bodyRow));
427
+ if (pane === "list") return { row: this.listOffset * 2 + row, col: Math.max(0, Math.min(lw - 1, x)) };
428
+ return { row: this.previewOffset + row, col: Math.max(0, Math.min(rw - 1, x - lw - SEP)) };
429
+ }
430
+
431
+ // 拖到某个位置:焦点跟过去。返回有没有变
432
+ private dragTo(x: number, y: number): boolean {
433
+ if (!this.sel) return false;
434
+ const cell = this.cellAt(this.sel.pane, x, y - BODY_TOP);
435
+ if (cell.row === this.sel.focus.row && cell.col === this.sel.focus.col) return false;
436
+ this.sel.focus = cell;
437
+ return true;
438
+ }
439
+
440
+ private setAutoScroll(dir: number): void {
441
+ this.autoScrollDir = dir;
442
+ if (!dir) {
443
+ this.stopAutoScroll();
444
+ return;
445
+ }
446
+ if (this.autoScrollTimer) return;
447
+ this.autoScrollTimer = setInterval(() => this.autoScrollTick(), AUTO_SCROLL_MS);
448
+ this.autoScrollTimer.unref?.();
449
+ }
450
+
451
+ // 每次滚一行,焦点跟着指针:指针在上边就是视口第一行,在下边就是最后一行
452
+ private autoScrollTick(): void {
453
+ const { H } = this.layout;
454
+ const maxOff = Math.max(0, this.previewLines.length - H);
455
+ const next = Math.max(0, Math.min(maxOff, this.previewOffset + this.autoScrollDir));
456
+ if (!this.sel || !this.dragging || next === this.previewOffset) {
457
+ this.stopAutoScroll();
458
+ return;
459
+ }
460
+ this.previewOffset = next;
461
+ if (this.dragTo(this.dragX, this.autoScrollDir < 0 ? BODY_TOP - 1 : BODY_TOP + H)) this.selMoved = true;
462
+ this.invalidate();
463
+ this.requestRender?.();
464
+ }
465
+
466
+ private stopAutoScroll(): void {
467
+ if (this.autoScrollTimer) clearInterval(this.autoScrollTimer);
468
+ this.autoScrollTimer = undefined;
469
+ this.autoScrollDir = 0;
470
+ }
471
+
472
+ // 选区里的纯文本
473
+ private selectionToText(sel: Selection): string {
474
+ const { lw, rw } = this.layout;
475
+ if (sel.pane === "preview") return selectionText(sel, rw, (row) => this.previewLines[row]);
476
+ const { rt, hasBody } = this.listTerms();
477
+ return selectionText(sel, lw, (row) => {
478
+ const s = this.filtered[row >> 1];
479
+ return s && this.listRows(s, (row >> 1) === this.selected, lw, rt, hasBody)[row & 1];
480
+ });
481
+ }
482
+
483
+ // Ctrl+C:复制选区,清掉高亮,底部闪一下结果
484
+ private copySelection(): void {
485
+ const sel = this.activeSel();
486
+ if (!sel) return;
487
+ const text = this.selectionToText(sel);
488
+ const n = text.split("\n").length;
489
+ this.clearSelection();
490
+ this.invalidate();
491
+ if (!text.trim() || !this.onCopy) return;
492
+ void this.onCopy(text).then((ok) => this.showHint(ok ? msg("copied", { n }) : msg("copyFailed"), ok));
493
+ }
494
+
495
+ private showHint(text: string, ok: boolean): void {
496
+ this.hint = { text, ok };
497
+ if (this.hintTimer) clearTimeout(this.hintTimer);
498
+ this.hintTimer = setTimeout(() => {
499
+ this.hintTimer = undefined;
500
+ this.hint = undefined;
501
+ this.invalidate();
502
+ this.requestRender?.();
503
+ }, HINT_MS);
504
+ this.hintTimer.unref?.();
505
+ this.invalidate();
506
+ this.requestRender?.();
507
+ }
508
+
252
509
  // 预览里跳到当前视口之外的下一个 / 上一个命中行,到头了回绕。同一屏里的多个命中算一处,
253
510
  // 否则一段里连着几行都命中要按好几次才过得去
254
511
  private jumpMatch(dir: 1 | -1): void {
@@ -268,11 +525,37 @@ export class PeekComponent implements Component, Focusable {
268
525
  }
269
526
  }
270
527
 
528
+ // 左栏用的关键词:排除词和 name: / cwd: 不在正文里高亮
529
+ private listTerms(): { rt: RoleTerms; hasBody: boolean } {
530
+ const rt = roleTerms(this.query().terms);
531
+ return { rt, hasBody: rt.user.length > 0 || rt.assistant.length > 0 };
532
+ }
533
+
534
+ // 左栏一个会话的两行:时间 + 目录(有正文关键词时加命中数),首条消息或命中片段
535
+ private listRows(s: PeekSession, sel: boolean, lw: number, rt: RoleTerms, hasBody: boolean): [string, string] {
536
+ const t = this.theme;
537
+ const time = fmtTime(s.time);
538
+ const cwdTail = s.cwd.replace(/\\/g, "/").split("/").slice(-2).join("/");
539
+ const hitBadge = hasBody
540
+ ? ` ·${s.msgs.reduce((n, m) => n + (anyMatch(m.text, rt[m.role]) ? 1 : 0), 0)}`
541
+ : "";
542
+ const l1 = `${sel ? "›" : " "} ${time} ${cwdTail}${hitBadge}`;
543
+ const snip = hasBody ? snippet(s.msgs, rt, lw) : undefined;
544
+ const l2 = snip !== undefined
545
+ ? ` ${highlight(snip.text, rt[snip.role], t)}`
546
+ : ` ${s.first}${s.name ? ` [${s.name}]` : ""}`;
547
+ if (sel) {
548
+ return [
549
+ t.bg("selectedBg", padEndVisible(t.fg("accent", truncateToWidth(l1, lw)), lw)),
550
+ t.bg("selectedBg", padEndVisible(truncateToWidth(l2, lw), lw)),
551
+ ];
552
+ }
553
+ return [padEndVisible(l1, lw), padEndVisible(t.fg("dim", truncateToWidth(l2, lw)), lw)];
554
+ }
555
+
271
556
  // 左栏,每个会话两行
272
557
  private buildList(height: number, lw: number): string[] {
273
- const t = this.theme;
274
- const rt = roleTerms(this.query().terms); // 排除词和 name: / cwd: 不在正文里高亮
275
- const hasBody = rt.user.length > 0 || rt.assistant.length > 0;
558
+ const { rt, hasBody } = this.listTerms();
276
559
  const visible = Math.max(1, Math.floor(height / 2));
277
560
  if (this.selected < this.listOffset) this.listOffset = this.selected;
278
561
  if (this.selected >= this.listOffset + visible) {
@@ -287,26 +570,7 @@ export class PeekComponent implements Component, Focusable {
287
570
  rows.push(" ".repeat(lw), " ".repeat(lw));
288
571
  continue;
289
572
  }
290
- const sel = idx === this.selected;
291
- const time = fmtTime(s.time);
292
- const cwdTail = s.cwd.replace(/\\/g, "/").split("/").slice(-2).join("/");
293
- // 有正文关键词时显示命中的消息数
294
- const hitBadge = hasBody
295
- ? ` ·${s.msgs.reduce((n, m) => n + (anyMatch(m.text, rt[m.role]) ? 1 : 0), 0)}`
296
- : "";
297
- const l1 = `${sel ? "›" : " "} ${time} ${cwdTail}${hitBadge}`;
298
- // 第二行:有正文关键词时显示命中片段,否则显示首条消息
299
- const snip = hasBody ? snippet(s.msgs, rt, lw) : undefined;
300
- const l2 = snip !== undefined
301
- ? ` ${highlight(snip.text, rt[snip.role], t)}`
302
- : ` ${s.first}${s.name ? ` [${s.name}]` : ""}`;
303
- if (sel) {
304
- rows.push(t.bg("selectedBg", padEndVisible(t.fg("accent", truncateToWidth(l1, lw)), lw)));
305
- rows.push(t.bg("selectedBg", padEndVisible(truncateToWidth(l2, lw), lw)));
306
- } else {
307
- rows.push(padEndVisible(l1, lw));
308
- rows.push(padEndVisible(t.fg("dim", truncateToWidth(l2, lw)), lw));
309
- }
573
+ rows.push(...this.listRows(s, idx === this.selected, lw, rt, hasBody));
310
574
  }
311
575
  // 每项两行,height 是奇数时会少一行,补空行,否则最后一行右栏会顶到左边
312
576
  while (rows.length < height) rows.push(" ".repeat(lw));
@@ -339,7 +603,7 @@ export class PeekComponent implements Component, Focusable {
339
603
  private buildPreview(rw: number): void {
340
604
  const t = this.theme;
341
605
  const s = this.filtered[this.selected];
342
- const key = s ? `${s.path}|${this.getQuery()}|${rw}` : "none";
606
+ const key = s ? `${s.path}|${this.getQuery()}|${rw}|${this.showTools ? "t" : ""}` : "none";
343
607
  if (key === this.previewKey) return;
344
608
  this.previewKey = key;
345
609
  this.matchLines = [];
@@ -351,9 +615,11 @@ export class PeekComponent implements Component, Focusable {
351
615
  }
352
616
 
353
617
  const lines: string[] = [];
618
+ // 消息数只算有文字的,纯工具轮次不计
619
+ const textCount = s.msgs.reduce((n, m) => n + (m.text ? 1 : 0), 0);
354
620
  lines.push(
355
621
  truncateToWidth(
356
- t.fg("dim", `${s.cwd} • ${msg("msgCount", { n: s.msgs.length })}${s.name ? " • " + s.name : ""}`),
622
+ t.fg("dim", `${s.cwd} • ${msg("msgCount", { n: textCount })}${s.name ? " • " + s.name : ""}`),
357
623
  rw,
358
624
  ),
359
625
  );
@@ -385,26 +651,39 @@ export class PeekComponent implements Component, Focusable {
385
651
  return truncateToWidth(label + " " + "─".repeat(Math.max(2, rw - w - 1)), rw);
386
652
  };
387
653
 
654
+ let prevRole: PeekMsg["role"] | undefined; // 上一条画出来的消息的角色
388
655
  for (const m of msgs) {
656
+ const tools = this.showTools ? (m.tools ?? []) : [];
657
+ if (!m.text && !tools.length) continue; // 工具隐藏时,只有工具调用的 AI 轮次整条跳过
389
658
  const terms = rt[m.role]; // user: 的词只在用户消息里高亮,ai: 的只在 AI 回复里
390
659
  const isMatch = terms.length > 0 && anyMatch(m.text, terms);
391
- const head = lines.length;
392
- if (m.role === "user") {
660
+ // 紧跟在 AI 文字后面、只有工具调用的轮次不重复画标签,接在上一条正文下面,看起来是同一轮
661
+ const continues = m.role === "assistant" && prevRole === "assistant" && !m.text;
662
+ if (continues) {
663
+ lines.pop();
664
+ } else if (m.role === "user") {
393
665
  lines.push(
394
666
  t.bg("userMessageBg", padEndVisible(t.bold(t.fg("accent", fillLabel(msg("you")))), rw)),
395
667
  );
396
668
  } else {
397
669
  lines.push(t.fg("muted", fillLabel(msg("ai"))));
398
670
  }
671
+ const head = lines.length - 1;
399
672
  // 先渲染再高亮:往 markdown 源码里插转义码会把链接、代码块的语法弄坏
400
673
  const before = this.matchLines.length;
401
- for (const l of this.renderMsg(m, rw)) {
402
- if (isMatch && lineHasMatch(l, terms)) this.matchLines.push(lines.length);
403
- lines.push(isMatch ? highlight(l, terms, t) : l);
674
+ if (m.text) {
675
+ for (const l of this.renderMsg(m, rw)) {
676
+ if (isMatch && lineHasMatch(l, terms)) this.matchLines.push(lines.length);
677
+ lines.push(isMatch ? highlight(l, terms, t) : l);
678
+ }
404
679
  }
405
680
  // 关键词被折行拆开时哪一行都找不到,退回到消息标题行,别把这条消息漏掉
406
681
  if (isMatch && this.matchLines.length === before) this.matchLines.push(head);
682
+ for (const tool of tools) {
683
+ lines.push(t.fg("dim", truncateToWidth(` ⚙ ${tool.name}${tool.summary ? " " + tool.summary : ""}`, rw)));
684
+ }
407
685
  lines.push("");
686
+ prevRole = m.role;
408
687
  }
409
688
 
410
689
  this.previewLines = lines;
@@ -419,16 +698,24 @@ export class PeekComponent implements Component, Focusable {
419
698
  render(width: number): string[] {
420
699
  const H = this.bodyHeight();
421
700
  const cacheKey = width * 1000 + H; // 宽或高变了都要重画
422
- if (this.cachedWidth === cacheKey) return this.cachedLines;
701
+ if (this.cachedWidth === cacheKey) {
702
+ // 缓存命中时组件在屏幕上的位置也可能变了(上面多了一行状态提示),照样通知一次,桥接那边自己去重
703
+ this.afterRender?.();
704
+ return this.cachedLines;
705
+ }
423
706
  const t = this.theme;
424
707
  const lw = Math.max(26, Math.min(56, Math.floor(width * 0.4)));
425
708
  const rw = Math.max(20, width - lw - 3);
709
+ this.layout = { lw, rw, H };
426
710
 
427
711
  const out: string[] = [];
428
712
 
429
713
  // 头部:标题、范围、时间过滤、匹配数,然后是搜索框
430
714
  const scopeLabel = this.scope === "all" ? msg("scopeAll") : msg("scopeCurrent");
431
715
  const { sinceLabel } = this.query();
716
+ // 「范围[Tab]: 当前目录树」整段都能点
717
+ const scopeStart = visibleWidth(msg("title"));
718
+ this.scopeSpan = [scopeStart, scopeStart + visibleWidth(msg("scopeLabel") + scopeLabel)];
432
719
  const head =
433
720
  t.fg("accent", t.bold(msg("title"))) +
434
721
  t.fg("dim", msg("scopeLabel")) +
@@ -456,17 +743,26 @@ export class PeekComponent implements Component, Focusable {
456
743
  if (this.previewLines.length > H) info += ` (${this.previewOffset + 1}-${end}/${this.previewLines.length})`;
457
744
  const scrollInfo = info ? t.fg("dim", info) : "";
458
745
 
746
+ const sel = this.activeSel();
459
747
  for (let i = 0; i < H; i++) {
460
- const l = leftRows[i] ?? " ".repeat(lw);
748
+ let l = leftRows[i] ?? " ".repeat(lw);
461
749
  let r = rightRows[i] ?? "";
462
- if (i === 0 && scrollInfo) {
463
- r = truncateToWidth(r, Math.max(0, rw - visibleWidth(scrollInfo))) + scrollInfo;
750
+ const infoW = i === 0 && scrollInfo ? visibleWidth(scrollInfo) : 0;
751
+ if (infoW) r = truncateToWidth(r, Math.max(0, rw - infoW));
752
+ r = padEndVisible(r, rw - infoW) + (infoW ? scrollInfo : "");
753
+ // 拖选的高亮最后叠上去,反显整格;右栏第一行别盖到右上角的命中信息
754
+ if (sel?.pane === "list") {
755
+ const cols = rowColumns(sel, this.listOffset * 2 + i, l, lw);
756
+ if (cols) l = highlightColumns(l, cols[0], cols[1]);
757
+ } else if (sel?.pane === "preview") {
758
+ const cols = rowColumns(sel, this.previewOffset + i, r, rw - infoW);
759
+ if (cols) r = highlightColumns(r, cols[0], cols[1]);
464
760
  }
465
761
  // 左栏在 buildList 里已经 pad 到 lw,这里再 pad 会把带样式的行截坏
466
- out.push(l + t.fg("borderMuted", " │ ") + padEndVisible(r, rw));
762
+ out.push(l + t.fg("borderMuted", " │ ") + r);
467
763
  }
468
764
 
469
- // 底部:重命名和删除确认时换成对应的操作行
765
+ // 底部:重命名和删除确认时换成对应的操作行,复制结果和选区提示也在这里
470
766
  out.push(t.fg("borderMuted", "─".repeat(width)));
471
767
  if (this.renaming) {
472
768
  const prefix = msg("renamePrefix");
@@ -481,6 +777,11 @@ export class PeekComponent implements Component, Focusable {
481
777
  width,
482
778
  ),
483
779
  );
780
+ } else if (this.hint) {
781
+ out.push(truncateToWidth(t.fg(this.hint.ok ? "success" : "error", this.hint.text), width));
782
+ } else if (sel) {
783
+ const [s, e] = bounds(sel);
784
+ out.push(truncateToWidth(t.fg("warning", msg("selHint", { n: e.row - s.row + 1 })), width));
484
785
  } else {
485
786
  out.push(
486
787
  truncateToWidth(
@@ -492,6 +793,7 @@ export class PeekComponent implements Component, Focusable {
492
793
 
493
794
  this.cachedWidth = cacheKey;
494
795
  this.cachedLines = out;
796
+ this.afterRender?.();
495
797
  return out;
496
798
  }
497
799
 
@@ -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
@@ -7,9 +7,15 @@ import { join } from "node:path";
7
7
  // 读 ~/.pi/agent/sessions 下的会话 JSONL,按 mtime 缓存;重命名和删除也在这里。
8
8
  // 搜索不预存小写全文:会话正文只在 msgs 里放一份,匹配时用不区分大小写的正则直接扫(见 query.ts 的 matchesSession)
9
9
 
10
+ export interface PeekTool {
11
+ name: string;
12
+ summary: string; // 主参数(命令 / 路径 / URL…)压成一行,预览里显示用
13
+ }
14
+
10
15
  export interface PeekMsg {
11
16
  role: "user" | "assistant";
12
- text: string;
17
+ text: string; // 只发了工具调用、没写字的 AI 轮次为空串
18
+ tools?: PeekTool[]; // 这条消息里的工具调用,没有就不带这个键。不进搜索索引
13
19
  }
14
20
 
15
21
  export interface PeekSession {
@@ -55,6 +61,26 @@ function extractText(content: unknown): string {
55
61
  .join("\n");
56
62
  }
57
63
 
64
+ // 工具调用的主参数:按常见键名找第一个字符串,都没有就取第一个字符串参数。不存结果,bash 输出动辄几十 KB
65
+ const TOOL_ARG_KEYS = ["command", "path", "url", "query", "pattern", "file_path"];
66
+ const TOOL_SUMMARY_MAX = 120;
67
+
68
+ export function toolSummary(args: unknown): string {
69
+ if (!args || typeof args !== "object") return "";
70
+ const o = args as Record<string, unknown>;
71
+ let v = TOOL_ARG_KEYS.map((k) => o[k]).find((x) => typeof x === "string" && x.trim());
72
+ if (typeof v !== "string") v = Object.values(o).find((x) => typeof x === "string" && (x as string).trim());
73
+ if (typeof v !== "string") return "";
74
+ return v.replace(/\s+/g, " ").trim().slice(0, TOOL_SUMMARY_MAX);
75
+ }
76
+
77
+ function extractTools(content: unknown): PeekTool[] {
78
+ if (!Array.isArray(content)) return [];
79
+ return content
80
+ .filter((c: any) => c?.type === "toolCall" && typeof c.name === "string")
81
+ .map((c: any) => ({ name: c.name, summary: toolSummary(c.arguments) }));
82
+ }
83
+
58
84
  function parseSession(path: string, raw: string, mtime: number): PeekSession | null {
59
85
  let cwd = "";
60
86
  let time = "";
@@ -82,12 +108,17 @@ function parseSession(path: string, raw: string, mtime: number): PeekSession | n
82
108
  const role = o.message?.role;
83
109
  if (role !== "user" && role !== "assistant") continue;
84
110
  const text = extractText(o.message?.content).trim();
85
- if (text) msgs.push({ role, text });
111
+ const tools = role === "assistant" ? extractTools(o.message.content) : [];
112
+ // 只有工具调用的 AI 轮次也留着,否则预览里"我来看看"直接跳到结论,中间做了什么全没了
113
+ if (tools.length) msgs.push({ role, text, tools });
114
+ else if (text) msgs.push({ role, text });
86
115
  } catch {
87
116
  }
88
117
  }
89
118
 
90
- if (!msgs.length) return null;
119
+ // 一条文字消息都没有的会话不列出来;列表里的首条也只看有文字的
120
+ const firstText = msgs.find((m) => m.text);
121
+ if (!firstText) return null;
91
122
 
92
123
  return {
93
124
  path,
@@ -96,7 +127,7 @@ function parseSession(path: string, raw: string, mtime: number): PeekSession | n
96
127
  name,
97
128
  msgs,
98
129
  mtime,
99
- first: msgs[0].text.replace(/\s+/g, " ").slice(0, 80),
130
+ first: firstText.text.replace(/\s+/g, " ").slice(0, 80),
100
131
  };
101
132
  }
102
133