pi-session-peek 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +23 -9
- package/README.zh-CN.md +23 -9
- package/package.json +3 -1
- package/src/i18n.ts +4 -4
- package/src/peek-component.ts +47 -25
- package/src/query.ts +149 -42
- package/src/sessions.ts +35 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
- `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.
|
|
6
|
+
- 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.
|
|
7
|
+
|
|
8
|
+
## 0.4.0
|
|
9
|
+
|
|
10
|
+
- 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.
|
|
11
|
+
- The preview highlights and counts only positive terms, and `user:` / `ai:` terms only inside messages of that role. `name:` / `dir:` terms never touch the conversation.
|
|
12
|
+
- Lines containing a character whose lowercase form has a different length (for example `İ`) are now highlighted too. Before, the whole line was skipped.
|
|
13
|
+
|
|
3
14
|
## 0.3.1
|
|
4
15
|
|
|
5
16
|
- `scanSessions` is now async: directory walking and file reads go through `fs/promises` with bounded concurrency, so opening `/peek` with hundreds of sessions no longer blocks the TUI.
|
package/README.md
CHANGED
|
@@ -12,10 +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
|
+
- Understands `"exact phrases"`, `either|or`, `-exclude`, `name:` / `dir:` / `user:` / `ai:` prefixes and `/regex/`.
|
|
17
18
|
- Limits results to recently active sessions with `@7d`, `@24h`, `@2w` or `@1m`.
|
|
18
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.
|
|
19
21
|
- Resumes with `Enter`, forks into a new session with `Ctrl+O`, renames with `Ctrl+R`, deletes with `Ctrl+D`.
|
|
20
22
|
- Toggles between the current directory tree and all projects with `Tab`.
|
|
21
23
|
- Starts straight into the picker with `pi --rp` or `pi --peek=<keyword>`.
|
|
@@ -58,13 +60,14 @@ In TUI mode, run `/peek` and start typing. Press `Enter` on a session to continu
|
|
|
58
60
|
|
|
59
61
|
| Key | Action |
|
|
60
62
|
| --- | --- |
|
|
61
|
-
| type | Filter. Space-separated words must all match;
|
|
63
|
+
| type | Filter. Space-separated words must all match; see [Search syntax](#-search-syntax) for phrases, `-exclude`, prefixes, regex and `@7d`. |
|
|
62
64
|
| `Tab` | Current directory tree ↔ all projects |
|
|
63
65
|
| `↑` `↓` | Select session |
|
|
64
66
|
| `PgUp` `PgDn` | Scroll preview by a page |
|
|
65
67
|
| `Ctrl+U` `Ctrl+F` | Scroll preview by half a page |
|
|
66
68
|
| `Shift+↑` `Shift+↓` | Scroll preview by three lines |
|
|
67
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. |
|
|
68
71
|
| `Enter` | Resume the session |
|
|
69
72
|
| `Ctrl+O` | Fork the session and open the fork |
|
|
70
73
|
| `Ctrl+R` | Rename. Writes the same `session_info` entry as `/name`. |
|
|
@@ -73,15 +76,23 @@ In TUI mode, run `/peek` and start typing. Press `Enter` on a session to continu
|
|
|
73
76
|
|
|
74
77
|
## 🔎 Search syntax
|
|
75
78
|
|
|
76
|
-
The filter is case-insensitive and matches anywhere in the text.
|
|
79
|
+
The filter is case-insensitive and matches anywhere in the text. Whitespace separates terms, and every term must be satisfied.
|
|
77
80
|
|
|
78
81
|
| Input | Meaning |
|
|
79
82
|
| --- | --- |
|
|
80
|
-
| `token undefined` | Sessions containing both `token` and `undefined
|
|
83
|
+
| `token undefined` | Sessions containing both `token` and `undefined`, in any order and any message |
|
|
84
|
+
| `"token undefined"` | The exact phrase. Whitespace inside the quotes matches any run of whitespace, including a line break. |
|
|
85
|
+
| `vue\|react` | Sessions containing `vue` or `react` |
|
|
86
|
+
| `-draft` | Sessions that do not contain `draft` anywhere (messages, name or directory) |
|
|
87
|
+
| `name:auth` | Session name only. `dir:` or `cwd:` for the working directory. |
|
|
88
|
+
| `user:deploy` | Only the messages you wrote. `ai:` or `assistant:` for the replies. |
|
|
89
|
+
| `/\bfoo\d+\b/` | JavaScript regular expression, case-insensitive, `^` and `$` match line boundaries. Use `\s` instead of a space. |
|
|
81
90
|
| `@7d` | Sessions active in the last 7 days. Units: `h`, `d`, `w`, `m` (30 days). |
|
|
82
|
-
|
|
|
91
|
+
| `-user:"not now" ai:/todo\|fixme/ @2w` | Prefixes stack, and everything combines |
|
|
83
92
|
|
|
84
|
-
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
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.
|
|
85
96
|
|
|
86
97
|
## 🌐 Language
|
|
87
98
|
|
|
@@ -139,13 +150,16 @@ One-time setup on npmjs.com: package settings → Trusted Publisher → GitHub A
|
|
|
139
150
|
To cut a release:
|
|
140
151
|
|
|
141
152
|
```bash
|
|
142
|
-
# 1.
|
|
143
|
-
#
|
|
144
|
-
|
|
153
|
+
# 1. write the release notes under "## Unreleased" in CHANGELOG.md and commit them
|
|
154
|
+
# (npm version refuses to run with uncommitted changes to tracked files)
|
|
155
|
+
# 2. bump the version (patch / minor / major)
|
|
156
|
+
npm version patch
|
|
145
157
|
# 3. push the commit together with the tag; CI does the rest
|
|
146
158
|
git push --follow-tags
|
|
147
159
|
```
|
|
148
160
|
|
|
161
|
+
`npm version` first checks that the working tree is clean and stops with `Git working directory not clean.` otherwise, so everything, including `CHANGELOG.md`, has to be committed before the bump. Untracked files are fine. It then runs the hooks declared in `package.json`: `preversion` checks that `CHANGELOG.md` has a non-empty `## Unreleased` section and runs `npm run check`, aborting the bump before anything is touched if either fails; `version` runs `scripts/release-changelog.mjs`, which renames `## Unreleased` to the new version and stages the file. npm then commits `package.json`, the lockfile and `CHANGELOG.md` as `chore: release X.Y.Z` (message set in `.npmrc`) and tags it `vX.Y.Z`.
|
|
162
|
+
|
|
149
163
|
## 📄 License
|
|
150
164
|
|
|
151
165
|
MIT. See [`LICENSE`](./LICENSE).
|
package/README.zh-CN.md
CHANGED
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
|
|
13
13
|
- `/peek` 或 `/peek 关键词` 打开左右分栏的选择器:左边是会话列表,右边是完整对话。
|
|
14
14
|
- 右栏用 pi 自己的 Markdown 渲染器画,标题、带语法高亮的代码块、表格、列表和主界面里的对话一个样子。
|
|
15
|
-
-
|
|
15
|
+
- 边打字边过滤,搜的是对话正文、会话名和工作目录。工具调用的参数和结果不进搜索索引,所以只会命中真正讨论过这个词的会话。
|
|
16
16
|
- 空格分隔多个关键词,全部命中才显示;列表里直接显示第一个命中处的片段。
|
|
17
|
+
- 支持 `"精确短语"`、`a|b` 任一命中、`-排除`、`name:` / `dir:` / `user:` / `ai:` 限定范围,以及 `/正则/`。
|
|
17
18
|
- `@7d`、`@24h`、`@2w`、`@1m` 只看最近活动过的会话。
|
|
18
19
|
- 预览里所有命中都高亮,`Ctrl+N` / `Ctrl+P` 在命中间跳。
|
|
20
|
+
- `Ctrl+T` 显示 AI 实际做了什么:每次工具调用在对应回复下面占一行灰字(`⚙ bash git status`、`⚙ edit src/query.ts`)。默认关闭。
|
|
19
21
|
- `Enter` 进入会话,`Ctrl+O` 分叉成新会话,`Ctrl+R` 重命名,`Ctrl+D` 删除。
|
|
20
22
|
- `Tab` 在当前目录树和全部项目之间切换。
|
|
21
23
|
- `pi --rp` 或 `pi --peek=关键词` 启动时直接打开。
|
|
@@ -58,13 +60,14 @@ pi 扩展以你的用户权限运行,只装信得过的包。
|
|
|
58
60
|
|
|
59
61
|
| 按键 | 作用 |
|
|
60
62
|
| --- | --- |
|
|
61
|
-
| 打字 |
|
|
63
|
+
| 打字 | 过滤。空格分隔的词都要命中;短语、`-排除`、前缀、正则和 `@7d` 见[搜索语法](#-搜索语法)。 |
|
|
62
64
|
| `Tab` | 当前目录树 ↔ 全部项目 |
|
|
63
65
|
| `↑` `↓` | 选会话 |
|
|
64
66
|
| `PgUp` `PgDn` | 预览翻页 |
|
|
65
67
|
| `Ctrl+U` `Ctrl+F` | 预览翻半页 |
|
|
66
68
|
| `Shift+↑` `Shift+↓` | 预览滚三行 |
|
|
67
69
|
| `Ctrl+N` `Ctrl+P` | 跳到当前屏幕外的下一处 / 上一处命中行,右上角显示「命中 k/n」 |
|
|
70
|
+
| `Ctrl+T` | 预览里显示 / 隐藏工具调用。每次调用一行:工具名和主参数(命令、路径、URL、查询词)。不显示结果。 |
|
|
68
71
|
| `Enter` | 进入会话 |
|
|
69
72
|
| `Ctrl+O` | 分叉出新会话并进入 |
|
|
70
73
|
| `Ctrl+R` | 重命名,写入的记录和 `/name` 一样 |
|
|
@@ -73,15 +76,23 @@ pi 扩展以你的用户权限运行,只装信得过的包。
|
|
|
73
76
|
|
|
74
77
|
## 🔎 搜索语法
|
|
75
78
|
|
|
76
|
-
|
|
79
|
+
大小写不敏感,匹配文本中任意位置。空白分隔多个词,每个词都要满足。
|
|
77
80
|
|
|
78
81
|
| 输入 | 含义 |
|
|
79
82
|
| --- | --- |
|
|
80
|
-
| `token undefined` | 同时包含 `token` 和 `undefined`
|
|
83
|
+
| `token undefined` | 同时包含 `token` 和 `undefined` 的会话,顺序不限,可以在不同的消息里 |
|
|
84
|
+
| `"token undefined"` | 精确短语。引号里的空白可以匹配正文里的任意一段空白,包括换行 |
|
|
85
|
+
| `vue\|react` | 包含 `vue` 或 `react` 的会话 |
|
|
86
|
+
| `-draft` | 哪里都不含 `draft` 的会话(消息、会话名、目录都算) |
|
|
87
|
+
| `name:auth` | 只在会话名里找。`dir:` 或 `cwd:` 只看工作目录 |
|
|
88
|
+
| `user:deploy` | 只看你发的消息。`ai:` 或 `assistant:` 只看回复 |
|
|
89
|
+
| `/\bfoo\d+\b/` | JavaScript 正则,大小写不敏感,`^` 和 `$` 匹配行首行尾。空格用 `\s` 代替 |
|
|
81
90
|
| `@7d` | 最近 7 天活动过的会话。单位:`h` 小时、`d` 天、`w` 周、`m` 月(按 30 天算) |
|
|
82
|
-
|
|
|
91
|
+
| `-user:"not now" ai:/todo\|fixme/ @2w` | 前缀可以叠加,所有条件一起组合 |
|
|
83
92
|
|
|
84
|
-
|
|
93
|
+
引号是万能转义:`"-foo"`、`"a|b"`、`"name:x"`、`"/x/"` 都按字面搜这些字符。正则写错了就当普通文字搜。预览里只高亮正向的词,`name:` / `dir:` 的词不碰对话正文,所以只输入 `name:auth` 时右栏显示完整对话、没有高亮。
|
|
94
|
+
|
|
95
|
+
索引里有用户和助手的消息、会话名、工作目录。没有工具调用的参数和结果,`Ctrl+T` 显示的工具行也不参与匹配和高亮。
|
|
85
96
|
|
|
86
97
|
## 🌐 界面语言
|
|
87
98
|
|
|
@@ -139,13 +150,16 @@ npm 侧需要一次性配置:包设置 → Trusted Publisher → GitHub Action
|
|
|
139
150
|
发一个版本:
|
|
140
151
|
|
|
141
152
|
```bash
|
|
142
|
-
# 1.
|
|
143
|
-
#
|
|
144
|
-
|
|
153
|
+
# 1. 把这版的更新说明写在 CHANGELOG.md 的 "## Unreleased" 下面,并提交
|
|
154
|
+
# (npm version 遇到已跟踪文件有未提交改动会直接拒绝)
|
|
155
|
+
# 2. 升版本号(patch / minor / major)
|
|
156
|
+
npm version patch
|
|
145
157
|
# 3. 提交和 tag 一起推上去,剩下的交给 CI
|
|
146
158
|
git push --follow-tags
|
|
147
159
|
```
|
|
148
160
|
|
|
161
|
+
`npm version` 一上来先检查 git 工作区是否干净,不干净就报 `Git working directory not clean.` 退出,所以 `CHANGELOG.md` 在内的所有改动都要先提交(未跟踪的文件不影响)。然后才跑 `package.json` 里声明的钩子:`preversion` 先确认 `CHANGELOG.md` 里有非空的 `## Unreleased` 段落,再跑 `npm run check`,任一不通过就在改动任何文件之前中止;`version` 跑 `scripts/release-changelog.mjs`,把 `## Unreleased` 改成新版本号并 `git add`。然后 npm 把 `package.json`、lockfile 和 `CHANGELOG.md` 一起提交成 `chore: release X.Y.Z`(提交信息定义在 `.npmrc`),并打上 `vX.Y.Z` 的 tag。
|
|
162
|
+
|
|
149
163
|
## 📄 许可证
|
|
150
164
|
|
|
151
165
|
MIT,见 [`LICENSE`](./LICENSE)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-session-peek",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Pi extension: search session history in a two-pane picker with full conversation preview, then resume or fork.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
"test": "node --import tsx --test \"test/**/*.test.ts\"",
|
|
41
41
|
"check": "npm run typecheck && npm test",
|
|
42
42
|
"screenshot": "node --import tsx scripts/screenshot.mjs",
|
|
43
|
+
"preversion": "node scripts/release-changelog.mjs --check && npm run check",
|
|
44
|
+
"version": "node scripts/release-changelog.mjs",
|
|
43
45
|
"prepublishOnly": "npm run check"
|
|
44
46
|
},
|
|
45
47
|
"peerDependencies": {
|
package/src/i18n.ts
CHANGED
|
@@ -14,7 +14,7 @@ const zh = {
|
|
|
14
14
|
forked: "已分叉为新会话 {file}",
|
|
15
15
|
forkFailed: "分叉失败:{error}",
|
|
16
16
|
switchCancelled: "切换会话已取消",
|
|
17
|
-
searchPlaceholder: "
|
|
17
|
+
searchPlaceholder: "过滤:空格=都要命中 \"短语\" a|b -排除 name:/dir:/user:/ai: /正则/ @7d",
|
|
18
18
|
renamePlaceholder: "新会话名(Enter 确认 / Esc 取消,留空取消)",
|
|
19
19
|
title: "🔍 会话搜索预览",
|
|
20
20
|
scopeLabel: " 范围[Tab]: ",
|
|
@@ -32,7 +32,7 @@ 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
36
|
since: "近{n}{unit}",
|
|
37
37
|
unitH: "小时",
|
|
38
38
|
unitD: "天",
|
|
@@ -54,7 +54,7 @@ const en: Record<keyof typeof zh, string> = {
|
|
|
54
54
|
forked: "Forked into new session {file}",
|
|
55
55
|
forkFailed: "Fork failed: {error}",
|
|
56
56
|
switchCancelled: "Session switch cancelled",
|
|
57
|
-
searchPlaceholder: "Filter
|
|
57
|
+
searchPlaceholder: "Filter: space=AND \"phrase\" a|b -exclude name:/dir:/user:/ai: /regex/ @7d",
|
|
58
58
|
renamePlaceholder: "New session name (Enter to confirm, Esc to cancel, empty = cancel)",
|
|
59
59
|
title: "🔍 Session search",
|
|
60
60
|
scopeLabel: " Scope[Tab]: ",
|
|
@@ -72,7 +72,7 @@ const en: Record<keyof typeof zh, string> = {
|
|
|
72
72
|
ai: "🤖 AI",
|
|
73
73
|
renamePrefix: "✏ Rename: ",
|
|
74
74
|
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",
|
|
75
|
+
footer: "↑↓ select PgUp/Dn·^U/^F·⇧↑↓ scroll ^N/^P hits ^T tools Tab scope ^D del ^R rename ^O fork Enter open Esc close",
|
|
76
76
|
since: "last {n}{unit}",
|
|
77
77
|
unitH: "h",
|
|
78
78
|
unitD: "d",
|
package/src/peek-component.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type MarkdownTheme,
|
|
11
11
|
} from "@earendil-works/pi-tui";
|
|
12
12
|
import { msg } from "./i18n.ts";
|
|
13
|
-
import {
|
|
13
|
+
import { anyMatch, highlight, lineHasMatch, matchesSession, parseQuery, roleTerms, snippet, type ParsedQuery } from "./query.ts";
|
|
14
14
|
import type { PeekMsg, PeekSession } from "./sessions.ts";
|
|
15
15
|
import { fmtTime, normPath, padEndVisible } from "./text.ts";
|
|
16
16
|
|
|
@@ -23,7 +23,8 @@ export class PeekComponent implements Component, Focusable {
|
|
|
23
23
|
private selected = 0;
|
|
24
24
|
private listOffset = 0;
|
|
25
25
|
private previewOffset = 0;
|
|
26
|
-
private previewKey = ""; // 预览缓存的键(会话 + 关键词 +
|
|
26
|
+
private previewKey = ""; // 预览缓存的键(会话 + 关键词 + 宽度 + 工具开关),置空强制重建
|
|
27
|
+
private showTools = false; // Ctrl+T:预览里显示每次工具调用的一行摘要
|
|
27
28
|
private previewLines: string[] = [];
|
|
28
29
|
private matchLines: number[] = []; // 预览里含关键词的行(升序),Ctrl+N / Ctrl+P 用
|
|
29
30
|
private rendered = new WeakMap<PeekMsg, { w: number; lines: string[] }>(); // 每条消息渲染好的行,按宽度缓存
|
|
@@ -86,14 +87,11 @@ export class PeekComponent implements Component, Focusable {
|
|
|
86
87
|
// keepSelection:尽量保持原来选中的会话,重命名后用
|
|
87
88
|
private refilter(keepSelection = false): void {
|
|
88
89
|
const prev = keepSelection ? this.filtered[this.selected] : undefined;
|
|
89
|
-
const {
|
|
90
|
+
const { terms, since } = this.query();
|
|
90
91
|
let out = this.all;
|
|
91
92
|
if (this.scope === "current") out = out.filter((s) => this.inCurrentTree(s));
|
|
92
93
|
if (since) out = out.filter((s) => s.mtime >= since);
|
|
93
|
-
if (
|
|
94
|
-
const res = kwRegExps(kws);
|
|
95
|
-
out = out.filter((s) => matchesSession(s, res));
|
|
96
|
-
}
|
|
94
|
+
if (terms.length) out = out.filter((s) => matchesSession(s, terms));
|
|
97
95
|
this.filtered = out;
|
|
98
96
|
const idx = prev ? out.indexOf(prev) : -1;
|
|
99
97
|
this.selected = idx >= 0 ? idx : keepSelection ? Math.min(this.selected, Math.max(0, out.length - 1)) : 0;
|
|
@@ -227,6 +225,12 @@ export class PeekComponent implements Component, Focusable {
|
|
|
227
225
|
this.invalidate();
|
|
228
226
|
return;
|
|
229
227
|
}
|
|
228
|
+
if (matchesKey(data, Key.ctrl("t"))) {
|
|
229
|
+
this.showTools = !this.showTools;
|
|
230
|
+
this.previewKey = "";
|
|
231
|
+
this.invalidate();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
230
234
|
if (matchesKey(data, Key.ctrl("n"))) {
|
|
231
235
|
this.jumpMatch(1);
|
|
232
236
|
return;
|
|
@@ -274,7 +278,8 @@ export class PeekComponent implements Component, Focusable {
|
|
|
274
278
|
// 左栏,每个会话两行
|
|
275
279
|
private buildList(height: number, lw: number): string[] {
|
|
276
280
|
const t = this.theme;
|
|
277
|
-
const
|
|
281
|
+
const rt = roleTerms(this.query().terms); // 排除词和 name: / cwd: 不在正文里高亮
|
|
282
|
+
const hasBody = rt.user.length > 0 || rt.assistant.length > 0;
|
|
278
283
|
const visible = Math.max(1, Math.floor(height / 2));
|
|
279
284
|
if (this.selected < this.listOffset) this.listOffset = this.selected;
|
|
280
285
|
if (this.selected >= this.listOffset + visible) {
|
|
@@ -292,15 +297,15 @@ export class PeekComponent implements Component, Focusable {
|
|
|
292
297
|
const sel = idx === this.selected;
|
|
293
298
|
const time = fmtTime(s.time);
|
|
294
299
|
const cwdTail = s.cwd.replace(/\\/g, "/").split("/").slice(-2).join("/");
|
|
295
|
-
//
|
|
296
|
-
const hitBadge =
|
|
297
|
-
? ` ·${s.msgs.reduce((n, m) => n + (
|
|
300
|
+
// 有正文关键词时显示命中的消息数
|
|
301
|
+
const hitBadge = hasBody
|
|
302
|
+
? ` ·${s.msgs.reduce((n, m) => n + (anyMatch(m.text, rt[m.role]) ? 1 : 0), 0)}`
|
|
298
303
|
: "";
|
|
299
304
|
const l1 = `${sel ? "›" : " "} ${time} ${cwdTail}${hitBadge}`;
|
|
300
|
-
//
|
|
301
|
-
const snip = snippet(s.msgs,
|
|
305
|
+
// 第二行:有正文关键词时显示命中片段,否则显示首条消息
|
|
306
|
+
const snip = hasBody ? snippet(s.msgs, rt, lw) : undefined;
|
|
302
307
|
const l2 = snip !== undefined
|
|
303
|
-
? ` ${highlight(snip,
|
|
308
|
+
? ` ${highlight(snip.text, rt[snip.role], t)}`
|
|
304
309
|
: ` ${s.first}${s.name ? ` [${s.name}]` : ""}`;
|
|
305
310
|
if (sel) {
|
|
306
311
|
rows.push(t.bg("selectedBg", padEndVisible(t.fg("accent", truncateToWidth(l1, lw)), lw)));
|
|
@@ -341,7 +346,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
341
346
|
private buildPreview(rw: number): void {
|
|
342
347
|
const t = this.theme;
|
|
343
348
|
const s = this.filtered[this.selected];
|
|
344
|
-
const key = s ? `${s.path}|${this.getQuery()}|${rw}` : "none";
|
|
349
|
+
const key = s ? `${s.path}|${this.getQuery()}|${rw}|${this.showTools ? "t" : ""}` : "none";
|
|
345
350
|
if (key === this.previewKey) return;
|
|
346
351
|
this.previewKey = key;
|
|
347
352
|
this.matchLines = [];
|
|
@@ -353,15 +358,18 @@ export class PeekComponent implements Component, Focusable {
|
|
|
353
358
|
}
|
|
354
359
|
|
|
355
360
|
const lines: string[] = [];
|
|
361
|
+
// 消息数只算有文字的,纯工具轮次不计
|
|
362
|
+
const textCount = s.msgs.reduce((n, m) => n + (m.text ? 1 : 0), 0);
|
|
356
363
|
lines.push(
|
|
357
364
|
truncateToWidth(
|
|
358
|
-
t.fg("dim", `${s.cwd} • ${msg("msgCount", { n:
|
|
365
|
+
t.fg("dim", `${s.cwd} • ${msg("msgCount", { n: textCount })}${s.name ? " • " + s.name : ""}`),
|
|
359
366
|
rw,
|
|
360
367
|
),
|
|
361
368
|
);
|
|
362
369
|
lines.push("");
|
|
363
370
|
|
|
364
|
-
const
|
|
371
|
+
const rt = roleTerms(this.query().terms);
|
|
372
|
+
const hasBody = rt.user.length > 0 || rt.assistant.length > 0;
|
|
365
373
|
const MAX_MSGS = 500;
|
|
366
374
|
const truncated = s.msgs.length > MAX_MSGS;
|
|
367
375
|
const msgs = truncated ? s.msgs.slice(-MAX_MSGS) : s.msgs;
|
|
@@ -369,8 +377,8 @@ export class PeekComponent implements Component, Focusable {
|
|
|
369
377
|
lines.push(t.fg("dim", msg("truncated", { n: MAX_MSGS })));
|
|
370
378
|
lines.push("");
|
|
371
379
|
}
|
|
372
|
-
if (
|
|
373
|
-
const inTruncated = truncated && s.msgs.some((m) =>
|
|
380
|
+
if (hasBody && !msgs.some((m) => anyMatch(m.text, rt[m.role]))) {
|
|
381
|
+
const inTruncated = truncated && s.msgs.some((m) => anyMatch(m.text, rt[m.role]));
|
|
374
382
|
lines.push(
|
|
375
383
|
t.fg(
|
|
376
384
|
"warning",
|
|
@@ -386,25 +394,39 @@ export class PeekComponent implements Component, Focusable {
|
|
|
386
394
|
return truncateToWidth(label + " " + "─".repeat(Math.max(2, rw - w - 1)), rw);
|
|
387
395
|
};
|
|
388
396
|
|
|
397
|
+
let prevRole: PeekMsg["role"] | undefined; // 上一条画出来的消息的角色
|
|
389
398
|
for (const m of msgs) {
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
399
|
+
const tools = this.showTools ? (m.tools ?? []) : [];
|
|
400
|
+
if (!m.text && !tools.length) continue; // 工具隐藏时,只有工具调用的 AI 轮次整条跳过
|
|
401
|
+
const terms = rt[m.role]; // user: 的词只在用户消息里高亮,ai: 的只在 AI 回复里
|
|
402
|
+
const isMatch = terms.length > 0 && anyMatch(m.text, terms);
|
|
403
|
+
// 紧跟在 AI 文字后面、只有工具调用的轮次不重复画标签,接在上一条正文下面,看起来是同一轮
|
|
404
|
+
const continues = m.role === "assistant" && prevRole === "assistant" && !m.text;
|
|
405
|
+
if (continues) {
|
|
406
|
+
lines.pop();
|
|
407
|
+
} else if (m.role === "user") {
|
|
393
408
|
lines.push(
|
|
394
409
|
t.bg("userMessageBg", padEndVisible(t.bold(t.fg("accent", fillLabel(msg("you")))), rw)),
|
|
395
410
|
);
|
|
396
411
|
} else {
|
|
397
412
|
lines.push(t.fg("muted", fillLabel(msg("ai"))));
|
|
398
413
|
}
|
|
414
|
+
const head = lines.length - 1;
|
|
399
415
|
// 先渲染再高亮:往 markdown 源码里插转义码会把链接、代码块的语法弄坏
|
|
400
416
|
const before = this.matchLines.length;
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
417
|
+
if (m.text) {
|
|
418
|
+
for (const l of this.renderMsg(m, rw)) {
|
|
419
|
+
if (isMatch && lineHasMatch(l, terms)) this.matchLines.push(lines.length);
|
|
420
|
+
lines.push(isMatch ? highlight(l, terms, t) : l);
|
|
421
|
+
}
|
|
404
422
|
}
|
|
405
423
|
// 关键词被折行拆开时哪一行都找不到,退回到消息标题行,别把这条消息漏掉
|
|
406
424
|
if (isMatch && this.matchLines.length === before) this.matchLines.push(head);
|
|
425
|
+
for (const tool of tools) {
|
|
426
|
+
lines.push(t.fg("dim", truncateToWidth(` ⚙ ${tool.name}${tool.summary ? " " + tool.summary : ""}`, rw)));
|
|
427
|
+
}
|
|
407
428
|
lines.push("");
|
|
429
|
+
prevRole = m.role;
|
|
408
430
|
}
|
|
409
431
|
|
|
410
432
|
this.previewLines = lines;
|
package/src/query.ts
CHANGED
|
@@ -1,63 +1,165 @@
|
|
|
1
1
|
import { msg, type MsgKey } from "./i18n.ts";
|
|
2
2
|
import type { PeekMsg, PeekSession } from "./sessions.ts";
|
|
3
3
|
|
|
4
|
-
//
|
|
4
|
+
// 搜索框语法。空白分隔多个词,全部满足才算(AND)。每个词可以是:
|
|
5
|
+
// foo 子串,大小写不敏感
|
|
6
|
+
// "foo bar" 短语,引号里的一段空白匹配正文里任意一段空白(含换行)
|
|
7
|
+
// a|b 任一命中(OR);要搜字面的 | 就加引号
|
|
8
|
+
// /re/ 正则(JS 语法,自动带 i 和 m 标志,不能含空白,用 \s 代替);写错了按普通文字搜
|
|
9
|
+
// -foo 排除:命中的会话不显示。--foo 还是普通词,要搜以 - 开头的词就加引号
|
|
10
|
+
// name:foo 只在会话名里找;cwd: / dir: 只看工作目录;user: / ai:(assistant:)只看对应角色的消息
|
|
11
|
+
// @7d 只看最近 7 天活动过的会话,单位 h / d / w / m
|
|
12
|
+
// 前缀可以叠:-user:"foo bar"。引号是万能转义:"-foo"、"a|b"、"name:x"、"/x/" 都按字面搜
|
|
13
|
+
|
|
14
|
+
export type Field = "any" | "name" | "cwd" | "user" | "assistant";
|
|
15
|
+
|
|
16
|
+
export interface Term {
|
|
17
|
+
re: RegExp; // 带 g 和 i,直接在原文上匹配(不再把正文转小写:个别字符小写后长度会变,高亮位置就对不上);用之前先把 lastIndex 归零
|
|
18
|
+
negate: boolean;
|
|
19
|
+
field: Field;
|
|
20
|
+
}
|
|
5
21
|
|
|
6
22
|
export interface ParsedQuery {
|
|
7
|
-
|
|
23
|
+
terms: Term[];
|
|
8
24
|
since: number; // 毫秒时间戳,0 表示不限
|
|
9
25
|
sinceLabel: string;
|
|
10
26
|
}
|
|
11
27
|
|
|
12
28
|
const TIME_UNIT_MS: Record<string, number> = { h: 3600e3, d: 86400e3, w: 7 * 86400e3, m: 30 * 86400e3 };
|
|
13
29
|
const TIME_UNIT_LABEL: Record<string, MsgKey> = { h: "unitH", d: "unitD", w: "unitW", m: "unitM" };
|
|
30
|
+
const FIELD_ALIAS: Record<string, Field> = { name: "name", cwd: "cwd", dir: "cwd", user: "user", ai: "assistant", assistant: "assistant" };
|
|
31
|
+
|
|
32
|
+
// 按空白切分;带引号的词(前面可以有 - 和 name: 之类的前缀)里的空白不切,引号不闭合就一直到结尾。
|
|
33
|
+
// 引号只在词首算,foo"bar 是普通词
|
|
34
|
+
const TOKEN_RE = /-?(?:(?:name|cwd|dir|user|ai|assistant):)?"[^"]*"?|\S+/gi;
|
|
35
|
+
const FIELD_RE = /^(name|cwd|dir|user|ai|assistant):/i;
|
|
36
|
+
|
|
37
|
+
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
38
|
+
|
|
39
|
+
// 单个词 → Term;空词(只剩前缀、空引号)返回 undefined。单个 - 是普通词
|
|
40
|
+
function parseTerm(raw: string): Term | undefined {
|
|
41
|
+
let s = raw;
|
|
42
|
+
let negate = false;
|
|
43
|
+
if (s.length > 1 && s[0] === "-" && s[1] !== "-") {
|
|
44
|
+
negate = true;
|
|
45
|
+
s = s.slice(1);
|
|
46
|
+
}
|
|
47
|
+
let field: Field = "any";
|
|
48
|
+
const f = FIELD_RE.exec(s);
|
|
49
|
+
if (f) {
|
|
50
|
+
field = FIELD_ALIAS[f[1].toLowerCase()];
|
|
51
|
+
s = s.slice(f[0].length);
|
|
52
|
+
}
|
|
53
|
+
let re: RegExp;
|
|
54
|
+
if (s.startsWith('"')) {
|
|
55
|
+
const body = (s.length > 1 && s.endsWith('"') ? s.slice(1, -1) : s.slice(1)).trim();
|
|
56
|
+
if (!body) return undefined;
|
|
57
|
+
re = new RegExp(escapeRe(body.toLowerCase()).replace(/\s+/g, "\\s+"), "gi");
|
|
58
|
+
} else if (s.length >= 3 && s.startsWith("/") && s.endsWith("/")) {
|
|
59
|
+
try {
|
|
60
|
+
re = new RegExp(s.slice(1, -1), "gim");
|
|
61
|
+
} catch {
|
|
62
|
+
re = new RegExp(escapeRe(s.toLowerCase()), "gi");
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
// 长的放前面:a|ab 在同一位置时高亮取长的那个,和多个词的行为一致
|
|
66
|
+
const parts = s.toLowerCase().split("|").filter(Boolean).sort((a, b) => b.length - a.length);
|
|
67
|
+
if (!parts.length) return undefined;
|
|
68
|
+
re = new RegExp(parts.map(escapeRe).join("|"), "gi");
|
|
69
|
+
}
|
|
70
|
+
return { re, negate, field };
|
|
71
|
+
}
|
|
14
72
|
|
|
15
|
-
// "foo bar @7d" →
|
|
73
|
+
// "foo bar @7d" → terms = [foo, bar],since = 7 天前
|
|
16
74
|
export function parseQuery(q: string): ParsedQuery {
|
|
17
|
-
const
|
|
75
|
+
const terms: Term[] = [];
|
|
76
|
+
const seen = new Set<string>();
|
|
18
77
|
let since = 0;
|
|
19
78
|
let sinceLabel = "";
|
|
20
|
-
for (const tok of q.
|
|
21
|
-
|
|
22
|
-
const m = /^@(\d+)([hdwm])$/.exec(tok);
|
|
79
|
+
for (const tok of q.match(TOKEN_RE) ?? []) {
|
|
80
|
+
const m = /^@(\d+)([hdwm])$/i.exec(tok);
|
|
23
81
|
if (m) {
|
|
24
82
|
const n = Number(m[1]);
|
|
83
|
+
const unit = m[2].toLowerCase();
|
|
25
84
|
if (n > 0) {
|
|
26
|
-
since = Date.now() - n * TIME_UNIT_MS[
|
|
27
|
-
sinceLabel = msg("since", { n, unit: msg(TIME_UNIT_LABEL[
|
|
85
|
+
since = Date.now() - n * TIME_UNIT_MS[unit];
|
|
86
|
+
sinceLabel = msg("since", { n, unit: msg(TIME_UNIT_LABEL[unit]) });
|
|
28
87
|
}
|
|
29
88
|
continue;
|
|
30
89
|
}
|
|
31
|
-
|
|
90
|
+
const t = parseTerm(tok);
|
|
91
|
+
if (!t) continue;
|
|
92
|
+
const key = `${t.negate ? "-" : "+"}${t.field}:${t.re.source}`; // 同一个词打两遍只算一次(普通词已转小写,正则原样)
|
|
93
|
+
if (seen.has(key)) continue;
|
|
94
|
+
seen.add(key);
|
|
95
|
+
terms.push(t);
|
|
32
96
|
}
|
|
33
|
-
return {
|
|
97
|
+
return { terms, since, sinceLabel };
|
|
34
98
|
}
|
|
35
99
|
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
100
|
+
// 从 from 起找第一个非空命中。g 标志的正则复用前要设 lastIndex,否则从上次命中的位置接着找;
|
|
101
|
+
// 空匹配(如 /a*/ 对上空串)跳过:既不算命中,也免得高亮循环原地打转
|
|
102
|
+
function execFrom(re: RegExp, s: string, from: number): RegExpExecArray | null {
|
|
103
|
+
re.lastIndex = from;
|
|
104
|
+
let m = re.exec(s);
|
|
105
|
+
while (m && !m[0].length) {
|
|
106
|
+
if (m.index >= s.length) return null;
|
|
107
|
+
re.lastIndex = m.index + 1;
|
|
108
|
+
m = re.exec(s);
|
|
109
|
+
}
|
|
110
|
+
return m;
|
|
39
111
|
}
|
|
40
112
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return res.every((re) => re.test(s.name) || re.test(s.cwd) || s.msgs.some((m) => re.test(m.text)));
|
|
113
|
+
function hit(re: RegExp, s: string): boolean {
|
|
114
|
+
return execFrom(re, s, 0) !== null;
|
|
44
115
|
}
|
|
45
116
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
117
|
+
function termHits(s: PeekSession, t: Term): boolean {
|
|
118
|
+
switch (t.field) {
|
|
119
|
+
case "name":
|
|
120
|
+
return hit(t.re, s.name);
|
|
121
|
+
case "cwd":
|
|
122
|
+
return hit(t.re, s.cwd);
|
|
123
|
+
case "user":
|
|
124
|
+
case "assistant":
|
|
125
|
+
return s.msgs.some((m) => m.role === t.field && hit(t.re, m.text));
|
|
126
|
+
default:
|
|
127
|
+
return hit(t.re, s.name) || hit(t.re, s.cwd) || s.msgs.some((m) => hit(t.re, m.text));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 每个词都要满足:普通词在会话名、cwd 或某条消息里出现,排除词则哪里都不出现;带前缀的只看对应的地方。
|
|
132
|
+
// 正文不预存小写副本,直接在原文上用不区分大小写的正则匹配
|
|
133
|
+
export function matchesSession(s: PeekSession, terms: Term[]): boolean {
|
|
134
|
+
return terms.every((t) => termHits(s, t) !== t.negate);
|
|
50
135
|
}
|
|
51
136
|
|
|
52
|
-
//
|
|
53
|
-
function
|
|
137
|
+
// 单段文字用:命中任意一个词就算
|
|
138
|
+
export function anyMatch(text: string, terms: Term[]): boolean {
|
|
139
|
+
return terms.some((t) => hit(t.re, text));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface RoleTerms {
|
|
143
|
+
user: Term[];
|
|
144
|
+
assistant: Term[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 正文里要高亮、计命中的词,按消息角色分开:排除词不算,name: / cwd: 不算,user: / ai: 只给对应角色
|
|
148
|
+
export function roleTerms(terms: Term[]): RoleTerms {
|
|
149
|
+
const pick = (role: PeekMsg["role"]) => terms.filter((t) => !t.negate && (t.field === "any" || t.field === role));
|
|
150
|
+
return { user: pick("user"), assistant: pick("assistant") };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 从 from 起最早的命中 [位置, 长度];同一位置取最长的
|
|
154
|
+
function nextMatch(text: string, terms: Term[], from: number): [number, number] {
|
|
54
155
|
let best = -1;
|
|
55
156
|
let len = 0;
|
|
56
|
-
for (const
|
|
57
|
-
const
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
157
|
+
for (const t of terms) {
|
|
158
|
+
const m = execFrom(t.re, text, from);
|
|
159
|
+
if (!m) continue;
|
|
160
|
+
if (best === -1 || m.index < best || (m.index === best && m[0].length > len)) {
|
|
161
|
+
best = m.index;
|
|
162
|
+
len = m[0].length;
|
|
61
163
|
}
|
|
62
164
|
}
|
|
63
165
|
return [best, len];
|
|
@@ -106,17 +208,17 @@ function applySgr(st: Sgr, seq: string): void {
|
|
|
106
208
|
}
|
|
107
209
|
}
|
|
108
210
|
|
|
109
|
-
// 渲染后的一行(可带 ANSI
|
|
110
|
-
export function
|
|
111
|
-
return
|
|
211
|
+
// 渲染后的一行(可带 ANSI)里有没有命中,预览按行记命中用
|
|
212
|
+
export function lineHasMatch(line: string, terms: Term[]): boolean {
|
|
213
|
+
return terms.length > 0 && anyMatch(line.replace(ESC_RE, ""), terms);
|
|
112
214
|
}
|
|
113
215
|
|
|
114
|
-
//
|
|
216
|
+
// 在一行文字里高亮命中,返回带 ANSI 的字符串。
|
|
115
217
|
// 输入可以是已经渲染过、带样式的行:转义序列不参与匹配也不会被切断;高亮结束时把原来的
|
|
116
218
|
// 前景色 / 粗体 / 下划线恢复回去,所以放在标题、链接等样式里面也不会把后面的文字弄丢样式。
|
|
117
219
|
// 不用主题自带的 searchMatchBg:dark/light 主题里它和 selectedBg 同色,看不出来
|
|
118
|
-
export function highlight(line: string,
|
|
119
|
-
if (!
|
|
220
|
+
export function highlight(line: string, terms: Term[], theme: any): string {
|
|
221
|
+
if (!terms.length || !line) return line;
|
|
120
222
|
|
|
121
223
|
// 拆成可见文本和转义序列两类片段
|
|
122
224
|
const segs: { text: string; esc: boolean }[] = [];
|
|
@@ -130,12 +232,10 @@ export function highlight(line: string, kws: string[], theme: any): string {
|
|
|
130
232
|
|
|
131
233
|
let visible = "";
|
|
132
234
|
for (const s of segs) if (!s.esc) visible += s.text;
|
|
133
|
-
const lower = visible.toLowerCase();
|
|
134
|
-
if (lower.length !== visible.length) return line; // 个别字符小写后长度会变,位置对不上就不高亮
|
|
135
235
|
|
|
136
236
|
const ranges: [number, number][] = [];
|
|
137
237
|
for (let i = 0; ; ) {
|
|
138
|
-
const [j, len] = nextMatch(
|
|
238
|
+
const [j, len] = nextMatch(visible, terms, i);
|
|
139
239
|
if (j === -1) break;
|
|
140
240
|
ranges.push([j, j + len]);
|
|
141
241
|
i = j + len;
|
|
@@ -188,15 +288,22 @@ export function highlight(line: string, kws: string[], theme: any): string {
|
|
|
188
288
|
return out;
|
|
189
289
|
}
|
|
190
290
|
|
|
291
|
+
export interface Snippet {
|
|
292
|
+
text: string;
|
|
293
|
+
role: PeekMsg["role"]; // 片段来自哪个角色的消息,高亮时用该角色的词
|
|
294
|
+
}
|
|
295
|
+
|
|
191
296
|
// 左栏用:第一个命中处前后截一段。没有消息命中时返回 undefined,调用方退回首条消息
|
|
192
|
-
export function snippet(msgs: PeekMsg[],
|
|
193
|
-
if (!kws.length) return undefined;
|
|
297
|
+
export function snippet(msgs: PeekMsg[], rt: RoleTerms, width: number): Snippet | undefined {
|
|
194
298
|
for (const m of msgs) {
|
|
195
|
-
const
|
|
196
|
-
|
|
299
|
+
const terms = rt[m.role];
|
|
300
|
+
if (!terms.length) continue;
|
|
301
|
+
// 在原文上找命中,正则的 ^ / $ 才能和列表过滤时对得上;截出来的那段再把换行和连续空白压成一个空格
|
|
302
|
+
const [j] = nextMatch(m.text, terms, 0);
|
|
197
303
|
if (j === -1) continue;
|
|
198
304
|
const start = Math.max(0, j - Math.floor(width / 3));
|
|
199
|
-
|
|
305
|
+
const flat = m.text.slice(start, j + width).replace(/\s+/g, " ");
|
|
306
|
+
return { text: (start > 0 ? "…" : "") + flat.slice(0, width), role: m.role };
|
|
200
307
|
}
|
|
201
308
|
return undefined;
|
|
202
309
|
}
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
130
|
+
first: firstText.text.replace(/\s+/g, " ").slice(0, 80),
|
|
100
131
|
};
|
|
101
132
|
}
|
|
102
133
|
|