pi-session-peek 0.1.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 +12 -0
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/README.zh-CN.md +120 -0
- package/docs/screenshot.png +0 -0
- package/index.ts +118 -0
- package/package.json +63 -0
- package/src/peek-component.ts +464 -0
- package/src/query.ts +89 -0
- package/src/sessions.ts +186 -0
- package/src/text.ts +28 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- Two-pane picker: session list on the left, full conversation preview on the right.
|
|
8
|
+
- Live filtering by conversation text, session name and working directory. Space-separated keywords must all match; `@7d`, `@24h`, `@2w`, `@1m` limit results to recently active sessions.
|
|
9
|
+
- Keyword highlighting, hit snippets in the list, Ctrl+N / Ctrl+P to jump between hits.
|
|
10
|
+
- Enter resumes, Ctrl+O forks, Ctrl+R renames, Ctrl+D deletes (moves to trash when the `trash` CLI is available).
|
|
11
|
+
- Tab toggles between the current directory tree and all projects.
|
|
12
|
+
- `/peek [keyword]` command, `--rp` and `--peek=<keyword>` startup flags.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jeffropz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# 🔍 pi-session-peek — Find That Old Conversation and Jump Back In
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/pi-session-peek) [](https://pi.dev) [](./LICENSE)
|
|
4
|
+
|
|
5
|
+
[中文说明](https://github.com/Jeffropz/pi-session-peek/blob/main/README.zh-CN.md)
|
|
6
|
+
|
|
7
|
+
Search your [pi](https://pi.dev) session history by what was actually said, read the whole conversation in a side pane, then resume it or fork it.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## ✨ Features
|
|
12
|
+
|
|
13
|
+
- Opens a two-pane picker with `/peek` or `/peek <keyword>`: sessions on the left, the full conversation on the right.
|
|
14
|
+
- 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
|
+
- Requires every space-separated keyword to match, and shows a snippet around the first hit in the list.
|
|
16
|
+
- Limits results to recently active sessions with `@7d`, `@24h`, `@2w` or `@1m`.
|
|
17
|
+
- Highlights every hit in the preview and jumps between them with `Ctrl+N` / `Ctrl+P`.
|
|
18
|
+
- Resumes with `Enter`, forks into a new session with `Ctrl+O`, renames with `Ctrl+R`, deletes with `Ctrl+D`.
|
|
19
|
+
- Toggles between the current directory tree and all projects with `Tab`.
|
|
20
|
+
- Starts straight into the picker with `pi --rp` or `pi --peek=<keyword>`.
|
|
21
|
+
- Parses each session file once and caches it by mtime, so reopening is instant.
|
|
22
|
+
|
|
23
|
+
## 📦 Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi install npm:pi-session-peek
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Try it without installing permanently:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pi -e npm:pi-session-peek
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Install from GitHub instead of npm:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pi install git:github.com/Jeffropz/pi-session-peek
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Pi extensions run with the Pi process's user permissions, so install only trusted packages.
|
|
42
|
+
|
|
43
|
+
## 🚀 Quick start
|
|
44
|
+
|
|
45
|
+
In TUI mode, run `/peek` and start typing. Press `Enter` on a session to continue it, or `Ctrl+O` to continue in a fresh fork and leave the original untouched.
|
|
46
|
+
|
|
47
|
+
## 💬 Commands
|
|
48
|
+
|
|
49
|
+
| Command | Purpose |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `/peek` | Open the picker. The previous keyword is remembered for the process. |
|
|
52
|
+
| `/peek <keyword>` | Open the picker with the keyword filled in. |
|
|
53
|
+
| `pi --rp` | Start pi and open the picker. Takes no argument. |
|
|
54
|
+
| `pi --peek=<keyword>` | Start pi and open the picker with the keyword filled in. `--peek` requires a value. |
|
|
55
|
+
|
|
56
|
+
## ⌨️ Keys
|
|
57
|
+
|
|
58
|
+
| Key | Action |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| type | Filter. Space-separated words must all match; `@7d` and friends limit by time. |
|
|
61
|
+
| `Tab` | Current directory tree ↔ all projects |
|
|
62
|
+
| `↑` `↓` | Select session |
|
|
63
|
+
| `PgUp` `PgDn` | Scroll preview by a page |
|
|
64
|
+
| `Ctrl+U` `Ctrl+F` | Scroll preview by half a page |
|
|
65
|
+
| `Shift+↑` `Shift+↓` | Scroll preview by three lines |
|
|
66
|
+
| `Ctrl+N` `Ctrl+P` | Next / previous hit |
|
|
67
|
+
| `Enter` | Resume the session |
|
|
68
|
+
| `Ctrl+O` | Fork the session and open the fork |
|
|
69
|
+
| `Ctrl+R` | Rename. Writes the same `session_info` entry as `/name`. |
|
|
70
|
+
| `Ctrl+D` | Delete, confirmed with `y` or `Enter`. Uses the `trash` CLI when available, otherwise deletes the file. |
|
|
71
|
+
| `Esc` `Ctrl+C` | Close |
|
|
72
|
+
|
|
73
|
+
## 🔎 Search syntax
|
|
74
|
+
|
|
75
|
+
The filter is case-insensitive and matches anywhere in the text.
|
|
76
|
+
|
|
77
|
+
| Input | Meaning |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `token undefined` | Sessions containing both `token` and `undefined` |
|
|
80
|
+
| `@7d` | Sessions active in the last 7 days. Units: `h`, `d`, `w`, `m` (30 days). |
|
|
81
|
+
| `token @2w` | Both combined |
|
|
82
|
+
|
|
83
|
+
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.
|
|
84
|
+
|
|
85
|
+
## 🚧 Limitations
|
|
86
|
+
|
|
87
|
+
- TUI mode only.
|
|
88
|
+
- The interface text is Chinese.
|
|
89
|
+
- No mouse support. Pi only enables mouse input in its experimental fullscreen mode, and this picker does not handle it yet.
|
|
90
|
+
- The preview shows the last 500 messages of very long sessions. Hits in earlier messages are still counted and announced.
|
|
91
|
+
- `pi --peek` without a value is rejected by pi at startup. Use `pi --rp` to open without a keyword.
|
|
92
|
+
- Only sessions under `~/.pi/agent/sessions` (or `$PI_CODING_AGENT_DIR/sessions`) are scanned.
|
|
93
|
+
|
|
94
|
+
## 🗂️ Package layout
|
|
95
|
+
|
|
96
|
+
```text
|
|
97
|
+
pi-session-peek/
|
|
98
|
+
├── index.ts # Registers /peek and the startup flags, injects delete / rename / fork
|
|
99
|
+
├── src/
|
|
100
|
+
│ ├── peek-component.ts # The two-pane TUI component
|
|
101
|
+
│ ├── sessions.ts # Scans and parses session JSONL, rename / delete helpers
|
|
102
|
+
│ ├── query.ts # Query parsing, highlighting, hit snippets
|
|
103
|
+
│ └── text.ts # Path, time and width helpers
|
|
104
|
+
├── scripts/screenshot.mjs # Optional: renders a synthetic screenshot from component output
|
|
105
|
+
└── test/ # node:test suites
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## 🛠️ Development
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npm install
|
|
112
|
+
npm test # node:test via tsx
|
|
113
|
+
npm run typecheck # tsc --noEmit
|
|
114
|
+
npm run screenshot # optional: overwrite docs/screenshot.png with a synthetic render (needs Chrome)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
To load a local checkout, add its path under `extensions` in `~/.pi/agent/settings.json` or drop the folder into `~/.pi/agent/extensions/`, then `/reload`.
|
|
118
|
+
|
|
119
|
+
## 📄 License
|
|
120
|
+
|
|
121
|
+
MIT. See [`LICENSE`](./LICENSE).
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# 🔍 pi-session-peek — 找到那次对话,接着聊
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/pi-session-peek) [](https://pi.dev) [](./LICENSE)
|
|
4
|
+
|
|
5
|
+
[English](https://github.com/Jeffropz/pi-session-peek/blob/main/README.md)
|
|
6
|
+
|
|
7
|
+
按对话内容搜索 [pi](https://pi.dev) 的历史会话,右栏直接读完整对话,然后回到那个会话继续,或者分叉出一个新的。
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## ✨ 功能
|
|
12
|
+
|
|
13
|
+
- `/peek` 或 `/peek 关键词` 打开左右分栏的选择器:左边是会话列表,右边是完整对话。
|
|
14
|
+
- 边打字边过滤,搜的是对话正文、会话名和工作目录。工具调用的参数和结果不进索引,所以只会命中真正讨论过这个词的会话。
|
|
15
|
+
- 空格分隔多个关键词,全部命中才显示;列表里直接显示第一个命中处的片段。
|
|
16
|
+
- `@7d`、`@24h`、`@2w`、`@1m` 只看最近活动过的会话。
|
|
17
|
+
- 预览里所有命中都高亮,`Ctrl+N` / `Ctrl+P` 在命中间跳。
|
|
18
|
+
- `Enter` 进入会话,`Ctrl+O` 分叉成新会话,`Ctrl+R` 重命名,`Ctrl+D` 删除。
|
|
19
|
+
- `Tab` 在当前目录树和全部项目之间切换。
|
|
20
|
+
- `pi --rp` 或 `pi --peek=关键词` 启动时直接打开。
|
|
21
|
+
- 会话文件只解析一次,按修改时间缓存,再次打开不用等。
|
|
22
|
+
|
|
23
|
+
## 📦 安装
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi install npm:pi-session-peek
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
不装、只试一次:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pi -e npm:pi-session-peek
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
从 GitHub 装:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pi install git:github.com/Jeffropz/pi-session-peek
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
pi 扩展以你的用户权限运行,只装信得过的包。
|
|
42
|
+
|
|
43
|
+
## 🚀 快速开始
|
|
44
|
+
|
|
45
|
+
在 TUI 模式下输入 `/peek`,直接打字过滤。选中后按 `Enter` 接着聊,或者按 `Ctrl+O` 在一个新分叉里聊,原会话不动。
|
|
46
|
+
|
|
47
|
+
## 💬 命令
|
|
48
|
+
|
|
49
|
+
| 命令 | 作用 |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `/peek` | 打开选择器,本次进程内记住上次的关键词 |
|
|
52
|
+
| `/peek 关键词` | 带关键词打开 |
|
|
53
|
+
| `pi --rp` | 启动 pi 并打开选择器。不接受参数。 |
|
|
54
|
+
| `pi --peek=关键词` | 启动 pi 并带关键词打开。`--peek` 必须带值。 |
|
|
55
|
+
|
|
56
|
+
## ⌨️ 按键
|
|
57
|
+
|
|
58
|
+
| 按键 | 作用 |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| 打字 | 过滤。空格分隔的词都要命中;`@7d` 等限定时间。 |
|
|
61
|
+
| `Tab` | 当前目录树 ↔ 全部项目 |
|
|
62
|
+
| `↑` `↓` | 选会话 |
|
|
63
|
+
| `PgUp` `PgDn` | 预览翻页 |
|
|
64
|
+
| `Ctrl+U` `Ctrl+F` | 预览翻半页 |
|
|
65
|
+
| `Shift+↑` `Shift+↓` | 预览滚三行 |
|
|
66
|
+
| `Ctrl+N` `Ctrl+P` | 下一个 / 上一个命中 |
|
|
67
|
+
| `Enter` | 进入会话 |
|
|
68
|
+
| `Ctrl+O` | 分叉出新会话并进入 |
|
|
69
|
+
| `Ctrl+R` | 重命名,写入的记录和 `/name` 一样 |
|
|
70
|
+
| `Ctrl+D` | 删除,按 `y` 或 `Enter` 确认。有 `trash` 命令就进回收站,否则直接删文件。 |
|
|
71
|
+
| `Esc` `Ctrl+C` | 关闭 |
|
|
72
|
+
|
|
73
|
+
## 🔎 搜索语法
|
|
74
|
+
|
|
75
|
+
大小写不敏感,匹配文本中任意位置。
|
|
76
|
+
|
|
77
|
+
| 输入 | 含义 |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `token undefined` | 同时包含 `token` 和 `undefined` 的会话 |
|
|
80
|
+
| `@7d` | 最近 7 天活动过的会话。单位:`h` 小时、`d` 天、`w` 周、`m` 月(按 30 天算) |
|
|
81
|
+
| `token @2w` | 两者组合 |
|
|
82
|
+
|
|
83
|
+
索引里有用户和助手的消息、会话名、工作目录。没有工具调用的参数和结果。
|
|
84
|
+
|
|
85
|
+
## 🚧 限制
|
|
86
|
+
|
|
87
|
+
- 只支持 TUI 模式。
|
|
88
|
+
- 不支持鼠标。pi 只在实验性的全屏模式里开鼠标,这个选择器还没处理鼠标事件。
|
|
89
|
+
- 特别长的会话预览只显示最后 500 条消息,更早的命中仍会计数并提示。
|
|
90
|
+
- `pi --peek` 不带值会被 pi 在启动时拒绝,不带关键词请用 `pi --rp`。
|
|
91
|
+
- 只扫描 `~/.pi/agent/sessions`(或 `$PI_CODING_AGENT_DIR/sessions`)下的会话。
|
|
92
|
+
|
|
93
|
+
## 🗂️ 目录结构
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
pi-session-peek/
|
|
97
|
+
├── index.ts # 注册 /peek 和启动参数,注入删除 / 重命名 / 分叉
|
|
98
|
+
├── src/
|
|
99
|
+
│ ├── peek-component.ts # 双栏 TUI 组件
|
|
100
|
+
│ ├── sessions.ts # 扫描解析会话 JSONL,重命名 / 删除
|
|
101
|
+
│ ├── query.ts # 查询解析、高亮、命中片段
|
|
102
|
+
│ └── text.ts # 路径、时间、宽度工具
|
|
103
|
+
├── scripts/screenshot.mjs # 可选:用组件渲染输出生成一张合成截图
|
|
104
|
+
└── test/ # node:test 测试
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## 🛠️ 开发
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
npm install
|
|
111
|
+
npm test # node:test,通过 tsx 跑 TS
|
|
112
|
+
npm run typecheck # tsc --noEmit
|
|
113
|
+
npm run screenshot # 可选:用合成渲染覆盖 docs/screenshot.png,需要本机有 Chrome
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
本地开发时把目录路径加到 `~/.pi/agent/settings.json` 的 `extensions` 里,或者直接放进 `~/.pi/agent/extensions/`,然后 `/reload`。
|
|
117
|
+
|
|
118
|
+
## 📄 许可证
|
|
119
|
+
|
|
120
|
+
MIT,见 [`LICENSE`](./LICENSE)。
|
|
Binary file
|
package/index.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { PeekComponent } from "./src/peek-component.ts";
|
|
4
|
+
import { deleteSession, renameSession, scanSessions, type PeekSession } from "./src/sessions.ts";
|
|
5
|
+
|
|
6
|
+
// 这里只做注册:/peek 命令、--peek / --rp 启动参数,以及把删除 / 重命名 / 分叉注入给组件
|
|
7
|
+
|
|
8
|
+
let lastQuery = ""; // 进程内记住上次搜索词
|
|
9
|
+
|
|
10
|
+
export default function (pi: ExtensionAPI) {
|
|
11
|
+
// pi 会把 boolean flag 的值一律强转成 true,所以带关键词的形式只能是 string 类型
|
|
12
|
+
pi.registerFlag("peek", {
|
|
13
|
+
description: "启动时打开会话搜索预览并预填关键词(--peek=关键词)",
|
|
14
|
+
type: "string",
|
|
15
|
+
});
|
|
16
|
+
pi.registerFlag("rp", {
|
|
17
|
+
description: "启动时打开会话搜索预览(不带关键词)",
|
|
18
|
+
type: "boolean",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
pi.on("session_start", async (event, ctx) => {
|
|
22
|
+
if (event.reason !== "startup") return; // 切换会话也会触发 session_start,只在进程启动时打开
|
|
23
|
+
const peek = pi.getFlag("peek");
|
|
24
|
+
const rp = pi.getFlag("rp");
|
|
25
|
+
if (!peek && !rp) return;
|
|
26
|
+
const kw = typeof peek === "string" ? peek.trim() : "";
|
|
27
|
+
const cmd = `/peek${kw ? ` ${kw}` : ""}`;
|
|
28
|
+
|
|
29
|
+
// 等 UI 空闲再弹;带了初始 prompt 时 agent 在忙,最后排队到它之后
|
|
30
|
+
const tryOpen = (attempt: number) => {
|
|
31
|
+
try {
|
|
32
|
+
if (ctx.isIdle()) {
|
|
33
|
+
pi.sendUserMessage(cmd, { expandPromptTemplates: true });
|
|
34
|
+
} else if (attempt < 20) {
|
|
35
|
+
setTimeout(() => tryOpen(attempt + 1), 150);
|
|
36
|
+
} else {
|
|
37
|
+
pi.sendUserMessage(cmd, { expandPromptTemplates: true, deliverAs: "followUp" });
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// 扩展已卸载时 API 会抛,忽略
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
setTimeout(() => tryOpen(0), 150);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
pi.registerCommand("peek", {
|
|
47
|
+
description: "搜索历史会话:左右分栏预览、关键词高亮,Enter 进入对话",
|
|
48
|
+
handler: async (args, ctx) => {
|
|
49
|
+
if (ctx.mode !== "tui") {
|
|
50
|
+
ctx.ui.notify("/peek 需要在交互模式(TUI)下使用", "error");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 当前正在用的会话不列出来
|
|
55
|
+
const currentFile = ctx.sessionManager.getSessionFile();
|
|
56
|
+
const all = scanSessions().filter((s) => s.path !== currentFile);
|
|
57
|
+
if (!all.length) {
|
|
58
|
+
ctx.ui.notify("没有找到历史会话", "info");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const initial = ((args ?? "").trim() || lastQuery) ?? "";
|
|
63
|
+
type Picked = { s: PeekSession; action: "resume" | "fork" } | null;
|
|
64
|
+
const picked = await ctx.ui.custom<Picked>((tui, theme, _kb, done) => {
|
|
65
|
+
const comp = new PeekComponent(all, ctx.cwd, theme, process.stdout.rows || 24, initial);
|
|
66
|
+
comp.requestRender = () => tui.requestRender();
|
|
67
|
+
comp.onResume = (s) => {
|
|
68
|
+
lastQuery = comp.getQuery();
|
|
69
|
+
done({ s, action: "resume" });
|
|
70
|
+
};
|
|
71
|
+
comp.onFork = (s) => {
|
|
72
|
+
lastQuery = comp.getQuery();
|
|
73
|
+
done({ s, action: "fork" });
|
|
74
|
+
};
|
|
75
|
+
comp.onCancel = () => {
|
|
76
|
+
lastQuery = comp.getQuery();
|
|
77
|
+
done(null);
|
|
78
|
+
};
|
|
79
|
+
comp.onDelete = async (s) => {
|
|
80
|
+
const ok = await deleteSession(s.path, (cmd, a) => pi.exec(cmd, a, { timeout: 4000 }));
|
|
81
|
+
ctx.ui.notify(ok ? `已删除会话 ${basename(s.path)}` : "删除会话失败", ok ? "info" : "error");
|
|
82
|
+
return ok;
|
|
83
|
+
};
|
|
84
|
+
comp.onRename = async (s, name) => {
|
|
85
|
+
let ok = true;
|
|
86
|
+
try {
|
|
87
|
+
renameSession(s.path, name);
|
|
88
|
+
} catch {
|
|
89
|
+
ok = false;
|
|
90
|
+
}
|
|
91
|
+
ctx.ui.notify(ok ? `已重命名为「${name}」` : "重命名失败", ok ? "info" : "error");
|
|
92
|
+
return ok;
|
|
93
|
+
};
|
|
94
|
+
return comp;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!picked) return;
|
|
98
|
+
|
|
99
|
+
let target = picked.s.path;
|
|
100
|
+
if (picked.action === "fork") {
|
|
101
|
+
// 和 pi --fork 一样:把全部记录复制到新文件,header 里记下 parentSession
|
|
102
|
+
try {
|
|
103
|
+
const forked = SessionManager.forkFrom(picked.s.path, ctx.cwd);
|
|
104
|
+
target = forked.getSessionFile() ?? target;
|
|
105
|
+
ctx.ui.notify(`已分叉为新会话 ${basename(target)}`, "info");
|
|
106
|
+
} catch (e) {
|
|
107
|
+
ctx.ui.notify(`分叉失败:${e instanceof Error ? e.message : String(e)}`, "error");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const result = await ctx.switchSession(target);
|
|
113
|
+
if (result?.cancelled) {
|
|
114
|
+
ctx.ui.notify("切换会话已取消", "info");
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-session-peek",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension: search session history in a two-pane picker with full conversation preview, then resume or fork.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Jeffropz",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi",
|
|
12
|
+
"pi-coding-agent",
|
|
13
|
+
"session",
|
|
14
|
+
"search",
|
|
15
|
+
"resume"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/Jeffropz/pi-session-peek.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/Jeffropz/pi-session-peek#readme",
|
|
22
|
+
"bugs": "https://github.com/Jeffropz/pi-session-peek/issues",
|
|
23
|
+
"files": [
|
|
24
|
+
"index.ts",
|
|
25
|
+
"src",
|
|
26
|
+
"docs/screenshot.png",
|
|
27
|
+
"README.md",
|
|
28
|
+
"README.zh-CN.md",
|
|
29
|
+
"CHANGELOG.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"pi": {
|
|
33
|
+
"extensions": [
|
|
34
|
+
"./index.ts"
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "node --import tsx --test \"test/**/*.test.ts\"",
|
|
40
|
+
"check": "npm run typecheck && npm test",
|
|
41
|
+
"screenshot": "node --import tsx scripts/screenshot.mjs",
|
|
42
|
+
"prepublishOnly": "npm run check"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
46
|
+
"@earendil-works/pi-tui": "*"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@earendil-works/pi-coding-agent": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"@earendil-works/pi-tui": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
58
|
+
"@earendil-works/pi-tui": "0.85.1",
|
|
59
|
+
"@types/node": "^22",
|
|
60
|
+
"tsx": "^4",
|
|
61
|
+
"typescript": "^5"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Input,
|
|
3
|
+
Key,
|
|
4
|
+
matchesKey,
|
|
5
|
+
truncateToWidth,
|
|
6
|
+
visibleWidth,
|
|
7
|
+
type Component,
|
|
8
|
+
type Focusable,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
import { anyKw, highlight, parseQuery, snippet, type ParsedQuery } from "./query.ts";
|
|
11
|
+
import type { PeekSession } from "./sessions.ts";
|
|
12
|
+
import { fmtTime, normPath, padEndVisible, wrapLines } from "./text.ts";
|
|
13
|
+
|
|
14
|
+
// 双栏选择器。文件操作(删除 / 重命名 / 分叉)不在这里做,由 index.ts 通过回调注入
|
|
15
|
+
|
|
16
|
+
export class PeekComponent implements Component, Focusable {
|
|
17
|
+
private input: Input;
|
|
18
|
+
private scope: "current" | "all";
|
|
19
|
+
private filtered: PeekSession[] = [];
|
|
20
|
+
private selected = 0;
|
|
21
|
+
private listOffset = 0;
|
|
22
|
+
private previewOffset = 0;
|
|
23
|
+
private previewKey = ""; // 预览缓存的键(会话 + 关键词 + 宽度),置空强制重建
|
|
24
|
+
private previewLines: string[] = [];
|
|
25
|
+
private matchLines: number[] = []; // 预览里命中消息所在的行,Ctrl+N / Ctrl+P 用
|
|
26
|
+
private confirmingDelete = false; // Ctrl+D 之后等待确认
|
|
27
|
+
private renaming = false; // Ctrl+R 之后正在输入名字
|
|
28
|
+
private renameInput: Input;
|
|
29
|
+
private cachedWidth = -1;
|
|
30
|
+
private cachedLines: string[] = [];
|
|
31
|
+
private _focused = false;
|
|
32
|
+
|
|
33
|
+
// 由 index.ts 注入
|
|
34
|
+
onResume?: (s: PeekSession) => void;
|
|
35
|
+
onFork?: (s: PeekSession) => void;
|
|
36
|
+
onCancel?: () => void;
|
|
37
|
+
onDelete?: (s: PeekSession) => Promise<boolean>;
|
|
38
|
+
onRename?: (s: PeekSession, name: string) => Promise<boolean>;
|
|
39
|
+
requestRender?: () => void; // 异步操作完成后让 TUI 重画
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
private all: PeekSession[],
|
|
43
|
+
private currentCwd: string,
|
|
44
|
+
private theme: any,
|
|
45
|
+
private termRows: number,
|
|
46
|
+
initialQuery: string,
|
|
47
|
+
) {
|
|
48
|
+
this.input = new Input({ placeholder: "关键词过滤(空格分隔=同时命中;@7d 限定近 7 天)" });
|
|
49
|
+
this.renameInput = new Input({ placeholder: "新会话名(Enter 确认 / Esc 取消,留空取消)" });
|
|
50
|
+
if (initialQuery) this.input.setValue(initialQuery);
|
|
51
|
+
// 当前目录树下有会话就默认当前目录,否则全局
|
|
52
|
+
this.scope = all.some((s) => this.inCurrentTree(s)) ? "current" : "all";
|
|
53
|
+
this.refilter();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 焦点要落到真正在用的那个输入框上,否则 IME 候选框位置不对
|
|
57
|
+
get focused(): boolean {
|
|
58
|
+
return this._focused;
|
|
59
|
+
}
|
|
60
|
+
set focused(v: boolean) {
|
|
61
|
+
this._focused = v;
|
|
62
|
+
this.input.focused = v && !this.renaming;
|
|
63
|
+
this.renameInput.focused = v && this.renaming;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getQuery(): string {
|
|
67
|
+
return this.input.getValue().trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private query(): ParsedQuery {
|
|
71
|
+
return parseQuery(this.getQuery());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 等于当前目录,或在它的子目录下
|
|
75
|
+
private inCurrentTree(s: PeekSession): boolean {
|
|
76
|
+
const cur = normPath(this.currentCwd);
|
|
77
|
+
const c = normPath(s.cwd);
|
|
78
|
+
return c === cur || c.startsWith(cur + "/");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// keepSelection:尽量保持原来选中的会话,重命名后用
|
|
82
|
+
private refilter(keepSelection = false): void {
|
|
83
|
+
const prev = keepSelection ? this.filtered[this.selected] : undefined;
|
|
84
|
+
const { kws, since } = this.query();
|
|
85
|
+
let out = this.all;
|
|
86
|
+
if (this.scope === "current") out = out.filter((s) => this.inCurrentTree(s));
|
|
87
|
+
if (since) out = out.filter((s) => s.mtime >= since);
|
|
88
|
+
if (kws.length) out = out.filter((s) => kws.every((k) => s.searchText.includes(k)));
|
|
89
|
+
this.filtered = out;
|
|
90
|
+
const idx = prev ? out.indexOf(prev) : -1;
|
|
91
|
+
this.selected = idx >= 0 ? idx : keepSelection ? Math.min(this.selected, Math.max(0, out.length - 1)) : 0;
|
|
92
|
+
if (!keepSelection) this.listOffset = 0;
|
|
93
|
+
this.previewKey = "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private bodyHeight(): number {
|
|
97
|
+
const rows = process.stdout.rows || this.termRows || 24; // 每次都读,终端拉伸后布局跟着变
|
|
98
|
+
return Math.max(8, Math.min(rows - 12, 30));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
handleInput(data: string): void {
|
|
102
|
+
// 重命名输入中:Enter 提交,Esc / Ctrl+C 取消,其他键交给输入框
|
|
103
|
+
if (this.renaming) {
|
|
104
|
+
if (matchesKey(data, Key.enter)) {
|
|
105
|
+
const s = this.filtered[this.selected];
|
|
106
|
+
const name = this.renameInput.getValue().trim();
|
|
107
|
+
this.renaming = false;
|
|
108
|
+
this.focused = this._focused;
|
|
109
|
+
this.invalidate();
|
|
110
|
+
if (s && name && this.onRename) {
|
|
111
|
+
void this.onRename(s, name).then((ok) => {
|
|
112
|
+
if (ok) {
|
|
113
|
+
s.name = name;
|
|
114
|
+
// 名字参与搜索,改完可能就不再匹配当前关键词了
|
|
115
|
+
s.searchText = (s.msgs.map((m) => m.text).join(" ") + " " + name + " " + s.cwd).toLowerCase();
|
|
116
|
+
this.refilter(true);
|
|
117
|
+
}
|
|
118
|
+
this.invalidate();
|
|
119
|
+
this.requestRender?.();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
125
|
+
this.renaming = false;
|
|
126
|
+
this.focused = this._focused;
|
|
127
|
+
this.invalidate();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.renameInput.handleInput(data);
|
|
131
|
+
this.invalidate();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// 删除确认中:y / Enter 执行,其他任何键取消
|
|
135
|
+
if (this.confirmingDelete) {
|
|
136
|
+
this.confirmingDelete = false;
|
|
137
|
+
this.invalidate();
|
|
138
|
+
if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
|
|
139
|
+
const s = this.filtered[this.selected];
|
|
140
|
+
if (s && this.onDelete) {
|
|
141
|
+
void this.onDelete(s).then((ok) => {
|
|
142
|
+
if (ok) {
|
|
143
|
+
let i = this.all.indexOf(s);
|
|
144
|
+
if (i >= 0) this.all.splice(i, 1);
|
|
145
|
+
i = this.filtered.indexOf(s);
|
|
146
|
+
if (i >= 0) this.filtered.splice(i, 1);
|
|
147
|
+
// 选中位置留在原地
|
|
148
|
+
this.selected = Math.min(this.selected, Math.max(0, this.filtered.length - 1));
|
|
149
|
+
this.previewKey = "";
|
|
150
|
+
}
|
|
151
|
+
this.invalidate();
|
|
152
|
+
this.requestRender?.();
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
159
|
+
this.onCancel?.();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (matchesKey(data, Key.ctrl("d"))) {
|
|
163
|
+
if (this.filtered.length && this.onDelete) {
|
|
164
|
+
this.confirmingDelete = true;
|
|
165
|
+
this.invalidate();
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (matchesKey(data, Key.ctrl("r"))) {
|
|
170
|
+
const s = this.filtered[this.selected];
|
|
171
|
+
if (s && this.onRename) {
|
|
172
|
+
this.renaming = true;
|
|
173
|
+
this.renameInput.setValue(s.name || "");
|
|
174
|
+
this.focused = this._focused;
|
|
175
|
+
this.invalidate();
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (matchesKey(data, Key.tab)) {
|
|
180
|
+
this.scope = this.scope === "all" ? "current" : "all";
|
|
181
|
+
this.refilter();
|
|
182
|
+
this.invalidate();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (matchesKey(data, Key.up)) {
|
|
186
|
+
if (this.selected > 0) {
|
|
187
|
+
this.selected--;
|
|
188
|
+
this.previewKey = "";
|
|
189
|
+
this.invalidate();
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (matchesKey(data, Key.down)) {
|
|
194
|
+
if (this.selected < this.filtered.length - 1) {
|
|
195
|
+
this.selected++;
|
|
196
|
+
this.previewKey = "";
|
|
197
|
+
this.invalidate();
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("u"))) {
|
|
202
|
+
const step = matchesKey(data, Key.pageUp) ? this.bodyHeight() : Math.max(1, this.bodyHeight() >> 1);
|
|
203
|
+
this.previewOffset = Math.max(0, this.previewOffset - step);
|
|
204
|
+
this.invalidate();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) {
|
|
208
|
+
const step = matchesKey(data, Key.pageDown) ? this.bodyHeight() : Math.max(1, this.bodyHeight() >> 1);
|
|
209
|
+
this.previewOffset += step;
|
|
210
|
+
this.invalidate();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (matchesKey(data, Key.shift("up"))) {
|
|
214
|
+
this.previewOffset = Math.max(0, this.previewOffset - 3);
|
|
215
|
+
this.invalidate();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (matchesKey(data, Key.shift("down"))) {
|
|
219
|
+
this.previewOffset += 3;
|
|
220
|
+
this.invalidate();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (matchesKey(data, Key.ctrl("n"))) {
|
|
224
|
+
this.jumpMatch(1);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (matchesKey(data, Key.ctrl("p"))) {
|
|
228
|
+
this.jumpMatch(-1);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (matchesKey(data, Key.enter)) {
|
|
232
|
+
const s = this.filtered[this.selected];
|
|
233
|
+
if (s) this.onResume?.(s);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (matchesKey(data, Key.ctrl("o"))) {
|
|
237
|
+
const s = this.filtered[this.selected];
|
|
238
|
+
if (s) this.onFork?.(s);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
// 其余按键交给搜索框
|
|
242
|
+
const before = this.input.getValue();
|
|
243
|
+
this.input.handleInput(data);
|
|
244
|
+
if (this.input.getValue() !== before) this.refilter(); // 光标移动不算输入,别重置列表
|
|
245
|
+
this.invalidate();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 预览里跳到下一个 / 上一个命中,到头了回绕
|
|
249
|
+
private jumpMatch(dir: 1 | -1): void {
|
|
250
|
+
if (!this.matchLines.length) return;
|
|
251
|
+
const cur = this.previewOffset + 2;
|
|
252
|
+
let target: number | undefined;
|
|
253
|
+
if (dir === 1) {
|
|
254
|
+
target = this.matchLines.find((l) => l > cur) ?? this.matchLines[0];
|
|
255
|
+
} else {
|
|
256
|
+
target = [...this.matchLines].reverse().find((l) => l < cur) ?? this.matchLines[this.matchLines.length - 1];
|
|
257
|
+
}
|
|
258
|
+
if (target !== undefined) {
|
|
259
|
+
this.previewOffset = Math.max(0, target - 2);
|
|
260
|
+
this.invalidate();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 左栏,每个会话两行
|
|
265
|
+
private buildList(height: number, lw: number): string[] {
|
|
266
|
+
const t = this.theme;
|
|
267
|
+
const { kws } = this.query();
|
|
268
|
+
const visible = Math.max(1, Math.floor(height / 2));
|
|
269
|
+
if (this.selected < this.listOffset) this.listOffset = this.selected;
|
|
270
|
+
if (this.selected >= this.listOffset + visible) {
|
|
271
|
+
this.listOffset = this.selected - visible + 1;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const rows: string[] = [];
|
|
275
|
+
for (let i = 0; i < visible; i++) {
|
|
276
|
+
const idx = this.listOffset + i;
|
|
277
|
+
const s = this.filtered[idx];
|
|
278
|
+
if (!s) {
|
|
279
|
+
rows.push(" ".repeat(lw), " ".repeat(lw));
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const sel = idx === this.selected;
|
|
283
|
+
const time = fmtTime(s.time);
|
|
284
|
+
const cwdTail = s.cwd.replace(/\\/g, "/").split("/").slice(-2).join("/");
|
|
285
|
+
// 有关键词时显示命中的消息数
|
|
286
|
+
const hitBadge = kws.length
|
|
287
|
+
? ` ·${s.msgs.reduce((n, m) => n + (anyKw(m.text.toLowerCase(), kws) ? 1 : 0), 0)}`
|
|
288
|
+
: "";
|
|
289
|
+
const l1 = `${sel ? "›" : " "} ${time} ${cwdTail}${hitBadge}`;
|
|
290
|
+
// 第二行:有关键词时显示命中片段,否则显示首条消息
|
|
291
|
+
const snip = snippet(s.msgs, kws, lw);
|
|
292
|
+
const l2 = snip !== undefined
|
|
293
|
+
? ` ${highlight(snip, kws, t)}`
|
|
294
|
+
: ` ${s.first}${s.name ? ` [${s.name}]` : ""}`;
|
|
295
|
+
if (sel) {
|
|
296
|
+
rows.push(t.bg("selectedBg", padEndVisible(t.fg("accent", truncateToWidth(l1, lw)), lw)));
|
|
297
|
+
rows.push(t.bg("selectedBg", padEndVisible(truncateToWidth(l2, lw), lw)));
|
|
298
|
+
} else {
|
|
299
|
+
rows.push(padEndVisible(l1, lw));
|
|
300
|
+
rows.push(padEndVisible(t.fg("dim", truncateToWidth(l2, lw)), lw));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return rows.slice(0, height);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 右栏。有关键词时滚到第一个命中处,没有时滚到底部看最新消息
|
|
307
|
+
private buildPreview(rw: number): void {
|
|
308
|
+
const t = this.theme;
|
|
309
|
+
const s = this.filtered[this.selected];
|
|
310
|
+
const key = s ? `${s.path}|${this.getQuery()}|${rw}` : "none";
|
|
311
|
+
if (key === this.previewKey) return;
|
|
312
|
+
this.previewKey = key;
|
|
313
|
+
this.matchLines = [];
|
|
314
|
+
|
|
315
|
+
if (!s) {
|
|
316
|
+
this.previewLines = [t.fg("dim", "没有匹配的会话")];
|
|
317
|
+
this.previewOffset = 0;
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const lines: string[] = [];
|
|
322
|
+
lines.push(
|
|
323
|
+
truncateToWidth(
|
|
324
|
+
t.fg("dim", `${s.cwd} • ${s.msgs.length} 条消息${s.name ? " • " + s.name : ""}`),
|
|
325
|
+
rw,
|
|
326
|
+
),
|
|
327
|
+
);
|
|
328
|
+
lines.push("");
|
|
329
|
+
|
|
330
|
+
const { kws } = this.query();
|
|
331
|
+
const MAX_MSGS = 500;
|
|
332
|
+
const truncated = s.msgs.length > MAX_MSGS;
|
|
333
|
+
const msgs = truncated ? s.msgs.slice(-MAX_MSGS) : s.msgs;
|
|
334
|
+
if (truncated) {
|
|
335
|
+
lines.push(t.fg("dim", `(会话过长,仅显示最后 ${MAX_MSGS} 条)`));
|
|
336
|
+
lines.push("");
|
|
337
|
+
}
|
|
338
|
+
if (kws.length && !msgs.some((m) => anyKw(m.text.toLowerCase(), kws))) {
|
|
339
|
+
const inTruncated = truncated && s.msgs.some((m) => anyKw(m.text.toLowerCase(), kws));
|
|
340
|
+
lines.push(
|
|
341
|
+
t.fg(
|
|
342
|
+
"warning",
|
|
343
|
+
inTruncated ? "关键词命中在未显示的更早消息中" : "关键词仅命中会话名或目录,对话正文无匹配",
|
|
344
|
+
),
|
|
345
|
+
);
|
|
346
|
+
lines.push("");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 用户消息整块背景色,AI 消息只有一条细线标签
|
|
350
|
+
const fillLabel = (label: string): string => {
|
|
351
|
+
const w = visibleWidth(label);
|
|
352
|
+
return truncateToWidth(label + " " + "─".repeat(Math.max(2, rw - w - 1)), rw);
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
for (const m of msgs) {
|
|
356
|
+
const isMatch = kws.length > 0 && anyKw(m.text.toLowerCase(), kws);
|
|
357
|
+
if (isMatch) this.matchLines.push(lines.length);
|
|
358
|
+
const body = isMatch ? highlight(m.text, kws, t) : m.text;
|
|
359
|
+
|
|
360
|
+
if (m.role === "user") {
|
|
361
|
+
lines.push(
|
|
362
|
+
t.bg("userMessageBg", padEndVisible(t.bold(t.fg("accent", fillLabel("👤 你"))), rw)),
|
|
363
|
+
);
|
|
364
|
+
for (const wl of wrapLines(body, rw - 2)) {
|
|
365
|
+
lines.push(t.bg("userMessageBg", padEndVisible(" " + wl, rw)));
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
lines.push(t.fg("muted", fillLabel("🤖 AI")));
|
|
369
|
+
for (const wl of wrapLines(body, rw - 2)) lines.push(" " + wl);
|
|
370
|
+
}
|
|
371
|
+
lines.push("");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
this.previewLines = lines;
|
|
375
|
+
|
|
376
|
+
if (this.matchLines.length) {
|
|
377
|
+
this.previewOffset = Math.max(0, this.matchLines[0] - 2);
|
|
378
|
+
} else {
|
|
379
|
+
this.previewOffset = Math.max(0, lines.length - this.bodyHeight());
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
render(width: number): string[] {
|
|
384
|
+
const H = this.bodyHeight();
|
|
385
|
+
const cacheKey = width * 1000 + H; // 宽或高变了都要重画
|
|
386
|
+
if (this.cachedWidth === cacheKey) return this.cachedLines;
|
|
387
|
+
const t = this.theme;
|
|
388
|
+
const lw = Math.max(26, Math.min(56, Math.floor(width * 0.4)));
|
|
389
|
+
const rw = Math.max(20, width - lw - 3);
|
|
390
|
+
|
|
391
|
+
const out: string[] = [];
|
|
392
|
+
|
|
393
|
+
// 头部:标题、范围、时间过滤、匹配数,然后是搜索框
|
|
394
|
+
const scopeLabel = this.scope === "all" ? "全局" : "当前目录树";
|
|
395
|
+
const { sinceLabel } = this.query();
|
|
396
|
+
const head =
|
|
397
|
+
t.fg("accent", t.bold("🔍 会话搜索预览")) +
|
|
398
|
+
t.fg("dim", " 范围[Tab]: ") +
|
|
399
|
+
t.fg("warning", scopeLabel) +
|
|
400
|
+
(sinceLabel ? t.fg("dim", " 时间: ") + t.fg("warning", sinceLabel) : "") +
|
|
401
|
+
t.fg("dim", ` 匹配 ${this.filtered.length}/${this.all.length}`);
|
|
402
|
+
out.push(truncateToWidth(head, width));
|
|
403
|
+
out.push(this.input.render(width)[0] ?? "");
|
|
404
|
+
out.push(t.fg("borderMuted", "─".repeat(width)));
|
|
405
|
+
|
|
406
|
+
// 双栏
|
|
407
|
+
this.buildPreview(rw);
|
|
408
|
+
const leftRows = this.buildList(H, lw);
|
|
409
|
+
|
|
410
|
+
const maxOff = Math.max(0, this.previewLines.length - H);
|
|
411
|
+
if (this.previewOffset > maxOff) this.previewOffset = maxOff;
|
|
412
|
+
const rightRows = this.previewLines.slice(this.previewOffset, this.previewOffset + H);
|
|
413
|
+
const scrollInfo =
|
|
414
|
+
this.previewLines.length > H
|
|
415
|
+
? t.fg(
|
|
416
|
+
"dim",
|
|
417
|
+
` (${this.previewOffset + 1}-${Math.min(this.previewOffset + H, this.previewLines.length)}/${this.previewLines.length})`,
|
|
418
|
+
)
|
|
419
|
+
: "";
|
|
420
|
+
|
|
421
|
+
for (let i = 0; i < H; i++) {
|
|
422
|
+
const l = leftRows[i] ?? "";
|
|
423
|
+
let r = rightRows[i] ?? "";
|
|
424
|
+
if (i === 0 && scrollInfo) {
|
|
425
|
+
r = truncateToWidth(r, Math.max(0, rw - visibleWidth(scrollInfo))) + scrollInfo;
|
|
426
|
+
}
|
|
427
|
+
// 左栏在 buildList 里已经 pad 到 lw,这里再 pad 会把带样式的行截坏
|
|
428
|
+
out.push(l + t.fg("borderMuted", " │ ") + padEndVisible(r, rw));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// 底部:重命名和删除确认时换成对应的操作行
|
|
432
|
+
out.push(t.fg("borderMuted", "─".repeat(width)));
|
|
433
|
+
if (this.renaming) {
|
|
434
|
+
const prefix = "✏ 重命名: ";
|
|
435
|
+
const inputLine = this.renameInput.render(Math.max(10, width - visibleWidth(prefix)))[0] ?? "";
|
|
436
|
+
out.push(truncateToWidth(t.fg("warning", prefix) + inputLine, width));
|
|
437
|
+
} else if (this.confirmingDelete) {
|
|
438
|
+
const s = this.filtered[this.selected];
|
|
439
|
+
const desc = s ? `${fmtTime(s.time, true)} ${s.first.slice(0, 30)}` : "";
|
|
440
|
+
out.push(
|
|
441
|
+
truncateToWidth(
|
|
442
|
+
t.fg("error", t.bold(`⚠ 删除会话 [${desc}]?按 y / Enter 确认,任意其他键取消`)),
|
|
443
|
+
width,
|
|
444
|
+
),
|
|
445
|
+
);
|
|
446
|
+
} else {
|
|
447
|
+
out.push(
|
|
448
|
+
truncateToWidth(
|
|
449
|
+
t.fg("dim", "↑↓ 选择 PgUp/Dn·^U/^F·⇧↑↓ 滚动 ^N/^P 命中 Tab 范围 ^D 删除 ^R 改名 ^O 分叉 Enter 进入 Esc/^C 关闭"),
|
|
450
|
+
width,
|
|
451
|
+
),
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
this.cachedWidth = cacheKey;
|
|
456
|
+
this.cachedLines = out;
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
invalidate(): void {
|
|
461
|
+
this.cachedWidth = -1;
|
|
462
|
+
this.input.invalidate();
|
|
463
|
+
}
|
|
464
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { PeekMsg } from "./sessions.ts";
|
|
2
|
+
|
|
3
|
+
// 搜索框语法:空格分隔多个词,都要命中;@7d / @24h / @2w / @1m 限定最近活动时间
|
|
4
|
+
|
|
5
|
+
export interface ParsedQuery {
|
|
6
|
+
kws: string[];
|
|
7
|
+
since: number; // 毫秒时间戳,0 表示不限
|
|
8
|
+
sinceLabel: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const TIME_UNIT_MS: Record<string, number> = { h: 3600e3, d: 86400e3, w: 7 * 86400e3, m: 30 * 86400e3 };
|
|
12
|
+
const TIME_UNIT_LABEL: Record<string, string> = { h: "小时", d: "天", w: "周", m: "个月" };
|
|
13
|
+
|
|
14
|
+
// "foo bar @7d" → kws = [foo, bar],since = 7 天前
|
|
15
|
+
export function parseQuery(q: string): ParsedQuery {
|
|
16
|
+
const kws: string[] = [];
|
|
17
|
+
let since = 0;
|
|
18
|
+
let sinceLabel = "";
|
|
19
|
+
for (const tok of q.toLowerCase().split(/\s+/)) {
|
|
20
|
+
if (!tok) continue;
|
|
21
|
+
const m = /^@(\d+)([hdwm])$/.exec(tok);
|
|
22
|
+
if (m) {
|
|
23
|
+
const n = Number(m[1]);
|
|
24
|
+
if (n > 0) {
|
|
25
|
+
since = Date.now() - n * TIME_UNIT_MS[m[2]];
|
|
26
|
+
sinceLabel = `近${n}${TIME_UNIT_LABEL[m[2]]}`;
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!kws.includes(tok)) kws.push(tok);
|
|
31
|
+
}
|
|
32
|
+
return { kws, since, sinceLabel };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 单条消息用:命中任意一个关键词就算
|
|
36
|
+
export function anyKw(lower: string, kws: string[]): boolean {
|
|
37
|
+
for (const k of kws) if (lower.includes(k)) return true;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 最早出现的关键词;同一位置取最长的那个
|
|
42
|
+
function nextMatch(lower: string, kws: string[], i: number): [number, number] {
|
|
43
|
+
let best = -1;
|
|
44
|
+
let len = 0;
|
|
45
|
+
for (const k of kws) {
|
|
46
|
+
const j = lower.indexOf(k, i);
|
|
47
|
+
if (j !== -1 && (best === -1 || j < best || (j === best && k.length > len))) {
|
|
48
|
+
best = j;
|
|
49
|
+
len = k.length;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return [best, len];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 在换行之前调用,返回带 ANSI 的字符串。
|
|
56
|
+
// 不用主题自带的 searchMatchBg:dark/light 主题里它和 selectedBg 同色,看不出来
|
|
57
|
+
export function highlight(text: string, kws: string[], theme: any): string {
|
|
58
|
+
if (!kws.length) return text;
|
|
59
|
+
const lower = text.toLowerCase();
|
|
60
|
+
let out = "";
|
|
61
|
+
let i = 0;
|
|
62
|
+
for (;;) {
|
|
63
|
+
const [j, len] = nextMatch(lower, kws, i);
|
|
64
|
+
if (j === -1) {
|
|
65
|
+
out += text.slice(i);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
out +=
|
|
69
|
+
text.slice(i, j) +
|
|
70
|
+
"\x1b[4m" +
|
|
71
|
+
theme.bold(theme.fg("warning", text.slice(j, j + len))) +
|
|
72
|
+
"\x1b[24m";
|
|
73
|
+
i = j + len;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 左栏用:第一个命中处前后截一段。没有消息命中时返回 undefined,调用方退回首条消息
|
|
79
|
+
export function snippet(msgs: PeekMsg[], kws: string[], width: number): string | undefined {
|
|
80
|
+
if (!kws.length) return undefined;
|
|
81
|
+
for (const m of msgs) {
|
|
82
|
+
const flat = m.text.replace(/\s+/g, " ");
|
|
83
|
+
const [j] = nextMatch(flat.toLowerCase(), kws, 0);
|
|
84
|
+
if (j === -1) continue;
|
|
85
|
+
const start = Math.max(0, j - Math.floor(width / 3));
|
|
86
|
+
return (start > 0 ? "…" : "") + flat.slice(start, start + width);
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
package/src/sessions.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { appendFileSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// 读 ~/.pi/agent/sessions 下的会话 JSONL,按 mtime 缓存;重命名和删除也在这里
|
|
7
|
+
|
|
8
|
+
export interface PeekMsg {
|
|
9
|
+
role: "user" | "assistant";
|
|
10
|
+
text: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PeekSession {
|
|
14
|
+
path: string;
|
|
15
|
+
cwd: string;
|
|
16
|
+
time: string;
|
|
17
|
+
name: string; // 最后一条 session_info 里的名字,没有则为空
|
|
18
|
+
first: string; // 首条消息,列表里显示用
|
|
19
|
+
msgs: PeekMsg[];
|
|
20
|
+
searchText: string; // 正文 + 会话名 + cwd,已小写
|
|
21
|
+
mtime: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const sessionCache = new Map<string, PeekSession>();
|
|
25
|
+
|
|
26
|
+
// 和 pi 一样认 PI_CODING_AGENT_DIR
|
|
27
|
+
function sessionsDir(): string {
|
|
28
|
+
const env = process.env.PI_CODING_AGENT_DIR;
|
|
29
|
+
const agent = env ? (env.startsWith("~") ? join(homedir(), env.slice(1)) : env) : join(homedir(), ".pi", "agent");
|
|
30
|
+
return join(agent, "sessions");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function* walkJsonl(dir: string): Generator<string> {
|
|
34
|
+
let entries;
|
|
35
|
+
try {
|
|
36
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
37
|
+
} catch {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const e of entries) {
|
|
41
|
+
const p = join(dir, e.name);
|
|
42
|
+
if (e.isDirectory()) yield* walkJsonl(p);
|
|
43
|
+
else if (e.name.endsWith(".jsonl")) yield p;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function extractText(content: unknown): string {
|
|
48
|
+
if (typeof content === "string") return content;
|
|
49
|
+
if (!Array.isArray(content)) return "";
|
|
50
|
+
return content
|
|
51
|
+
.filter((c: any) => c?.type === "text" && typeof c.text === "string")
|
|
52
|
+
.map((c: any) => c.text)
|
|
53
|
+
.join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseSession(path: string, mtime: number): PeekSession | null {
|
|
57
|
+
let raw: string;
|
|
58
|
+
try {
|
|
59
|
+
raw = readFileSync(path, "utf8");
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
let cwd = "";
|
|
64
|
+
let time = "";
|
|
65
|
+
let name = "";
|
|
66
|
+
const msgs: PeekMsg[] = [];
|
|
67
|
+
|
|
68
|
+
for (const line of raw.split("\n")) {
|
|
69
|
+
if (!line) continue;
|
|
70
|
+
try {
|
|
71
|
+
if (line.includes('"type":"session"')) {
|
|
72
|
+
const o = JSON.parse(line);
|
|
73
|
+
cwd = o.cwd ?? cwd;
|
|
74
|
+
time = o.timestamp ?? time;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (line.includes('"type":"session_info"')) {
|
|
78
|
+
const o = JSON.parse(line);
|
|
79
|
+
if (typeof o.name === "string" && o.name) name = o.name;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// 先用子串粗筛,省掉大部分行的 JSON.parse
|
|
83
|
+
if (!line.includes('"role":"user"') && !line.includes('"role":"assistant"')) continue;
|
|
84
|
+
const o = JSON.parse(line);
|
|
85
|
+
if (o.type !== "message") continue;
|
|
86
|
+
const role = o.message?.role;
|
|
87
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
88
|
+
const text = extractText(o.message?.content).trim();
|
|
89
|
+
if (text) msgs.push({ role, text });
|
|
90
|
+
} catch {
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!msgs.length) return null;
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
path,
|
|
98
|
+
cwd,
|
|
99
|
+
time,
|
|
100
|
+
name,
|
|
101
|
+
msgs,
|
|
102
|
+
mtime,
|
|
103
|
+
first: msgs[0].text.replace(/\s+/g, " ").slice(0, 80),
|
|
104
|
+
searchText: (msgs.map((m) => m.text).join(" ") + " " + name + " " + cwd).toLowerCase(),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 全部会话,按最后修改时间倒序;文件没变的直接用缓存 */
|
|
109
|
+
export function scanSessions(): PeekSession[] {
|
|
110
|
+
const seen = new Set<string>();
|
|
111
|
+
const out: PeekSession[] = [];
|
|
112
|
+
|
|
113
|
+
for (const file of walkJsonl(sessionsDir())) {
|
|
114
|
+
seen.add(file);
|
|
115
|
+
let mtime = 0;
|
|
116
|
+
try {
|
|
117
|
+
mtime = statSync(file).mtimeMs;
|
|
118
|
+
} catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const hit = sessionCache.get(file);
|
|
122
|
+
if (hit && hit.mtime === mtime) {
|
|
123
|
+
out.push(hit);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const parsed = parseSession(file, mtime);
|
|
127
|
+
if (parsed) {
|
|
128
|
+
sessionCache.set(file, parsed);
|
|
129
|
+
out.push(parsed);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 文件已经不在了的,从缓存里去掉
|
|
134
|
+
for (const key of [...sessionCache.keys()]) {
|
|
135
|
+
if (!seen.has(key)) sessionCache.delete(key);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return out.sort((a, b) => b.mtime - a.mtime);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 和 pi 自带的 /name 写同一种 session_info 记录,parentId 用文件里最后一条记录的 id
|
|
142
|
+
export function renameSession(path: string, name: string): void {
|
|
143
|
+
const raw = readFileSync(path, "utf8");
|
|
144
|
+
const lines = raw.split("\n");
|
|
145
|
+
let parentId: string | null = null;
|
|
146
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
147
|
+
if (!lines[i]) continue;
|
|
148
|
+
try {
|
|
149
|
+
const o = JSON.parse(lines[i]);
|
|
150
|
+
if (typeof o.id === "string") {
|
|
151
|
+
parentId = o.id;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// 末行可能只写了一半
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const entry = {
|
|
159
|
+
type: "session_info",
|
|
160
|
+
id: randomBytes(4).toString("hex"),
|
|
161
|
+
parentId,
|
|
162
|
+
timestamp: new Date().toISOString(),
|
|
163
|
+
name,
|
|
164
|
+
};
|
|
165
|
+
// 末行没换行(写了一半)时先补一个,别和新记录粘在一起
|
|
166
|
+
const sep = raw === "" || raw.endsWith("\n") ? "" : "\n";
|
|
167
|
+
appendFileSync(path, sep + JSON.stringify(entry) + "\n", "utf8");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 先试 trash(能恢复),不行再直接删。trashExec 由调用方传入,方便测试
|
|
171
|
+
export async function deleteSession(
|
|
172
|
+
path: string,
|
|
173
|
+
trashExec: (cmd: string, args: string[]) => Promise<{ code: number }>,
|
|
174
|
+
): Promise<boolean> {
|
|
175
|
+
try {
|
|
176
|
+
const r = await trashExec("trash", [path]);
|
|
177
|
+
if (r.code === 0) return true;
|
|
178
|
+
} catch {
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
rmSync(path, { force: true });
|
|
182
|
+
return true;
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/text.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
// Windows 路径大小写不敏感,统一小写再比
|
|
4
|
+
export const normPath = (p: string) => p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
5
|
+
|
|
6
|
+
// 本地时间 "MM-DD HH:mm";withYear 或不是今年时带年份
|
|
7
|
+
export function fmtTime(iso: string, withYear = false): string {
|
|
8
|
+
const d = new Date(iso);
|
|
9
|
+
if (!iso || Number.isNaN(d.getTime())) return withYear ? "????-??-?? ??:??" : "??-?? ??:??";
|
|
10
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
11
|
+
const md = `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
12
|
+
return withYear || d.getFullYear() !== new Date().getFullYear() ? `${d.getFullYear()}-${md}` : md;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 按显示宽度补齐到 w,中文和 ANSI 都算对
|
|
16
|
+
export function padEndVisible(s: string, w: number): string {
|
|
17
|
+
const v = visibleWidth(s);
|
|
18
|
+
return v >= w ? truncateToWidth(s, w) : s + " ".repeat(w - v);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function wrapLines(text: string, width: number): string[] {
|
|
22
|
+
const out: string[] = [];
|
|
23
|
+
for (const part of text.split("\n")) {
|
|
24
|
+
const wrapped = wrapTextWithAnsi(part, width);
|
|
25
|
+
out.push(...(wrapped.length ? wrapped : [""]));
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|