pi-session-peek 0.2.0 → 0.3.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 +8 -0
- package/LICENSE +21 -21
- package/README.md +18 -2
- package/README.zh-CN.md +18 -2
- package/index.ts +7 -3
- package/package.json +1 -1
- package/src/i18n.ts +6 -2
- package/src/peek-component.ts +500 -489
- package/src/query.ts +5 -0
- package/src/sessions.ts +33 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- Ctrl+D now prefers the system trash on every platform instead of only the `trash` CLI: Windows Recycle Bin via PowerShell, Finder on macOS, `gio trash` / `trash-put` on Linux. The file is deleted permanently only when none of them works, and the notification says which one happened.
|
|
6
|
+
|
|
7
|
+
## 0.2.1
|
|
8
|
+
|
|
9
|
+
- Ctrl+N / Ctrl+P now jump between hit lines instead of hit messages. Several hits inside one long reply used to collapse into a single stop, so the preview appeared not to move. Hits already on screen are skipped, and the top-right corner shows the current hit as `hit k/n`.
|
|
10
|
+
|
|
3
11
|
## 0.2.0
|
|
4
12
|
|
|
5
13
|
- The preview renders messages with pi's own Markdown renderer: headings, bold, inline code, syntax-highlighted code blocks, tables with borders and cell wrapping, lists, quotes and links now look the same as in the main transcript.
|
package/LICENSE
CHANGED
|
@@ -1,21 +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.
|
|
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
CHANGED
|
@@ -64,11 +64,11 @@ In TUI mode, run `/peek` and start typing. Press `Enter` on a session to continu
|
|
|
64
64
|
| `PgUp` `PgDn` | Scroll preview by a page |
|
|
65
65
|
| `Ctrl+U` `Ctrl+F` | Scroll preview by half a page |
|
|
66
66
|
| `Shift+↑` `Shift+↓` | Scroll preview by three lines |
|
|
67
|
-
| `Ctrl+N` `Ctrl+P` | Next / previous hit |
|
|
67
|
+
| `Ctrl+N` `Ctrl+P` | Next / previous hit line outside the current view; the top-right corner shows `hit k/n` |
|
|
68
68
|
| `Enter` | Resume the session |
|
|
69
69
|
| `Ctrl+O` | Fork the session and open the fork |
|
|
70
70
|
| `Ctrl+R` | Rename. Writes the same `session_info` entry as `/name`. |
|
|
71
|
-
| `Ctrl+D` | Delete, confirmed with `y` or `Enter`.
|
|
71
|
+
| `Ctrl+D` | Delete, confirmed with `y` or `Enter`. Moves the file to the system trash whenever one is available (`trash` CLI, then the Windows Recycle Bin via PowerShell, Finder on macOS, `gio trash` / `trash-put` on Linux). Deletes permanently only if none of those work. |
|
|
72
72
|
| `Esc` `Ctrl+C` | Close |
|
|
73
73
|
|
|
74
74
|
## 🔎 Search syntax
|
|
@@ -130,6 +130,22 @@ npm run screenshot # optional: overwrite docs/screenshot.png with a syntheti
|
|
|
130
130
|
|
|
131
131
|
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`.
|
|
132
132
|
|
|
133
|
+
### Releasing
|
|
134
|
+
|
|
135
|
+
Every push to `main` and every pull request runs `npm run check` on Ubuntu and Windows (`.github/workflows/ci.yml`). Pushing a `vX.Y.Z` tag runs `.github/workflows/release.yml`, which checks that the tag matches `package.json`, runs the checks again, publishes to npm through [trusted publishing](https://docs.npmjs.com/trusted-publishers/) (no token needed, provenance attached automatically) and creates a GitHub Release from the matching `CHANGELOG.md` section.
|
|
136
|
+
|
|
137
|
+
One-time setup on npmjs.com: package settings → Trusted Publisher → GitHub Actions, with owner `Jeffropz`, repository `pi-session-peek`, workflow filename `release.yml`.
|
|
138
|
+
|
|
139
|
+
To cut a release:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
# 1. move the "## Unreleased" entries in CHANGELOG.md under the new version and commit
|
|
143
|
+
# 2. bump the version, commit and tag in one go (patch / minor / major)
|
|
144
|
+
npm version patch -m "chore: release %s"
|
|
145
|
+
# 3. push the commit together with the tag; CI does the rest
|
|
146
|
+
git push --follow-tags
|
|
147
|
+
```
|
|
148
|
+
|
|
133
149
|
## 📄 License
|
|
134
150
|
|
|
135
151
|
MIT. See [`LICENSE`](./LICENSE).
|
package/README.zh-CN.md
CHANGED
|
@@ -64,11 +64,11 @@ pi 扩展以你的用户权限运行,只装信得过的包。
|
|
|
64
64
|
| `PgUp` `PgDn` | 预览翻页 |
|
|
65
65
|
| `Ctrl+U` `Ctrl+F` | 预览翻半页 |
|
|
66
66
|
| `Shift+↑` `Shift+↓` | 预览滚三行 |
|
|
67
|
-
| `Ctrl+N` `Ctrl+P` |
|
|
67
|
+
| `Ctrl+N` `Ctrl+P` | 跳到当前屏幕外的下一处 / 上一处命中行,右上角显示「命中 k/n」 |
|
|
68
68
|
| `Enter` | 进入会话 |
|
|
69
69
|
| `Ctrl+O` | 分叉出新会话并进入 |
|
|
70
70
|
| `Ctrl+R` | 重命名,写入的记录和 `/name` 一样 |
|
|
71
|
-
| `Ctrl+D` | 删除,按 `y` 或 `Enter`
|
|
71
|
+
| `Ctrl+D` | 删除,按 `y` 或 `Enter` 确认。只要系统有回收站就优先进回收站(`trash` 命令、Windows 用 PowerShell 进回收站、macOS 用 Finder、Linux 用 `gio trash` / `trash-put`),全都不可用才直接删文件。 |
|
|
72
72
|
| `Esc` `Ctrl+C` | 关闭 |
|
|
73
73
|
|
|
74
74
|
## 🔎 搜索语法
|
|
@@ -130,6 +130,22 @@ npm run screenshot # 可选:用合成渲染覆盖 docs/screenshot.png,
|
|
|
130
130
|
|
|
131
131
|
本地开发时把目录路径加到 `~/.pi/agent/settings.json` 的 `extensions` 里,或者直接放进 `~/.pi/agent/extensions/`,然后 `/reload`。
|
|
132
132
|
|
|
133
|
+
### 发版
|
|
134
|
+
|
|
135
|
+
推到 `main` 和提 PR 都会在 Ubuntu 和 Windows 上跑 `npm run check`(`.github/workflows/ci.yml`)。推一个 `vX.Y.Z` 的 tag 会触发 `.github/workflows/release.yml`:校验 tag 和 `package.json` 版本一致,再跑一遍检查,通过 [trusted publishing](https://docs.npmjs.com/trusted-publishers/) 发到 npm(不需要 token,自动附带 provenance),最后从 `CHANGELOG.md` 里对应版本的段落生成 GitHub Release。
|
|
136
|
+
|
|
137
|
+
npm 侧需要一次性配置:包设置 → Trusted Publisher → GitHub Actions,填 owner `Jeffropz`、repository `pi-session-peek`、workflow filename `release.yml`。
|
|
138
|
+
|
|
139
|
+
发一个版本:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
# 1. 把 CHANGELOG.md 里 "## Unreleased" 下的条目挪到新版本号下面,提交
|
|
143
|
+
# 2. 升版本号、提交、打 tag 一步完成(patch / minor / major)
|
|
144
|
+
npm version patch -m "chore: release %s"
|
|
145
|
+
# 3. 提交和 tag 一起推上去,剩下的交给 CI
|
|
146
|
+
git push --follow-tags
|
|
147
|
+
```
|
|
148
|
+
|
|
133
149
|
## 📄 许可证
|
|
134
150
|
|
|
135
151
|
MIT,见 [`LICENSE`](./LICENSE)。
|
package/index.ts
CHANGED
|
@@ -78,9 +78,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
78
78
|
done(null);
|
|
79
79
|
};
|
|
80
80
|
comp.onDelete = async (s) => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
// PowerShell / osascript 冷启动要一两秒,超时给宽一点
|
|
82
|
+
const r = await deleteSession(s.path, (cmd, a) => pi.exec(cmd, a, { timeout: 15000 }));
|
|
83
|
+
const file = basename(s.path);
|
|
84
|
+
if (r === "trash") ctx.ui.notify(msg("trashed", { file }), "info");
|
|
85
|
+
else if (r === "rm") ctx.ui.notify(msg("deleted", { file }), "info");
|
|
86
|
+
else ctx.ui.notify(msg("deleteFailed"), "error");
|
|
87
|
+
return r !== false;
|
|
84
88
|
};
|
|
85
89
|
comp.onRename = async (s, name) => {
|
|
86
90
|
let ok = true;
|
package/package.json
CHANGED
package/src/i18n.ts
CHANGED
|
@@ -6,7 +6,8 @@ const zh = {
|
|
|
6
6
|
cmdDesc: "搜索历史会话:左右分栏预览、关键词高亮,Enter 进入对话",
|
|
7
7
|
needTui: "/peek 需要在交互模式(TUI)下使用",
|
|
8
8
|
noSessions: "没有找到历史会话",
|
|
9
|
-
|
|
9
|
+
trashed: "已将会话 {file} 移入回收站",
|
|
10
|
+
deleted: "已永久删除会话 {file}(未找到回收站)",
|
|
10
11
|
deleteFailed: "删除会话失败",
|
|
11
12
|
renamed: "已重命名为「{name}」",
|
|
12
13
|
renameFailed: "重命名失败",
|
|
@@ -23,6 +24,7 @@ const zh = {
|
|
|
23
24
|
matchCount: " 匹配 {n}/{total}",
|
|
24
25
|
noMatch: "没有匹配的会话",
|
|
25
26
|
msgCount: "{n} 条消息",
|
|
27
|
+
hitPos: " 命中 {k}/{n}",
|
|
26
28
|
truncated: "(会话过长,仅显示最后 {n} 条)",
|
|
27
29
|
hitInTruncated: "关键词命中在未显示的更早消息中",
|
|
28
30
|
hitOnlyMeta: "关键词仅命中会话名或目录,对话正文无匹配",
|
|
@@ -44,7 +46,8 @@ const en: Record<keyof typeof zh, string> = {
|
|
|
44
46
|
cmdDesc: "Search session history: two-pane preview with keyword highlighting, Enter to resume",
|
|
45
47
|
needTui: "/peek only works in interactive (TUI) mode",
|
|
46
48
|
noSessions: "No sessions found",
|
|
47
|
-
|
|
49
|
+
trashed: "Moved session {file} to the trash",
|
|
50
|
+
deleted: "Permanently deleted session {file} (no trash available)",
|
|
48
51
|
deleteFailed: "Failed to delete session",
|
|
49
52
|
renamed: "Renamed to “{name}”",
|
|
50
53
|
renameFailed: "Rename failed",
|
|
@@ -61,6 +64,7 @@ const en: Record<keyof typeof zh, string> = {
|
|
|
61
64
|
matchCount: " {n}/{total} matched",
|
|
62
65
|
noMatch: "No matching sessions",
|
|
63
66
|
msgCount: "{n} messages",
|
|
67
|
+
hitPos: " hit {k}/{n}",
|
|
64
68
|
truncated: "(long session, showing the last {n} messages)",
|
|
65
69
|
hitInTruncated: "Keyword hits are in earlier messages not shown here",
|
|
66
70
|
hitOnlyMeta: "Keyword matched only the session name or directory, not the conversation",
|
package/src/peek-component.ts
CHANGED
|
@@ -1,489 +1,500 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Input,
|
|
3
|
-
Key,
|
|
4
|
-
Markdown,
|
|
5
|
-
matchesKey,
|
|
6
|
-
truncateToWidth,
|
|
7
|
-
visibleWidth,
|
|
8
|
-
type Component,
|
|
9
|
-
type Focusable,
|
|
10
|
-
type MarkdownTheme,
|
|
11
|
-
} from "@earendil-works/pi-tui";
|
|
12
|
-
import { msg } from "./i18n.ts";
|
|
13
|
-
import { anyKw, highlight, parseQuery, snippet, type ParsedQuery } from "./query.ts";
|
|
14
|
-
import type { PeekMsg, PeekSession } from "./sessions.ts";
|
|
15
|
-
import { fmtTime, normPath, padEndVisible } from "./text.ts";
|
|
16
|
-
|
|
17
|
-
// 双栏选择器。文件操作(删除 / 重命名 / 分叉)不在这里做,由 index.ts 通过回调注入
|
|
18
|
-
|
|
19
|
-
export class PeekComponent implements Component, Focusable {
|
|
20
|
-
private input: Input;
|
|
21
|
-
private scope: "current" | "all";
|
|
22
|
-
private filtered: PeekSession[] = [];
|
|
23
|
-
private selected = 0;
|
|
24
|
-
private listOffset = 0;
|
|
25
|
-
private previewOffset = 0;
|
|
26
|
-
private previewKey = ""; // 预览缓存的键(会话 + 关键词 + 宽度),置空强制重建
|
|
27
|
-
private previewLines: string[] = [];
|
|
28
|
-
private matchLines: number[] = []; //
|
|
29
|
-
private rendered = new WeakMap<PeekMsg, { w: number; lines: string[] }>(); // 每条消息渲染好的行,按宽度缓存
|
|
30
|
-
private confirmingDelete = false; // Ctrl+D 之后等待确认
|
|
31
|
-
private renaming = false; // Ctrl+R 之后正在输入名字
|
|
32
|
-
private renameInput: Input;
|
|
33
|
-
private cachedWidth = -1;
|
|
34
|
-
private cachedLines: string[] = [];
|
|
35
|
-
private _focused = false;
|
|
36
|
-
|
|
37
|
-
// 由 index.ts 注入
|
|
38
|
-
onResume?: (s: PeekSession) => void;
|
|
39
|
-
onFork?: (s: PeekSession) => void;
|
|
40
|
-
onCancel?: () => void;
|
|
41
|
-
onDelete?: (s: PeekSession) => Promise<boolean>;
|
|
42
|
-
onRename?: (s: PeekSession, name: string) => Promise<boolean>;
|
|
43
|
-
requestRender?: () => void; // 异步操作完成后让 TUI 重画
|
|
44
|
-
|
|
45
|
-
constructor(
|
|
46
|
-
private all: PeekSession[],
|
|
47
|
-
private currentCwd: string,
|
|
48
|
-
private theme: any,
|
|
49
|
-
private mdTheme: MarkdownTheme,
|
|
50
|
-
private termRows: number,
|
|
51
|
-
initialQuery: string,
|
|
52
|
-
) {
|
|
53
|
-
this.input = new Input({ placeholder: msg("searchPlaceholder") });
|
|
54
|
-
this.renameInput = new Input({ placeholder: msg("renamePlaceholder") });
|
|
55
|
-
if (initialQuery) this.input.setValue(initialQuery);
|
|
56
|
-
// 当前目录树下有会话就默认当前目录,否则全局
|
|
57
|
-
this.scope = all.some((s) => this.inCurrentTree(s)) ? "current" : "all";
|
|
58
|
-
this.refilter();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// 焦点要落到真正在用的那个输入框上,否则 IME 候选框位置不对
|
|
62
|
-
get focused(): boolean {
|
|
63
|
-
return this._focused;
|
|
64
|
-
}
|
|
65
|
-
set focused(v: boolean) {
|
|
66
|
-
this._focused = v;
|
|
67
|
-
this.input.focused = v && !this.renaming;
|
|
68
|
-
this.renameInput.focused = v && this.renaming;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
getQuery(): string {
|
|
72
|
-
return this.input.getValue().trim();
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
private query(): ParsedQuery {
|
|
76
|
-
return parseQuery(this.getQuery());
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// 等于当前目录,或在它的子目录下
|
|
80
|
-
private inCurrentTree(s: PeekSession): boolean {
|
|
81
|
-
const cur = normPath(this.currentCwd);
|
|
82
|
-
const c = normPath(s.cwd);
|
|
83
|
-
return c === cur || c.startsWith(cur + "/");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// keepSelection:尽量保持原来选中的会话,重命名后用
|
|
87
|
-
private refilter(keepSelection = false): void {
|
|
88
|
-
const prev = keepSelection ? this.filtered[this.selected] : undefined;
|
|
89
|
-
const { kws, since } = this.query();
|
|
90
|
-
let out = this.all;
|
|
91
|
-
if (this.scope === "current") out = out.filter((s) => this.inCurrentTree(s));
|
|
92
|
-
if (since) out = out.filter((s) => s.mtime >= since);
|
|
93
|
-
if (kws.length) out = out.filter((s) => kws.every((k) => s.searchText.includes(k)));
|
|
94
|
-
this.filtered = out;
|
|
95
|
-
const idx = prev ? out.indexOf(prev) : -1;
|
|
96
|
-
this.selected = idx >= 0 ? idx : keepSelection ? Math.min(this.selected, Math.max(0, out.length - 1)) : 0;
|
|
97
|
-
if (!keepSelection) this.listOffset = 0;
|
|
98
|
-
this.previewKey = "";
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
private bodyHeight(): number {
|
|
102
|
-
const rows = process.stdout.rows || this.termRows || 24; // 每次都读,终端拉伸后布局跟着变
|
|
103
|
-
return Math.max(8, Math.min(rows - 12, 30));
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
handleInput(data: string): void {
|
|
107
|
-
// 重命名输入中:Enter 提交,Esc / Ctrl+C 取消,其他键交给输入框
|
|
108
|
-
if (this.renaming) {
|
|
109
|
-
if (matchesKey(data, Key.enter)) {
|
|
110
|
-
const s = this.filtered[this.selected];
|
|
111
|
-
const name = this.renameInput.getValue().trim();
|
|
112
|
-
this.renaming = false;
|
|
113
|
-
this.focused = this._focused;
|
|
114
|
-
this.invalidate();
|
|
115
|
-
if (s && name && this.onRename) {
|
|
116
|
-
void this.onRename(s, name).then((ok) => {
|
|
117
|
-
if (ok) {
|
|
118
|
-
s.name = name;
|
|
119
|
-
// 名字参与搜索,改完可能就不再匹配当前关键词了
|
|
120
|
-
s.searchText = (s.msgs.map((m) => m.text).join(" ") + " " + name + " " + s.cwd).toLowerCase();
|
|
121
|
-
this.refilter(true);
|
|
122
|
-
}
|
|
123
|
-
this.invalidate();
|
|
124
|
-
this.requestRender?.();
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
130
|
-
this.renaming = false;
|
|
131
|
-
this.focused = this._focused;
|
|
132
|
-
this.invalidate();
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
this.renameInput.handleInput(data);
|
|
136
|
-
this.invalidate();
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
// 删除确认中:y / Enter 执行,其他任何键取消
|
|
140
|
-
if (this.confirmingDelete) {
|
|
141
|
-
this.confirmingDelete = false;
|
|
142
|
-
this.invalidate();
|
|
143
|
-
if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
|
|
144
|
-
const s = this.filtered[this.selected];
|
|
145
|
-
if (s && this.onDelete) {
|
|
146
|
-
void this.onDelete(s).then((ok) => {
|
|
147
|
-
if (ok) {
|
|
148
|
-
let i = this.all.indexOf(s);
|
|
149
|
-
if (i >= 0) this.all.splice(i, 1);
|
|
150
|
-
i = this.filtered.indexOf(s);
|
|
151
|
-
if (i >= 0) this.filtered.splice(i, 1);
|
|
152
|
-
// 选中位置留在原地
|
|
153
|
-
this.selected = Math.min(this.selected, Math.max(0, this.filtered.length - 1));
|
|
154
|
-
this.previewKey = "";
|
|
155
|
-
}
|
|
156
|
-
this.invalidate();
|
|
157
|
-
this.requestRender?.();
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
164
|
-
this.onCancel?.();
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
if (matchesKey(data, Key.ctrl("d"))) {
|
|
168
|
-
if (this.filtered.length && this.onDelete) {
|
|
169
|
-
this.confirmingDelete = true;
|
|
170
|
-
this.invalidate();
|
|
171
|
-
}
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
if (matchesKey(data, Key.ctrl("r"))) {
|
|
175
|
-
const s = this.filtered[this.selected];
|
|
176
|
-
if (s && this.onRename) {
|
|
177
|
-
this.renaming = true;
|
|
178
|
-
this.renameInput.setValue(s.name || "");
|
|
179
|
-
this.focused = this._focused;
|
|
180
|
-
this.invalidate();
|
|
181
|
-
}
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
if (matchesKey(data, Key.tab)) {
|
|
185
|
-
this.scope = this.scope === "all" ? "current" : "all";
|
|
186
|
-
this.refilter();
|
|
187
|
-
this.invalidate();
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
if (matchesKey(data, Key.up)) {
|
|
191
|
-
if (this.selected > 0) {
|
|
192
|
-
this.selected--;
|
|
193
|
-
this.previewKey = "";
|
|
194
|
-
this.invalidate();
|
|
195
|
-
}
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
if (matchesKey(data, Key.down)) {
|
|
199
|
-
if (this.selected < this.filtered.length - 1) {
|
|
200
|
-
this.selected++;
|
|
201
|
-
this.previewKey = "";
|
|
202
|
-
this.invalidate();
|
|
203
|
-
}
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("u"))) {
|
|
207
|
-
const step = matchesKey(data, Key.pageUp) ? this.bodyHeight() : Math.max(1, this.bodyHeight() >> 1);
|
|
208
|
-
this.previewOffset = Math.max(0, this.previewOffset - step);
|
|
209
|
-
this.invalidate();
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) {
|
|
213
|
-
const step = matchesKey(data, Key.pageDown) ? this.bodyHeight() : Math.max(1, this.bodyHeight() >> 1);
|
|
214
|
-
this.previewOffset += step;
|
|
215
|
-
this.invalidate();
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
if (matchesKey(data, Key.shift("up"))) {
|
|
219
|
-
this.previewOffset = Math.max(0, this.previewOffset - 3);
|
|
220
|
-
this.invalidate();
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
if (matchesKey(data, Key.shift("down"))) {
|
|
224
|
-
this.previewOffset += 3;
|
|
225
|
-
this.invalidate();
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
if (matchesKey(data, Key.ctrl("n"))) {
|
|
229
|
-
this.jumpMatch(1);
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
if (matchesKey(data, Key.ctrl("p"))) {
|
|
233
|
-
this.jumpMatch(-1);
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
if (matchesKey(data, Key.enter)) {
|
|
237
|
-
const s = this.filtered[this.selected];
|
|
238
|
-
if (s) this.onResume?.(s);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
if (matchesKey(data, Key.ctrl("o"))) {
|
|
242
|
-
const s = this.filtered[this.selected];
|
|
243
|
-
if (s) this.onFork?.(s);
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
// 其余按键交给搜索框
|
|
247
|
-
const before = this.input.getValue();
|
|
248
|
-
this.input.handleInput(data);
|
|
249
|
-
if (this.input.getValue() !== before) this.refilter(); // 光标移动不算输入,别重置列表
|
|
250
|
-
this.invalidate();
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
rows.push(padEndVisible(l1, lw));
|
|
305
|
-
rows.push(
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
this.
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
);
|
|
471
|
-
|
|
472
|
-
out.push(
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
Input,
|
|
3
|
+
Key,
|
|
4
|
+
Markdown,
|
|
5
|
+
matchesKey,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
visibleWidth,
|
|
8
|
+
type Component,
|
|
9
|
+
type Focusable,
|
|
10
|
+
type MarkdownTheme,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
12
|
+
import { msg } from "./i18n.ts";
|
|
13
|
+
import { anyKw, highlight, lineHasKw, parseQuery, snippet, type ParsedQuery } from "./query.ts";
|
|
14
|
+
import type { PeekMsg, PeekSession } from "./sessions.ts";
|
|
15
|
+
import { fmtTime, normPath, padEndVisible } from "./text.ts";
|
|
16
|
+
|
|
17
|
+
// 双栏选择器。文件操作(删除 / 重命名 / 分叉)不在这里做,由 index.ts 通过回调注入
|
|
18
|
+
|
|
19
|
+
export class PeekComponent implements Component, Focusable {
|
|
20
|
+
private input: Input;
|
|
21
|
+
private scope: "current" | "all";
|
|
22
|
+
private filtered: PeekSession[] = [];
|
|
23
|
+
private selected = 0;
|
|
24
|
+
private listOffset = 0;
|
|
25
|
+
private previewOffset = 0;
|
|
26
|
+
private previewKey = ""; // 预览缓存的键(会话 + 关键词 + 宽度),置空强制重建
|
|
27
|
+
private previewLines: string[] = [];
|
|
28
|
+
private matchLines: number[] = []; // 预览里含关键词的行(升序),Ctrl+N / Ctrl+P 用
|
|
29
|
+
private rendered = new WeakMap<PeekMsg, { w: number; lines: string[] }>(); // 每条消息渲染好的行,按宽度缓存
|
|
30
|
+
private confirmingDelete = false; // Ctrl+D 之后等待确认
|
|
31
|
+
private renaming = false; // Ctrl+R 之后正在输入名字
|
|
32
|
+
private renameInput: Input;
|
|
33
|
+
private cachedWidth = -1;
|
|
34
|
+
private cachedLines: string[] = [];
|
|
35
|
+
private _focused = false;
|
|
36
|
+
|
|
37
|
+
// 由 index.ts 注入
|
|
38
|
+
onResume?: (s: PeekSession) => void;
|
|
39
|
+
onFork?: (s: PeekSession) => void;
|
|
40
|
+
onCancel?: () => void;
|
|
41
|
+
onDelete?: (s: PeekSession) => Promise<boolean>;
|
|
42
|
+
onRename?: (s: PeekSession, name: string) => Promise<boolean>;
|
|
43
|
+
requestRender?: () => void; // 异步操作完成后让 TUI 重画
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
private all: PeekSession[],
|
|
47
|
+
private currentCwd: string,
|
|
48
|
+
private theme: any,
|
|
49
|
+
private mdTheme: MarkdownTheme,
|
|
50
|
+
private termRows: number,
|
|
51
|
+
initialQuery: string,
|
|
52
|
+
) {
|
|
53
|
+
this.input = new Input({ placeholder: msg("searchPlaceholder") });
|
|
54
|
+
this.renameInput = new Input({ placeholder: msg("renamePlaceholder") });
|
|
55
|
+
if (initialQuery) this.input.setValue(initialQuery);
|
|
56
|
+
// 当前目录树下有会话就默认当前目录,否则全局
|
|
57
|
+
this.scope = all.some((s) => this.inCurrentTree(s)) ? "current" : "all";
|
|
58
|
+
this.refilter();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 焦点要落到真正在用的那个输入框上,否则 IME 候选框位置不对
|
|
62
|
+
get focused(): boolean {
|
|
63
|
+
return this._focused;
|
|
64
|
+
}
|
|
65
|
+
set focused(v: boolean) {
|
|
66
|
+
this._focused = v;
|
|
67
|
+
this.input.focused = v && !this.renaming;
|
|
68
|
+
this.renameInput.focused = v && this.renaming;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getQuery(): string {
|
|
72
|
+
return this.input.getValue().trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private query(): ParsedQuery {
|
|
76
|
+
return parseQuery(this.getQuery());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 等于当前目录,或在它的子目录下
|
|
80
|
+
private inCurrentTree(s: PeekSession): boolean {
|
|
81
|
+
const cur = normPath(this.currentCwd);
|
|
82
|
+
const c = normPath(s.cwd);
|
|
83
|
+
return c === cur || c.startsWith(cur + "/");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// keepSelection:尽量保持原来选中的会话,重命名后用
|
|
87
|
+
private refilter(keepSelection = false): void {
|
|
88
|
+
const prev = keepSelection ? this.filtered[this.selected] : undefined;
|
|
89
|
+
const { kws, since } = this.query();
|
|
90
|
+
let out = this.all;
|
|
91
|
+
if (this.scope === "current") out = out.filter((s) => this.inCurrentTree(s));
|
|
92
|
+
if (since) out = out.filter((s) => s.mtime >= since);
|
|
93
|
+
if (kws.length) out = out.filter((s) => kws.every((k) => s.searchText.includes(k)));
|
|
94
|
+
this.filtered = out;
|
|
95
|
+
const idx = prev ? out.indexOf(prev) : -1;
|
|
96
|
+
this.selected = idx >= 0 ? idx : keepSelection ? Math.min(this.selected, Math.max(0, out.length - 1)) : 0;
|
|
97
|
+
if (!keepSelection) this.listOffset = 0;
|
|
98
|
+
this.previewKey = "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private bodyHeight(): number {
|
|
102
|
+
const rows = process.stdout.rows || this.termRows || 24; // 每次都读,终端拉伸后布局跟着变
|
|
103
|
+
return Math.max(8, Math.min(rows - 12, 30));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
handleInput(data: string): void {
|
|
107
|
+
// 重命名输入中:Enter 提交,Esc / Ctrl+C 取消,其他键交给输入框
|
|
108
|
+
if (this.renaming) {
|
|
109
|
+
if (matchesKey(data, Key.enter)) {
|
|
110
|
+
const s = this.filtered[this.selected];
|
|
111
|
+
const name = this.renameInput.getValue().trim();
|
|
112
|
+
this.renaming = false;
|
|
113
|
+
this.focused = this._focused;
|
|
114
|
+
this.invalidate();
|
|
115
|
+
if (s && name && this.onRename) {
|
|
116
|
+
void this.onRename(s, name).then((ok) => {
|
|
117
|
+
if (ok) {
|
|
118
|
+
s.name = name;
|
|
119
|
+
// 名字参与搜索,改完可能就不再匹配当前关键词了
|
|
120
|
+
s.searchText = (s.msgs.map((m) => m.text).join(" ") + " " + name + " " + s.cwd).toLowerCase();
|
|
121
|
+
this.refilter(true);
|
|
122
|
+
}
|
|
123
|
+
this.invalidate();
|
|
124
|
+
this.requestRender?.();
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
130
|
+
this.renaming = false;
|
|
131
|
+
this.focused = this._focused;
|
|
132
|
+
this.invalidate();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.renameInput.handleInput(data);
|
|
136
|
+
this.invalidate();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// 删除确认中:y / Enter 执行,其他任何键取消
|
|
140
|
+
if (this.confirmingDelete) {
|
|
141
|
+
this.confirmingDelete = false;
|
|
142
|
+
this.invalidate();
|
|
143
|
+
if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
|
|
144
|
+
const s = this.filtered[this.selected];
|
|
145
|
+
if (s && this.onDelete) {
|
|
146
|
+
void this.onDelete(s).then((ok) => {
|
|
147
|
+
if (ok) {
|
|
148
|
+
let i = this.all.indexOf(s);
|
|
149
|
+
if (i >= 0) this.all.splice(i, 1);
|
|
150
|
+
i = this.filtered.indexOf(s);
|
|
151
|
+
if (i >= 0) this.filtered.splice(i, 1);
|
|
152
|
+
// 选中位置留在原地
|
|
153
|
+
this.selected = Math.min(this.selected, Math.max(0, this.filtered.length - 1));
|
|
154
|
+
this.previewKey = "";
|
|
155
|
+
}
|
|
156
|
+
this.invalidate();
|
|
157
|
+
this.requestRender?.();
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
164
|
+
this.onCancel?.();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (matchesKey(data, Key.ctrl("d"))) {
|
|
168
|
+
if (this.filtered.length && this.onDelete) {
|
|
169
|
+
this.confirmingDelete = true;
|
|
170
|
+
this.invalidate();
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (matchesKey(data, Key.ctrl("r"))) {
|
|
175
|
+
const s = this.filtered[this.selected];
|
|
176
|
+
if (s && this.onRename) {
|
|
177
|
+
this.renaming = true;
|
|
178
|
+
this.renameInput.setValue(s.name || "");
|
|
179
|
+
this.focused = this._focused;
|
|
180
|
+
this.invalidate();
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (matchesKey(data, Key.tab)) {
|
|
185
|
+
this.scope = this.scope === "all" ? "current" : "all";
|
|
186
|
+
this.refilter();
|
|
187
|
+
this.invalidate();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (matchesKey(data, Key.up)) {
|
|
191
|
+
if (this.selected > 0) {
|
|
192
|
+
this.selected--;
|
|
193
|
+
this.previewKey = "";
|
|
194
|
+
this.invalidate();
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (matchesKey(data, Key.down)) {
|
|
199
|
+
if (this.selected < this.filtered.length - 1) {
|
|
200
|
+
this.selected++;
|
|
201
|
+
this.previewKey = "";
|
|
202
|
+
this.invalidate();
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("u"))) {
|
|
207
|
+
const step = matchesKey(data, Key.pageUp) ? this.bodyHeight() : Math.max(1, this.bodyHeight() >> 1);
|
|
208
|
+
this.previewOffset = Math.max(0, this.previewOffset - step);
|
|
209
|
+
this.invalidate();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) {
|
|
213
|
+
const step = matchesKey(data, Key.pageDown) ? this.bodyHeight() : Math.max(1, this.bodyHeight() >> 1);
|
|
214
|
+
this.previewOffset += step;
|
|
215
|
+
this.invalidate();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (matchesKey(data, Key.shift("up"))) {
|
|
219
|
+
this.previewOffset = Math.max(0, this.previewOffset - 3);
|
|
220
|
+
this.invalidate();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (matchesKey(data, Key.shift("down"))) {
|
|
224
|
+
this.previewOffset += 3;
|
|
225
|
+
this.invalidate();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (matchesKey(data, Key.ctrl("n"))) {
|
|
229
|
+
this.jumpMatch(1);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (matchesKey(data, Key.ctrl("p"))) {
|
|
233
|
+
this.jumpMatch(-1);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (matchesKey(data, Key.enter)) {
|
|
237
|
+
const s = this.filtered[this.selected];
|
|
238
|
+
if (s) this.onResume?.(s);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (matchesKey(data, Key.ctrl("o"))) {
|
|
242
|
+
const s = this.filtered[this.selected];
|
|
243
|
+
if (s) this.onFork?.(s);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// 其余按键交给搜索框
|
|
247
|
+
const before = this.input.getValue();
|
|
248
|
+
this.input.handleInput(data);
|
|
249
|
+
if (this.input.getValue() !== before) this.refilter(); // 光标移动不算输入,别重置列表
|
|
250
|
+
this.invalidate();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 预览里跳到当前视口之外的下一个 / 上一个命中行,到头了回绕。同一屏里的多个命中算一处,
|
|
254
|
+
// 否则一段里连着几行都命中要按好几次才过得去
|
|
255
|
+
private jumpMatch(dir: 1 | -1): void {
|
|
256
|
+
if (!this.matchLines.length) return;
|
|
257
|
+
const H = this.bodyHeight();
|
|
258
|
+
const top = this.previewOffset;
|
|
259
|
+
const bottom = Math.min(top + H, this.previewLines.length) - 1;
|
|
260
|
+
let target: number | undefined;
|
|
261
|
+
if (dir === 1) {
|
|
262
|
+
target = this.matchLines.find((l) => l > bottom) ?? this.matchLines[0];
|
|
263
|
+
} else {
|
|
264
|
+
target = [...this.matchLines].reverse().find((l) => l < top) ?? this.matchLines[this.matchLines.length - 1];
|
|
265
|
+
}
|
|
266
|
+
if (target !== undefined) {
|
|
267
|
+
this.previewOffset = Math.max(0, target - 2);
|
|
268
|
+
this.invalidate();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 左栏,每个会话两行
|
|
273
|
+
private buildList(height: number, lw: number): string[] {
|
|
274
|
+
const t = this.theme;
|
|
275
|
+
const { kws } = this.query();
|
|
276
|
+
const visible = Math.max(1, Math.floor(height / 2));
|
|
277
|
+
if (this.selected < this.listOffset) this.listOffset = this.selected;
|
|
278
|
+
if (this.selected >= this.listOffset + visible) {
|
|
279
|
+
this.listOffset = this.selected - visible + 1;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const rows: string[] = [];
|
|
283
|
+
for (let i = 0; i < visible; i++) {
|
|
284
|
+
const idx = this.listOffset + i;
|
|
285
|
+
const s = this.filtered[idx];
|
|
286
|
+
if (!s) {
|
|
287
|
+
rows.push(" ".repeat(lw), " ".repeat(lw));
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const sel = idx === this.selected;
|
|
291
|
+
const time = fmtTime(s.time);
|
|
292
|
+
const cwdTail = s.cwd.replace(/\\/g, "/").split("/").slice(-2).join("/");
|
|
293
|
+
// 有关键词时显示命中的消息数
|
|
294
|
+
const hitBadge = kws.length
|
|
295
|
+
? ` ·${s.msgs.reduce((n, m) => n + (anyKw(m.text.toLowerCase(), kws) ? 1 : 0), 0)}`
|
|
296
|
+
: "";
|
|
297
|
+
const l1 = `${sel ? "›" : " "} ${time} ${cwdTail}${hitBadge}`;
|
|
298
|
+
// 第二行:有关键词时显示命中片段,否则显示首条消息
|
|
299
|
+
const snip = snippet(s.msgs, kws, lw);
|
|
300
|
+
const l2 = snip !== undefined
|
|
301
|
+
? ` ${highlight(snip, kws, t)}`
|
|
302
|
+
: ` ${s.first}${s.name ? ` [${s.name}]` : ""}`;
|
|
303
|
+
if (sel) {
|
|
304
|
+
rows.push(t.bg("selectedBg", padEndVisible(t.fg("accent", truncateToWidth(l1, lw)), lw)));
|
|
305
|
+
rows.push(t.bg("selectedBg", padEndVisible(truncateToWidth(l2, lw), lw)));
|
|
306
|
+
} else {
|
|
307
|
+
rows.push(padEndVisible(l1, lw));
|
|
308
|
+
rows.push(padEndVisible(t.fg("dim", truncateToWidth(l2, lw)), lw));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// 每项两行,height 是奇数时会少一行,补空行,否则最后一行右栏会顶到左边
|
|
312
|
+
while (rows.length < height) rows.push(" ".repeat(lw));
|
|
313
|
+
return rows.slice(0, height);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 用 pi 自己的 Markdown 组件渲染一条消息,和主界面里的对话长得一样;参数照抄 pi 的
|
|
317
|
+
// user-message / assistant-message 组件。渲染比较贵,按消息和宽度缓存,换关键词时不用重来
|
|
318
|
+
private renderMsg(m: PeekMsg, rw: number): string[] {
|
|
319
|
+
const hit = this.rendered.get(m);
|
|
320
|
+
if (hit && hit.w === rw) return hit.lines;
|
|
321
|
+
const t = this.theme;
|
|
322
|
+
const md =
|
|
323
|
+
m.role === "user"
|
|
324
|
+
? new Markdown(
|
|
325
|
+
m.text,
|
|
326
|
+
1,
|
|
327
|
+
0,
|
|
328
|
+
this.mdTheme,
|
|
329
|
+
{ color: (s) => t.fg("userMessageText", s), bgColor: (s) => t.bg("userMessageBg", s) },
|
|
330
|
+
{ preserveOrderedListMarkers: true, preserveBackslashEscapes: true },
|
|
331
|
+
)
|
|
332
|
+
: new Markdown(m.text, 1, 0, this.mdTheme);
|
|
333
|
+
const lines = md.render(rw);
|
|
334
|
+
this.rendered.set(m, { w: rw, lines });
|
|
335
|
+
return lines;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 右栏。有关键词时滚到第一个命中处,没有时滚到底部看最新消息
|
|
339
|
+
private buildPreview(rw: number): void {
|
|
340
|
+
const t = this.theme;
|
|
341
|
+
const s = this.filtered[this.selected];
|
|
342
|
+
const key = s ? `${s.path}|${this.getQuery()}|${rw}` : "none";
|
|
343
|
+
if (key === this.previewKey) return;
|
|
344
|
+
this.previewKey = key;
|
|
345
|
+
this.matchLines = [];
|
|
346
|
+
|
|
347
|
+
if (!s) {
|
|
348
|
+
this.previewLines = [t.fg("dim", msg("noMatch"))];
|
|
349
|
+
this.previewOffset = 0;
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const lines: string[] = [];
|
|
354
|
+
lines.push(
|
|
355
|
+
truncateToWidth(
|
|
356
|
+
t.fg("dim", `${s.cwd} • ${msg("msgCount", { n: s.msgs.length })}${s.name ? " • " + s.name : ""}`),
|
|
357
|
+
rw,
|
|
358
|
+
),
|
|
359
|
+
);
|
|
360
|
+
lines.push("");
|
|
361
|
+
|
|
362
|
+
const { kws } = this.query();
|
|
363
|
+
const MAX_MSGS = 500;
|
|
364
|
+
const truncated = s.msgs.length > MAX_MSGS;
|
|
365
|
+
const msgs = truncated ? s.msgs.slice(-MAX_MSGS) : s.msgs;
|
|
366
|
+
if (truncated) {
|
|
367
|
+
lines.push(t.fg("dim", msg("truncated", { n: MAX_MSGS })));
|
|
368
|
+
lines.push("");
|
|
369
|
+
}
|
|
370
|
+
if (kws.length && !msgs.some((m) => anyKw(m.text.toLowerCase(), kws))) {
|
|
371
|
+
const inTruncated = truncated && s.msgs.some((m) => anyKw(m.text.toLowerCase(), kws));
|
|
372
|
+
lines.push(
|
|
373
|
+
t.fg(
|
|
374
|
+
"warning",
|
|
375
|
+
inTruncated ? msg("hitInTruncated") : msg("hitOnlyMeta"),
|
|
376
|
+
),
|
|
377
|
+
);
|
|
378
|
+
lines.push("");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// 用户消息整块背景色,AI 消息只有一条细线标签
|
|
382
|
+
const fillLabel = (label: string): string => {
|
|
383
|
+
const w = visibleWidth(label);
|
|
384
|
+
return truncateToWidth(label + " " + "─".repeat(Math.max(2, rw - w - 1)), rw);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
for (const m of msgs) {
|
|
388
|
+
const isMatch = kws.length > 0 && anyKw(m.text.toLowerCase(), kws);
|
|
389
|
+
const head = lines.length;
|
|
390
|
+
if (m.role === "user") {
|
|
391
|
+
lines.push(
|
|
392
|
+
t.bg("userMessageBg", padEndVisible(t.bold(t.fg("accent", fillLabel(msg("you")))), rw)),
|
|
393
|
+
);
|
|
394
|
+
} else {
|
|
395
|
+
lines.push(t.fg("muted", fillLabel(msg("ai"))));
|
|
396
|
+
}
|
|
397
|
+
// 先渲染再高亮:往 markdown 源码里插转义码会把链接、代码块的语法弄坏
|
|
398
|
+
const before = this.matchLines.length;
|
|
399
|
+
for (const l of this.renderMsg(m, rw)) {
|
|
400
|
+
if (isMatch && lineHasKw(l, kws)) this.matchLines.push(lines.length);
|
|
401
|
+
lines.push(isMatch ? highlight(l, kws, t) : l);
|
|
402
|
+
}
|
|
403
|
+
// 关键词被折行拆开时哪一行都找不到,退回到消息标题行,别把这条消息漏掉
|
|
404
|
+
if (isMatch && this.matchLines.length === before) this.matchLines.push(head);
|
|
405
|
+
lines.push("");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
this.previewLines = lines;
|
|
409
|
+
|
|
410
|
+
if (this.matchLines.length) {
|
|
411
|
+
this.previewOffset = Math.max(0, this.matchLines[0] - 2);
|
|
412
|
+
} else {
|
|
413
|
+
this.previewOffset = Math.max(0, lines.length - this.bodyHeight());
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
render(width: number): string[] {
|
|
418
|
+
const H = this.bodyHeight();
|
|
419
|
+
const cacheKey = width * 1000 + H; // 宽或高变了都要重画
|
|
420
|
+
if (this.cachedWidth === cacheKey) return this.cachedLines;
|
|
421
|
+
const t = this.theme;
|
|
422
|
+
const lw = Math.max(26, Math.min(56, Math.floor(width * 0.4)));
|
|
423
|
+
const rw = Math.max(20, width - lw - 3);
|
|
424
|
+
|
|
425
|
+
const out: string[] = [];
|
|
426
|
+
|
|
427
|
+
// 头部:标题、范围、时间过滤、匹配数,然后是搜索框
|
|
428
|
+
const scopeLabel = this.scope === "all" ? msg("scopeAll") : msg("scopeCurrent");
|
|
429
|
+
const { sinceLabel } = this.query();
|
|
430
|
+
const head =
|
|
431
|
+
t.fg("accent", t.bold(msg("title"))) +
|
|
432
|
+
t.fg("dim", msg("scopeLabel")) +
|
|
433
|
+
t.fg("warning", scopeLabel) +
|
|
434
|
+
(sinceLabel ? t.fg("dim", msg("timeLabel")) + t.fg("warning", sinceLabel) : "") +
|
|
435
|
+
t.fg("dim", msg("matchCount", { n: this.filtered.length, total: this.all.length }));
|
|
436
|
+
out.push(truncateToWidth(head, width));
|
|
437
|
+
out.push(this.input.render(width)[0] ?? "");
|
|
438
|
+
out.push(t.fg("borderMuted", "─".repeat(width)));
|
|
439
|
+
|
|
440
|
+
// 双栏
|
|
441
|
+
this.buildPreview(rw);
|
|
442
|
+
const leftRows = this.buildList(H, lw);
|
|
443
|
+
|
|
444
|
+
const maxOff = Math.max(0, this.previewLines.length - H);
|
|
445
|
+
if (this.previewOffset > maxOff) this.previewOffset = maxOff;
|
|
446
|
+
const rightRows = this.previewLines.slice(this.previewOffset, this.previewOffset + H);
|
|
447
|
+
// 右上角:有命中时显示"第几处/共几处"(视口内第一个命中的序号),行数多时再加行号范围
|
|
448
|
+
const end = Math.min(this.previewOffset + H, this.previewLines.length);
|
|
449
|
+
let info = "";
|
|
450
|
+
if (this.matchLines.length) {
|
|
451
|
+
const i = this.matchLines.findIndex((l) => l >= this.previewOffset && l < end);
|
|
452
|
+
info += msg("hitPos", { k: i >= 0 ? String(i + 1) : "-", n: this.matchLines.length });
|
|
453
|
+
}
|
|
454
|
+
if (this.previewLines.length > H) info += ` (${this.previewOffset + 1}-${end}/${this.previewLines.length})`;
|
|
455
|
+
const scrollInfo = info ? t.fg("dim", info) : "";
|
|
456
|
+
|
|
457
|
+
for (let i = 0; i < H; i++) {
|
|
458
|
+
const l = leftRows[i] ?? " ".repeat(lw);
|
|
459
|
+
let r = rightRows[i] ?? "";
|
|
460
|
+
if (i === 0 && scrollInfo) {
|
|
461
|
+
r = truncateToWidth(r, Math.max(0, rw - visibleWidth(scrollInfo))) + scrollInfo;
|
|
462
|
+
}
|
|
463
|
+
// 左栏在 buildList 里已经 pad 到 lw,这里再 pad 会把带样式的行截坏
|
|
464
|
+
out.push(l + t.fg("borderMuted", " │ ") + padEndVisible(r, rw));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// 底部:重命名和删除确认时换成对应的操作行
|
|
468
|
+
out.push(t.fg("borderMuted", "─".repeat(width)));
|
|
469
|
+
if (this.renaming) {
|
|
470
|
+
const prefix = msg("renamePrefix");
|
|
471
|
+
const inputLine = this.renameInput.render(Math.max(10, width - visibleWidth(prefix)))[0] ?? "";
|
|
472
|
+
out.push(truncateToWidth(t.fg("warning", prefix) + inputLine, width));
|
|
473
|
+
} else if (this.confirmingDelete) {
|
|
474
|
+
const s = this.filtered[this.selected];
|
|
475
|
+
const desc = s ? `${fmtTime(s.time, true)} ${s.first.slice(0, 30)}` : "";
|
|
476
|
+
out.push(
|
|
477
|
+
truncateToWidth(
|
|
478
|
+
t.fg("error", t.bold(msg("confirmDelete", { desc }))),
|
|
479
|
+
width,
|
|
480
|
+
),
|
|
481
|
+
);
|
|
482
|
+
} else {
|
|
483
|
+
out.push(
|
|
484
|
+
truncateToWidth(
|
|
485
|
+
t.fg("dim", msg("footer")),
|
|
486
|
+
width,
|
|
487
|
+
),
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
this.cachedWidth = cacheKey;
|
|
492
|
+
this.cachedLines = out;
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
invalidate(): void {
|
|
497
|
+
this.cachedWidth = -1;
|
|
498
|
+
this.input.invalidate();
|
|
499
|
+
}
|
|
500
|
+
}
|
package/src/query.ts
CHANGED
|
@@ -96,6 +96,11 @@ function applySgr(st: Sgr, seq: string): void {
|
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// 渲染后的一行(可带 ANSI)里有没有关键词,预览按行记命中用
|
|
100
|
+
export function lineHasKw(line: string, kws: string[]): boolean {
|
|
101
|
+
return kws.length > 0 && anyKw(line.replace(ESC_RE, "").toLowerCase(), kws);
|
|
102
|
+
}
|
|
103
|
+
|
|
99
104
|
// 在一行文字里高亮关键词,返回带 ANSI 的字符串。
|
|
100
105
|
// 输入可以是已经渲染过、带样式的行:转义序列不参与匹配也不会被切断;高亮结束时把原来的
|
|
101
106
|
// 前景色 / 粗体 / 下划线恢复回去,所以放在标题、链接等样式里面也不会把后面的文字弄丢样式。
|
package/src/sessions.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFileSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, existsSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
@@ -167,19 +167,43 @@ export function renameSession(path: string, name: string): void {
|
|
|
167
167
|
appendFileSync(path, sep + JSON.stringify(entry) + "\n", "utf8");
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
export type DeleteResult = "trash" | "rm" | false;
|
|
171
|
+
|
|
172
|
+
// 各平台把文件送进回收站的命令,按顺序试。trash(trash-cli / brew trash)放最前面,装了就说明用户想用它;
|
|
173
|
+
// 后面是系统自带的途径:Windows 走 PowerShell 的 VisualBasic FileSystem,macOS 走 Finder,Linux 走 gio / trash-put
|
|
174
|
+
export function trashCommands(path: string, platform: NodeJS.Platform = process.platform): [string, string[]][] {
|
|
175
|
+
const cmds: [string, string[]][] = [["trash", [path]]];
|
|
176
|
+
if (platform === "win32") {
|
|
177
|
+
const script =
|
|
178
|
+
"Add-Type -AssemblyName Microsoft.VisualBasic; " +
|
|
179
|
+
`[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile('${path.replace(/'/g, "''")}', 'OnlyErrorDialogs', 'SendToRecycleBin')`;
|
|
180
|
+
const args = ["-NoProfile", "-NonInteractive", "-Command", script];
|
|
181
|
+
cmds.push(["powershell", args], ["pwsh", args]);
|
|
182
|
+
} else if (platform === "darwin") {
|
|
183
|
+
cmds.push(["osascript", ["-e", `tell application "Finder" to delete POSIX file "${path.replace(/[\\"]/g, "\\$&")}"`]]);
|
|
184
|
+
} else {
|
|
185
|
+
cmds.push(["gio", ["trash", path]], ["trash-put", [path]]);
|
|
186
|
+
}
|
|
187
|
+
return cmds;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 优先进回收站(能恢复),每个途径都不行再直接删。exec 由调用方传入,方便测试;
|
|
191
|
+
// 命令退出码为 0 但文件还在也当失败,继续试下一个
|
|
171
192
|
export async function deleteSession(
|
|
172
193
|
path: string,
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
194
|
+
exec: (cmd: string, args: string[]) => Promise<{ code: number }>,
|
|
195
|
+
platform: NodeJS.Platform = process.platform,
|
|
196
|
+
): Promise<DeleteResult> {
|
|
197
|
+
for (const [cmd, args] of trashCommands(path, platform)) {
|
|
198
|
+
try {
|
|
199
|
+
const r = await exec(cmd, args);
|
|
200
|
+
if (r.code === 0 && !existsSync(path)) return "trash";
|
|
201
|
+
} catch {
|
|
202
|
+
}
|
|
179
203
|
}
|
|
180
204
|
try {
|
|
181
205
|
rmSync(path, { force: true });
|
|
182
|
-
return
|
|
206
|
+
return "rm";
|
|
183
207
|
} catch {
|
|
184
208
|
return false;
|
|
185
209
|
}
|