pi-session-peek 0.5.1 → 0.6.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 +15 -0
- package/README.md +19 -9
- package/README.zh-CN.md +19 -9
- package/package.json +1 -1
- package/src/ansi.ts +73 -0
- package/src/drag-select.ts +119 -0
- package/src/i18n.ts +4 -4
- package/src/layout.ts +18 -0
- package/src/list.ts +64 -0
- package/src/peek-component.ts +101 -303
- package/src/preview.ts +139 -0
- package/src/query.ts +16 -61
- package/src/selection.ts +7 -11
- package/src/sessions.ts +1 -5
- package/src/theme.ts +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
- Search: plain terms match the working directory again, but only in the all-projects scope. The current-directory scope keeps the 0.5.2 behaviour (conversation and session name only), since every session there shares the same path prefix.
|
|
6
|
+
搜索:普通词重新支持匹配工作目录,但仅在「全部项目」范围下生效;当前目录树范围保持 0.5.2 的行为(只匹配对话和会话名),因为该范围下所有会话共享同一段路径前缀。
|
|
7
|
+
- `Ctrl+Y` replaces `Ctrl+F` for scrolling the preview down half a page. In pi's fullscreen mode `Ctrl+F` is captured by pi's own transcript search on Windows/WSL before the picker ever sees it.
|
|
8
|
+
预览向下翻半页由 `Ctrl+F` 改为 `Ctrl+Y`:pi 全屏模式下 `Ctrl+F` 会被 pi 自己的全文搜索(Windows/WSL)先行截获,选择器根本收不到。
|
|
9
|
+
- The picker now declares `capturesViewportInput`, so in pi's fullscreen mode `PgUp` / `PgDn` reach the picker instead of scrolling pi's transcript.
|
|
10
|
+
选择器现在声明了 `capturesViewportInput`,pi 全屏模式下 `PgUp` / `PgDn` 会送达选择器,不再被 pi 截去滚动主对话记录。
|
|
11
|
+
|
|
12
|
+
## 0.5.2
|
|
13
|
+
|
|
14
|
+
- Wheel over the list now scrolls the list like a scrollbar instead of moving the selection. The selection stays where it is and comes back into view on `↑` / `↓`, a click, a new filter or a delete.
|
|
15
|
+
- Search: plain terms no longer match the working directory, only the conversation and the session name. With the current-directory scope every session shares the same path prefix, so a word from the path used to match all of them. Use `dir:` (or `cwd:`) to search paths.
|
|
16
|
+
- Internal: the picker component is split into `preview`, `list` and `drag-select` modules, escape-sequence helpers are consolidated in `ansi.ts`, and the theme is typed against pi's `Theme`. No behaviour change.
|
|
17
|
+
|
|
3
18
|
## 0.5.1
|
|
4
19
|
|
|
5
20
|
- Terminal escape sequences and control characters in message text, tool arguments and session names are stripped when parsing, so a pasted colour dump or an OSC sequence in a reply can no longer break the preview layout or reach the terminal.
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ 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
|
|
15
|
+
- Filters as you type across conversation text and session name. The working directory is searched only with an explicit `dir:`, so a word from a project path does not match every session in that project. 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`.
|
|
@@ -64,7 +64,7 @@ In TUI mode, run `/peek` and start typing. Press `Enter` on a session to continu
|
|
|
64
64
|
| `Tab` | Current directory tree ↔ all projects |
|
|
65
65
|
| `↑` `↓` | Select session |
|
|
66
66
|
| `PgUp` `PgDn` | Scroll preview by a page |
|
|
67
|
-
| `Ctrl+U` `Ctrl+
|
|
67
|
+
| `Ctrl+U` `Ctrl+Y` | Scroll preview by half a page |
|
|
68
68
|
| `Shift+↑` `Shift+↓` | Scroll preview by three lines |
|
|
69
69
|
| `Ctrl+N` `Ctrl+P` | Next / previous hit line outside the current view; the top-right corner shows `hit k/n` |
|
|
70
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. |
|
|
@@ -80,7 +80,7 @@ In TUI mode, run `/peek` and start typing. Press `Enter` on a session to continu
|
|
|
80
80
|
| --- | --- |
|
|
81
81
|
| Click a session in the list | Select it |
|
|
82
82
|
| Double-click a session | Resume it (same as `Enter`) |
|
|
83
|
-
| Wheel over the list |
|
|
83
|
+
| Wheel over the list | Scroll the list like a scrollbar; the selection stays where it is. `↑` `↓`, a click or a new filter bring it back into view. |
|
|
84
84
|
| Wheel over the preview | Scroll the preview three lines per notch (`Alt` for five times faster) |
|
|
85
85
|
| Click the scope label in the header | Toggle current directory tree ↔ all projects (same as `Tab`) |
|
|
86
86
|
| Click the search box or the rename line | Move the cursor |
|
|
@@ -98,7 +98,7 @@ The filter is case-insensitive and matches anywhere in the text. Whitespace sepa
|
|
|
98
98
|
| `token undefined` | Sessions containing both `token` and `undefined`, in any order and any message |
|
|
99
99
|
| `"token undefined"` | The exact phrase. Whitespace inside the quotes matches any run of whitespace, including a line break. |
|
|
100
100
|
| `vue\|react` | Sessions containing `vue` or `react` |
|
|
101
|
-
| `-draft` | Sessions that do not contain `draft`
|
|
101
|
+
| `-draft` | Sessions that do not contain `draft` in any message or in the session name |
|
|
102
102
|
| `name:auth` | Session name only. `dir:` or `cwd:` for the working directory. |
|
|
103
103
|
| `user:deploy` | Only the messages you wrote. `ai:` or `assistant:` for the replies. |
|
|
104
104
|
| `/\bfoo\d+\b/` | JavaScript regular expression, case-insensitive, `^` and `$` match line boundaries. Use `\s` instead of a space. |
|
|
@@ -107,7 +107,7 @@ The filter is case-insensitive and matches anywhere in the text. Whitespace sepa
|
|
|
107
107
|
|
|
108
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.
|
|
109
109
|
|
|
110
|
-
The search index holds user and assistant messages
|
|
110
|
+
The search index holds user and assistant messages and the session name. Plain words also match the working directory, but only in the all-projects scope: with the current-directory scope every session shares the same path prefix, so a plain word from the path would match all of them; use `dir:` / `cwd:` to match the path in that scope. The index does not hold tool call arguments or tool results, and the tool lines shown by `Ctrl+T` are never matched or highlighted.
|
|
111
111
|
|
|
112
112
|
## 🌐 Language
|
|
113
113
|
|
|
@@ -134,13 +134,19 @@ On Windows PowerShell: `$env:PI_SESSION_PEEK_LANG = "en"` before starting pi. Th
|
|
|
134
134
|
|
|
135
135
|
```text
|
|
136
136
|
pi-session-peek/
|
|
137
|
-
├── index.ts # Registers /peek and the startup flags, injects delete / rename / fork
|
|
137
|
+
├── index.ts # Registers /peek and the startup flags, injects delete / rename / fork / copy
|
|
138
138
|
├── src/
|
|
139
|
-
│ ├── peek-component.ts # The two-pane
|
|
139
|
+
│ ├── peek-component.ts # The two-pane component: state, key and mouse routing, header / footer, pane composition
|
|
140
|
+
│ ├── preview.ts # Right pane: preview lines and hit lines for a session, initial offset, Ctrl+N/P targets
|
|
141
|
+
│ ├── list.ts # Left pane: two rows per session, viewport following
|
|
142
|
+
│ ├── drag-select.ts # Drag-selection state machine: press / drag / release / auto-scroll
|
|
143
|
+
│ ├── selection.ts # Selection geometry: ranges, inverse-video highlight, text extraction
|
|
144
|
+
│ ├── layout.ts # Pane widths and body height
|
|
140
145
|
│ ├── sessions.ts # Scans and parses session JSONL, rename / delete helpers
|
|
141
|
-
│ ├── query.ts # Query parsing, highlighting, hit snippets
|
|
146
|
+
│ ├── query.ts # Query parsing, matching, highlighting, hit snippets
|
|
142
147
|
│ ├── mouse.ts # Mouse reporting for pi's regular (non-fullscreen) mode
|
|
143
|
-
│ ├──
|
|
148
|
+
│ ├── ansi.ts # Escape-sequence tokenising, SGR tracking, input sanitising
|
|
149
|
+
│ ├── theme.ts # The subset of pi's Theme the picker uses
|
|
144
150
|
│ ├── i18n.ts # Chinese / English UI strings and locale detection
|
|
145
151
|
│ └── text.ts # Path, time and width helpers
|
|
146
152
|
├── scripts/screenshot.mjs # Optional: renders a synthetic screenshot from component output
|
|
@@ -177,6 +183,10 @@ git push --follow-tags
|
|
|
177
183
|
|
|
178
184
|
`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`.
|
|
179
185
|
|
|
186
|
+
## 🔗 Friend links
|
|
187
|
+
|
|
188
|
+
- [LinuxDo — sincere, friendly, united, professional](https://linux.do)
|
|
189
|
+
|
|
180
190
|
## 📄 License
|
|
181
191
|
|
|
182
192
|
MIT. See [`LICENSE`](./LICENSE).
|
package/README.zh-CN.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
- `/peek` 或 `/peek 关键词` 打开左右分栏的选择器:左边是会话列表,右边是完整对话。
|
|
14
14
|
- 右栏用 pi 自己的 Markdown 渲染器画,标题、带语法高亮的代码块、表格、列表和主界面里的对话一个样子。
|
|
15
|
-
-
|
|
15
|
+
- 边打字边过滤,搜的是对话正文和会话名。工作目录只在显式写 `dir:` 时才搜,项目路径里的词不会把整个项目的会话都命中。工具调用的参数和结果不进搜索索引,所以只会命中真正讨论过这个词的会话。
|
|
16
16
|
- 空格分隔多个关键词,全部命中才显示;列表里直接显示第一个命中处的片段。
|
|
17
17
|
- 支持 `"精确短语"`、`a|b` 任一命中、`-排除`、`name:` / `dir:` / `user:` / `ai:` 限定范围,以及 `/正则/`。
|
|
18
18
|
- `@7d`、`@24h`、`@2w`、`@1m` 只看最近活动过的会话。
|
|
@@ -64,7 +64,7 @@ pi 扩展以你的用户权限运行,只装信得过的包。
|
|
|
64
64
|
| `Tab` | 当前目录树 ↔ 全部项目 |
|
|
65
65
|
| `↑` `↓` | 选会话 |
|
|
66
66
|
| `PgUp` `PgDn` | 预览翻页 |
|
|
67
|
-
| `Ctrl+U` `Ctrl+
|
|
67
|
+
| `Ctrl+U` `Ctrl+Y` | 预览翻半页 |
|
|
68
68
|
| `Shift+↑` `Shift+↓` | 预览滚三行 |
|
|
69
69
|
| `Ctrl+N` `Ctrl+P` | 跳到当前屏幕外的下一处 / 上一处命中行,右上角显示「命中 k/n」 |
|
|
70
70
|
| `Ctrl+T` | 预览里显示 / 隐藏工具调用。每次调用一行:工具名和主参数(命令、路径、URL、查询词)。不显示结果。 |
|
|
@@ -80,7 +80,7 @@ pi 扩展以你的用户权限运行,只装信得过的包。
|
|
|
80
80
|
| --- | --- |
|
|
81
81
|
| 点列表里的会话 | 选中 |
|
|
82
82
|
| 双击会话 | 进入,和 `Enter` 一样 |
|
|
83
|
-
| 在列表上滚滚轮 |
|
|
83
|
+
| 在列表上滚滚轮 | 像滚动条一样滚列表,选中项不变;`↑` `↓`、点击或改过滤词会把选中项带回视野 |
|
|
84
84
|
| 在预览上滚滚轮 | 预览每格滚三行,按住 `Alt` 快五倍 |
|
|
85
85
|
| 点头部的范围字样 | 当前目录树 ↔ 全部项目,和 `Tab` 一样 |
|
|
86
86
|
| 点搜索框或改名行 | 移动光标 |
|
|
@@ -98,7 +98,7 @@ pi 扩展以你的用户权限运行,只装信得过的包。
|
|
|
98
98
|
| `token undefined` | 同时包含 `token` 和 `undefined` 的会话,顺序不限,可以在不同的消息里 |
|
|
99
99
|
| `"token undefined"` | 精确短语。引号里的空白可以匹配正文里的任意一段空白,包括换行 |
|
|
100
100
|
| `vue\|react` | 包含 `vue` 或 `react` 的会话 |
|
|
101
|
-
| `-draft` |
|
|
101
|
+
| `-draft` | 消息和会话名里都不含 `draft` 的会话 |
|
|
102
102
|
| `name:auth` | 只在会话名里找。`dir:` 或 `cwd:` 只看工作目录 |
|
|
103
103
|
| `user:deploy` | 只看你发的消息。`ai:` 或 `assistant:` 只看回复 |
|
|
104
104
|
| `/\bfoo\d+\b/` | JavaScript 正则,大小写不敏感,`^` 和 `$` 匹配行首行尾。空格用 `\s` 代替 |
|
|
@@ -107,7 +107,7 @@ pi 扩展以你的用户权限运行,只装信得过的包。
|
|
|
107
107
|
|
|
108
108
|
引号是万能转义:`"-foo"`、`"a|b"`、`"name:x"`、`"/x/"` 都按字面搜这些字符。正则写错了就当普通文字搜。预览里只高亮正向的词,`name:` / `dir:` 的词不碰对话正文,所以只输入 `name:auth` 时右栏显示完整对话、没有高亮。
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
索引里有用户和助手的消息和会话名。普通词在「全部项目」范围下也匹配工作目录;当前目录树范围下所有会话的路径都带同一段前缀,普通词一碰到路径就会命中全部会话,所以这个范围下不匹配路径,要搜路径用 `dir:` / `cwd:`。没有工具调用的参数和结果,`Ctrl+T` 显示的工具行也不参与匹配和高亮。
|
|
111
111
|
|
|
112
112
|
## 🌐 界面语言
|
|
113
113
|
|
|
@@ -134,13 +134,19 @@ Windows PowerShell 里先执行 `$env:PI_SESSION_PEEK_LANG = "en"` 再启动 pi
|
|
|
134
134
|
|
|
135
135
|
```text
|
|
136
136
|
pi-session-peek/
|
|
137
|
-
├── index.ts # 注册 /peek 和启动参数,注入删除 / 重命名 / 分叉
|
|
137
|
+
├── index.ts # 注册 /peek 和启动参数,注入删除 / 重命名 / 分叉 / 复制
|
|
138
138
|
├── src/
|
|
139
|
-
│ ├── peek-component.ts #
|
|
139
|
+
│ ├── peek-component.ts # 双栏组件:状态、按键和鼠标路由、头尾、双栏拼接
|
|
140
|
+
│ ├── preview.ts # 右栏:按会话生成预览行和命中行、初始滚动位置、Ctrl+N/P 跳转目标
|
|
141
|
+
│ ├── list.ts # 左栏:每个会话两行、视口跟随
|
|
142
|
+
│ ├── drag-select.ts # 拖选状态机:按下 / 拖动 / 松开 / 自动滚动
|
|
143
|
+
│ ├── selection.ts # 选区几何:区间、反显、取文字
|
|
144
|
+
│ ├── layout.ts # 分栏宽度和主体高度
|
|
140
145
|
│ ├── sessions.ts # 扫描解析会话 JSONL,重命名 / 删除
|
|
141
|
-
│ ├── query.ts #
|
|
146
|
+
│ ├── query.ts # 查询解析、匹配、高亮、命中片段
|
|
142
147
|
│ ├── mouse.ts # 常规(非全屏)模式下的鼠标上报
|
|
143
|
-
│ ├──
|
|
148
|
+
│ ├── ansi.ts # 转义序列分词、SGR 跟踪、输入净化
|
|
149
|
+
│ ├── theme.ts # 选择器用到的 pi Theme 子集
|
|
144
150
|
│ ├── i18n.ts # 中英文界面文案和语言检测
|
|
145
151
|
│ └── text.ts # 路径、时间、宽度工具
|
|
146
152
|
├── scripts/screenshot.mjs # 可选:用组件渲染输出生成一张合成截图
|
|
@@ -177,6 +183,10 @@ git push --follow-tags
|
|
|
177
183
|
|
|
178
184
|
`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。
|
|
179
185
|
|
|
186
|
+
## 🔗 友情链接
|
|
187
|
+
|
|
188
|
+
- [LinuxDo — 真诚、友善、团结、专业](https://linux.do)
|
|
189
|
+
|
|
180
190
|
## 📄 许可证
|
|
181
191
|
|
|
182
192
|
MIT,见 [`LICENSE`](./LICENSE)。
|
package/package.json
CHANGED
package/src/ansi.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// 终端转义序列相关的工具,三种用途分开:
|
|
2
|
+
// 1. ANSI_SEQ_RE / splitAnsi:把 pi-tui 渲染出来的一行拆成"可见文字 / 序列"两类片段,序列原样保留、
|
|
3
|
+
// 绝不吞可见字。高亮(query.ts)和反显(selection.ts)都靠它,两边必须用同一个正则,
|
|
4
|
+
// 否则"记为命中的行"和"高亮到的行"会对不上
|
|
5
|
+
// 2. applySgr:只跟踪 SGR 里高亮会碰到的几项(前景色、粗体 / 暗淡、下划线),高亮结束时把外层样式恢复回去
|
|
6
|
+
// 3. sanitizeText:解析会话 JSONL 时净化输入。比 1 宽得多:DCS / PM / SOS、没终止的 OSC、孤立的 ESC、
|
|
7
|
+
// C0 控制字符全去掉,宁可多删。每个 ANSI_SEQ_RE 能匹配的序列都在它的覆盖范围内,反之不然
|
|
8
|
+
// scripts/screenshot.mjs 有一个自己的 SGR → CSS 状态机(要斜体和背景色),用途不同,不共用
|
|
9
|
+
|
|
10
|
+
/** pi-tui 会产生的三类序列:CSI(颜色等)、OSC(超链接)、APC。带 g,只用于 matchAll / replace,两者都不受 lastIndex 影响 */
|
|
11
|
+
export const ANSI_SEQ_RE = /\x1b\[[0-9;?<>=!]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b_[^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
12
|
+
|
|
13
|
+
/** 行尾连续的序列(39m、49m 之类的收尾码) */
|
|
14
|
+
export const ANSI_TAIL_RE = new RegExp(`(?:${ANSI_SEQ_RE.source})+$`);
|
|
15
|
+
|
|
16
|
+
export interface AnsiSegment {
|
|
17
|
+
text: string;
|
|
18
|
+
esc: boolean; // true:这一段是一个完整的转义序列
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 一行拆成可见文本和转义序列交替的片段,按顺序拼回去就是原样 */
|
|
22
|
+
export function splitAnsi(line: string): AnsiSegment[] {
|
|
23
|
+
const segs: AnsiSegment[] = [];
|
|
24
|
+
let last = 0;
|
|
25
|
+
for (const m of line.matchAll(ANSI_SEQ_RE)) {
|
|
26
|
+
if (m.index > last) segs.push({ text: line.slice(last, m.index), esc: false });
|
|
27
|
+
segs.push({ text: m[0], esc: true });
|
|
28
|
+
last = m.index + m[0].length;
|
|
29
|
+
}
|
|
30
|
+
if (last < line.length) segs.push({ text: line.slice(last), esc: false });
|
|
31
|
+
return segs;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 是不是 SGR(CSI … m):只有它会改样式 */
|
|
35
|
+
export const isSgr = (seq: string): boolean => seq.startsWith("\x1b[") && seq.endsWith("m");
|
|
36
|
+
|
|
37
|
+
// 只跟踪高亮会碰到的几项:前景色、粗体 / 暗淡、下划线
|
|
38
|
+
export interface SgrStyle {
|
|
39
|
+
fg: string;
|
|
40
|
+
bold: boolean;
|
|
41
|
+
dim: boolean;
|
|
42
|
+
underline: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function applySgr(st: SgrStyle, seq: string): void {
|
|
46
|
+
const m = /^\x1b\[([0-9;]*)m$/.exec(seq);
|
|
47
|
+
if (!m) return;
|
|
48
|
+
const p = m[1] === "" ? [0] : m[1].split(";").map(Number);
|
|
49
|
+
for (let i = 0; i < p.length; i++) {
|
|
50
|
+
const c = p[i];
|
|
51
|
+
if (c === 0) {
|
|
52
|
+
st.fg = "";
|
|
53
|
+
st.bold = st.dim = st.underline = false;
|
|
54
|
+
} else if (c === 1) st.bold = true;
|
|
55
|
+
else if (c === 2) st.dim = true;
|
|
56
|
+
else if (c === 22) st.bold = st.dim = false;
|
|
57
|
+
else if (c === 4) st.underline = true;
|
|
58
|
+
else if (c === 24) st.underline = false;
|
|
59
|
+
else if ((c >= 30 && c <= 37) || (c >= 90 && c <= 97)) st.fg = `\x1b[${c}m`;
|
|
60
|
+
else if (c === 39) st.fg = "";
|
|
61
|
+
else if (c === 38 || c === 48) {
|
|
62
|
+
// 扩展色 38;5;n / 38;2;r;g;b;背景色(48)只需要跳过它的参数
|
|
63
|
+
const n = p[i + 1] === 5 ? 2 : p[i + 1] === 2 ? 4 : 0;
|
|
64
|
+
if (c === 38 && n) st.fg = `\x1b[${p.slice(i, i + n + 1).join(";")}m`;
|
|
65
|
+
i += n;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 终端转义序列(CSI / OSC / DCS / APC 等)和除 \t \n 外的 C0 控制字符。正文里带这些会原样写到屏幕上:
|
|
71
|
+
// 排版错乱、宽度算错,OSC 52 之类的还能改剪贴板 / 窗口标题
|
|
72
|
+
const CONTROL_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|[\]P_^X][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-Z\\-_])|[\x00-\x08\x0b-\x1f\x7f]/g;
|
|
73
|
+
export const sanitizeText = (s: string) => s.replace(/\r\n?/g, "\n").replace(CONTROL_RE, "");
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { SEP } from "./layout.ts";
|
|
2
|
+
import type { Cell, Pane, Selection } from "./selection.ts";
|
|
3
|
+
|
|
4
|
+
// 鼠标拖选的状态机:按下(begin)→ 拖动(move)→ 松开(release),中间拖出预览上下边时自动滚动。
|
|
5
|
+
// 选区本身的几何(起止排序、每行列区间、反显、取文字)是 selection.ts 的纯函数,这里只管"现在选到哪了"。
|
|
6
|
+
// 坐标一律是主体行号(组件 y 减去 BODY_TOP);组件通过 host 提供分栏尺寸和滚动位置,并接收自动滚动的写回
|
|
7
|
+
|
|
8
|
+
const AUTO_SCROLL_MS = 50; // 拖选拖出预览上下边时自动滚动的节奏,和 pi-tui 一样
|
|
9
|
+
|
|
10
|
+
export interface DragHost {
|
|
11
|
+
/** 最近一次 render 的分栏尺寸和两栏当前的滚动偏移 */
|
|
12
|
+
geometry(): { lw: number; rw: number; H: number; listOffset: number; previewOffset: number };
|
|
13
|
+
/** 预览总行数,自动滚动到底时停 */
|
|
14
|
+
previewLength(): number;
|
|
15
|
+
/** 自动滚动写回预览偏移;紧接着的 move 用新值算格 */
|
|
16
|
+
setPreviewOffset(n: number): void;
|
|
17
|
+
/** 自动滚动改了状态,让组件重画(invalidate + requestRender) */
|
|
18
|
+
changed(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class DragSelect {
|
|
22
|
+
sel?: Selection; // 鼠标拖出来的选区,只在一栏里
|
|
23
|
+
dragging = false; // 左键还按着
|
|
24
|
+
selMoved = false; // 按下之后拖过没有;没拖过的选区不显示也不算数(那是单击)
|
|
25
|
+
dragX = 0; // 最近一次拖动的列,自动滚动时焦点跟着它
|
|
26
|
+
autoScrollDir = 0; // 拖出预览上下边:-1 往上 1 往下
|
|
27
|
+
autoScrollTimer?: ReturnType<typeof setInterval>;
|
|
28
|
+
|
|
29
|
+
constructor(private host: DragHost) {}
|
|
30
|
+
|
|
31
|
+
// 拖过的才算选区,只按下没拖的是单击
|
|
32
|
+
active(): Selection | undefined {
|
|
33
|
+
return this.sel && this.selMoved ? this.sel : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
clear(): void {
|
|
37
|
+
this.sel = undefined;
|
|
38
|
+
this.dragging = false;
|
|
39
|
+
this.selMoved = false;
|
|
40
|
+
this.stopAutoScroll();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 屏幕位置换成某一栏的内容坐标:行是内容的绝对行号,列夹在这一栏里,拖到栏外也只选栏内的字
|
|
44
|
+
cellAt(pane: Pane, x: number, bodyRow: number): Cell {
|
|
45
|
+
const { lw, rw, H, listOffset, previewOffset } = this.host.geometry();
|
|
46
|
+
const row = Math.max(0, Math.min(H - 1, bodyRow));
|
|
47
|
+
if (pane === "list") return { row: listOffset * 2 + row, col: Math.max(0, Math.min(lw - 1, x)) };
|
|
48
|
+
return { row: previewOffset + row, col: Math.max(0, Math.min(rw - 1, x - lw - SEP)) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 按下:可能是拖选的起点,拖起来才算数
|
|
52
|
+
begin(pane: Pane, x: number, bodyRow: number): void {
|
|
53
|
+
const cell = this.cellAt(pane, x, bodyRow);
|
|
54
|
+
this.sel = { pane, anchor: cell, focus: { ...cell } };
|
|
55
|
+
this.dragging = true;
|
|
56
|
+
this.dragX = x;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 拖到某个位置:焦点跟过去。返回有没有变
|
|
60
|
+
move(x: number, bodyRow: number): boolean {
|
|
61
|
+
this.dragX = x;
|
|
62
|
+
const changed = this.dragTo(x, bodyRow);
|
|
63
|
+
if (changed) this.selMoved = true;
|
|
64
|
+
return changed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private dragTo(x: number, bodyRow: number): boolean {
|
|
68
|
+
if (!this.sel) return false;
|
|
69
|
+
const cell = this.cellAt(this.sel.pane, x, bodyRow);
|
|
70
|
+
if (cell.row === this.sel.focus.row && cell.col === this.sel.focus.col) return false;
|
|
71
|
+
this.sel.focus = cell;
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 指针在预览上边(-1)/ 下边(1)时开始自动滚,回到里面(0)停
|
|
76
|
+
autoScroll(dir: -1 | 0 | 1): void {
|
|
77
|
+
this.autoScrollDir = dir;
|
|
78
|
+
if (!dir) {
|
|
79
|
+
this.stopAutoScroll();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (this.autoScrollTimer) return;
|
|
83
|
+
this.autoScrollTimer = setInterval(() => this.tick(), AUTO_SCROLL_MS);
|
|
84
|
+
this.autoScrollTimer.unref?.();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 每次滚一行,焦点跟着指针:指针在上边就是视口第一行,在下边就是最后一行
|
|
88
|
+
private tick(): void {
|
|
89
|
+
const { H, previewOffset } = this.host.geometry();
|
|
90
|
+
const maxOff = Math.max(0, this.host.previewLength() - H);
|
|
91
|
+
const next = Math.max(0, Math.min(maxOff, previewOffset + this.autoScrollDir));
|
|
92
|
+
if (!this.sel || !this.dragging || next === previewOffset) {
|
|
93
|
+
this.stopAutoScroll();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
this.host.setPreviewOffset(next);
|
|
97
|
+
if (this.dragTo(this.dragX, this.autoScrollDir < 0 ? -1 : H)) this.selMoved = true;
|
|
98
|
+
this.host.changed();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 松开:没拖过就是单击,选区作废。返回是不是我们在跟踪的那次按下
|
|
102
|
+
release(): boolean {
|
|
103
|
+
if (!this.dragging) return false;
|
|
104
|
+
this.dragging = false;
|
|
105
|
+
this.stopAutoScroll();
|
|
106
|
+
if (!this.selMoved) this.sel = undefined;
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private stopAutoScroll(): void {
|
|
111
|
+
if (this.autoScrollTimer) clearInterval(this.autoScrollTimer);
|
|
112
|
+
this.autoScrollTimer = undefined;
|
|
113
|
+
this.autoScrollDir = 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
dispose(): void {
|
|
117
|
+
this.stopAutoScroll();
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/i18n.ts
CHANGED
|
@@ -27,12 +27,12 @@ const zh = {
|
|
|
27
27
|
hitPos: " 命中 {k}/{n}",
|
|
28
28
|
truncated: "(会话过长,仅显示最后 {n} 条)",
|
|
29
29
|
hitInTruncated: "关键词命中在未显示的更早消息中",
|
|
30
|
-
hitOnlyMeta: "
|
|
30
|
+
hitOnlyMeta: "关键词仅命中会话名,对话正文无匹配",
|
|
31
31
|
you: "👤 你",
|
|
32
32
|
ai: "🤖 AI",
|
|
33
33
|
renamePrefix: "✏ 重命名: ",
|
|
34
34
|
confirmDelete: "⚠ 删除会话 [{desc}]?按 y / Enter 确认,任意其他键取消",
|
|
35
|
-
footer: "↑↓ 选择 PgUp/Dn·^U/^
|
|
35
|
+
footer: "↑↓ 选择 PgUp/Dn·^U/^Y·⇧↑↓ 滚动 ^N/^P 命中 ^T 工具 Tab 范围 ^D 删除 ^R 改名 ^O 分叉 Enter 进入 Esc/^C 关闭 🖱 点选·双击进入·拖选·滚轮",
|
|
36
36
|
selHint: "▭ 已选 {n} 行 ^C 复制 点击别处取消",
|
|
37
37
|
copied: "✓ 已复制 {n} 行",
|
|
38
38
|
copyFailed: "✗ 复制失败",
|
|
@@ -70,12 +70,12 @@ const en: Record<keyof typeof zh, string> = {
|
|
|
70
70
|
hitPos: " hit {k}/{n}",
|
|
71
71
|
truncated: "(long session, showing the last {n} messages)",
|
|
72
72
|
hitInTruncated: "Keyword hits are in earlier messages not shown here",
|
|
73
|
-
hitOnlyMeta: "Keyword matched only the session name
|
|
73
|
+
hitOnlyMeta: "Keyword matched only the session name, not the conversation",
|
|
74
74
|
you: "👤 You",
|
|
75
75
|
ai: "🤖 AI",
|
|
76
76
|
renamePrefix: "✏ Rename: ",
|
|
77
77
|
confirmDelete: "⚠ Delete session [{desc}]? y / Enter to confirm, any other key to cancel",
|
|
78
|
-
footer: "↑↓ select PgUp/Dn·^U/^
|
|
78
|
+
footer: "↑↓ select PgUp/Dn·^U/^Y·⇧↑↓ scroll ^N/^P hits ^T tools Tab scope ^D del ^R rename ^O fork Enter open Esc close 🖱 click·dbl-click·drag·wheel",
|
|
79
79
|
selHint: "▭ {n} lines selected ^C copy click elsewhere to clear",
|
|
80
80
|
copied: "✓ Copied {n} lines",
|
|
81
81
|
copyFailed: "✗ Copy failed",
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// 双栏选择器的几何:头部占几行、分隔线几列、主体多高、左右栏多宽。
|
|
2
|
+
// 组件、左栏、拖选都要用,单独放一个文件,免得它们反向 import 组件
|
|
3
|
+
|
|
4
|
+
export const BODY_TOP = 3; // 头部、搜索框、分隔线之后才是双栏主体
|
|
5
|
+
export const SEP = 3; // 两栏之间 " │ " 占的列数
|
|
6
|
+
|
|
7
|
+
// 主体高度。每次都读 process.stdout.rows,终端拉伸后布局跟着变;读不到时用启动时记下的行数
|
|
8
|
+
export function bodyRows(termRows: number): number {
|
|
9
|
+
const rows = process.stdout.rows || termRows || 24;
|
|
10
|
+
return Math.max(8, Math.min(rows - 12, 30));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// 左栏占 40%,夹在 26 到 56 列之间;右栏拿剩下的,至少 20 列
|
|
14
|
+
export function paneWidths(width: number): { lw: number; rw: number } {
|
|
15
|
+
const lw = Math.max(26, Math.min(56, Math.floor(width * 0.4)));
|
|
16
|
+
const rw = Math.max(20, width - lw - SEP);
|
|
17
|
+
return { lw, rw };
|
|
18
|
+
}
|
package/src/list.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { anyMatch, highlight, snippet, type RoleTerms } from "./query.ts";
|
|
3
|
+
import type { PeekSession } from "./sessions.ts";
|
|
4
|
+
import { fmtTime, padEndVisible } from "./text.ts";
|
|
5
|
+
import type { PeekTheme } from "./theme.ts";
|
|
6
|
+
|
|
7
|
+
// 左栏:每个会话两行,全是纯函数。滚动偏移由组件持有,这里只算"选中项要留在视口里时偏移该是多少"
|
|
8
|
+
|
|
9
|
+
// 主体 height 行能放下几个会话(每个两行)
|
|
10
|
+
export const visibleItems = (height: number): number => Math.max(1, Math.floor(height / 2));
|
|
11
|
+
|
|
12
|
+
// 选中项跑出视口时把偏移挪过去,其余情况不动
|
|
13
|
+
export function followSelection(selected: number, offset: number, visible: number): number {
|
|
14
|
+
if (selected < offset) return selected;
|
|
15
|
+
if (selected >= offset + visible) return selected - visible + 1;
|
|
16
|
+
return offset;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 一个会话的两行:时间 + 目录(有正文关键词时加命中数),首条消息或命中片段
|
|
20
|
+
export function listRows(s: PeekSession, sel: boolean, lw: number, rt: RoleTerms, hasBody: boolean, t: PeekTheme): [string, string] {
|
|
21
|
+
const time = fmtTime(s.time);
|
|
22
|
+
const cwdTail = s.cwd.replace(/\\/g, "/").split("/").slice(-2).join("/");
|
|
23
|
+
const hitBadge = hasBody
|
|
24
|
+
? ` ·${s.msgs.reduce((n, m) => n + (anyMatch(m.text, rt[m.role]) ? 1 : 0), 0)}`
|
|
25
|
+
: "";
|
|
26
|
+
const l1 = `${sel ? "›" : " "} ${time} ${cwdTail}${hitBadge}`;
|
|
27
|
+
const snip = hasBody ? snippet(s.msgs, rt, lw) : undefined;
|
|
28
|
+
const l2 = snip !== undefined
|
|
29
|
+
? ` ${highlight(snip.text, rt[snip.role], t)}`
|
|
30
|
+
: ` ${s.first}${s.name ? ` [${s.name}]` : ""}`;
|
|
31
|
+
if (sel) {
|
|
32
|
+
return [
|
|
33
|
+
t.bg("selectedBg", padEndVisible(t.fg("accent", truncateToWidth(l1, lw)), lw)),
|
|
34
|
+
t.bg("selectedBg", padEndVisible(truncateToWidth(l2, lw), lw)),
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
return [padEndVisible(l1, lw), padEndVisible(t.fg("dim", truncateToWidth(l2, lw)), lw)];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 从 offset 起画满 height 行,每个会话两行,没会话的位置留空
|
|
41
|
+
export function buildList(
|
|
42
|
+
filtered: PeekSession[],
|
|
43
|
+
selected: number,
|
|
44
|
+
offset: number,
|
|
45
|
+
height: number,
|
|
46
|
+
lw: number,
|
|
47
|
+
rt: RoleTerms,
|
|
48
|
+
hasBody: boolean,
|
|
49
|
+
t: PeekTheme,
|
|
50
|
+
): string[] {
|
|
51
|
+
const rows: string[] = [];
|
|
52
|
+
for (let i = 0, n = visibleItems(height); i < n; i++) {
|
|
53
|
+
const idx = offset + i;
|
|
54
|
+
const s = filtered[idx];
|
|
55
|
+
if (!s) {
|
|
56
|
+
rows.push(" ".repeat(lw), " ".repeat(lw));
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
rows.push(...listRows(s, idx === selected, lw, rt, hasBody, t));
|
|
60
|
+
}
|
|
61
|
+
// 每项两行,height 是奇数时会少一行,补空行,否则最后一行右栏会顶到左边
|
|
62
|
+
while (rows.length < height) rows.push(" ".repeat(lw));
|
|
63
|
+
return rows.slice(0, height);
|
|
64
|
+
}
|