pi-session-peek 0.3.0 → 0.3.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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1
4
+
5
+ - `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.
6
+ - Sessions no longer keep a second, lowercased copy of their full text for searching. Keyword filtering runs case-insensitive regexes over the messages directly, which roughly halves memory per cached session and is faster than the old `toLowerCase().includes()` scan.
7
+
3
8
  ## 0.3.0
4
9
 
5
10
  - 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.
package/index.ts CHANGED
@@ -54,7 +54,7 @@ export default function (pi: ExtensionAPI) {
54
54
 
55
55
  // 当前正在用的会话不列出来
56
56
  const currentFile = ctx.sessionManager.getSessionFile();
57
- const all = scanSessions().filter((s) => s.path !== currentFile);
57
+ const all = (await scanSessions()).filter((s) => s.path !== currentFile);
58
58
  if (!all.length) {
59
59
  ctx.ui.notify(msg("noSessions"), "info");
60
60
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-session-peek",
3
- "version": "0.3.0",
3
+ "version": "0.3.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",
@@ -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 { anyKw, highlight, lineHasKw, parseQuery, snippet, type ParsedQuery } from "./query.ts";
13
+ import { anyKw, highlight, kwRegExps, lineHasKw, matchesSession, parseQuery, 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
 
@@ -90,7 +90,10 @@ export class PeekComponent implements Component, Focusable {
90
90
  let out = this.all;
91
91
  if (this.scope === "current") out = out.filter((s) => this.inCurrentTree(s));
92
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)));
93
+ if (kws.length) {
94
+ const res = kwRegExps(kws);
95
+ out = out.filter((s) => matchesSession(s, res));
96
+ }
94
97
  this.filtered = out;
95
98
  const idx = prev ? out.indexOf(prev) : -1;
96
99
  this.selected = idx >= 0 ? idx : keepSelection ? Math.min(this.selected, Math.max(0, out.length - 1)) : 0;
@@ -117,7 +120,6 @@ export class PeekComponent implements Component, Focusable {
117
120
  if (ok) {
118
121
  s.name = name;
119
122
  // 名字参与搜索,改完可能就不再匹配当前关键词了
120
- s.searchText = (s.msgs.map((m) => m.text).join(" ") + " " + name + " " + s.cwd).toLowerCase();
121
123
  this.refilter(true);
122
124
  }
123
125
  this.invalidate();
package/src/query.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { msg, type MsgKey } from "./i18n.ts";
2
- import type { PeekMsg } from "./sessions.ts";
2
+ import type { PeekMsg, PeekSession } from "./sessions.ts";
3
3
 
4
4
  // 搜索框语法:空格分隔多个词,都要命中;@7d / @24h / @2w / @1m 限定最近活动时间
5
5
 
@@ -33,6 +33,16 @@ export function parseQuery(q: string): ParsedQuery {
33
33
  return { kws, since, sinceLabel };
34
34
  }
35
35
 
36
+ // 关键词转成不区分大小写的正则,列表过滤用。正文不预存小写副本,直接在原文上匹配
37
+ export function kwRegExps(kws: string[]): RegExp[] {
38
+ return kws.map((k) => new RegExp(k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"));
39
+ }
40
+
41
+ // 每个关键词都要在会话名、cwd 或某条消息里出现
42
+ export function matchesSession(s: PeekSession, res: RegExp[]): boolean {
43
+ return res.every((re) => re.test(s.name) || re.test(s.cwd) || s.msgs.some((m) => re.test(m.text)));
44
+ }
45
+
36
46
  // 单条消息用:命中任意一个关键词就算
37
47
  export function anyKw(lower: string, kws: string[]): boolean {
38
48
  for (const k of kws) if (lower.includes(k)) return true;
package/src/sessions.ts CHANGED
@@ -1,9 +1,11 @@
1
- import { appendFileSync, existsSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
1
+ import { appendFileSync, existsSync, readFileSync, rmSync } from "node:fs";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
2
3
  import { randomBytes } from "node:crypto";
3
4
  import { homedir } from "node:os";
4
5
  import { join } from "node:path";
5
6
 
6
- // 读 ~/.pi/agent/sessions 下的会话 JSONL,按 mtime 缓存;重命名和删除也在这里
7
+ // 读 ~/.pi/agent/sessions 下的会话 JSONL,按 mtime 缓存;重命名和删除也在这里。
8
+ // 搜索不预存小写全文:会话正文只在 msgs 里放一份,匹配时用不区分大小写的正则直接扫(见 query.ts 的 matchesSession)
7
9
 
8
10
  export interface PeekMsg {
9
11
  role: "user" | "assistant";
@@ -17,11 +19,11 @@ export interface PeekSession {
17
19
  name: string; // 最后一条 session_info 里的名字,没有则为空
18
20
  first: string; // 首条消息,列表里显示用
19
21
  msgs: PeekMsg[];
20
- searchText: string; // 正文 + 会话名 + cwd,已小写
21
22
  mtime: number;
22
23
  }
23
24
 
24
25
  const sessionCache = new Map<string, PeekSession>();
26
+ const READ_CONCURRENCY = 16; // 同时读的文件数,太高会把 libuv 线程池排满
25
27
 
26
28
  // 和 pi 一样认 PI_CODING_AGENT_DIR
27
29
  function sessionsDir(): string {
@@ -30,17 +32,17 @@ function sessionsDir(): string {
30
32
  return join(agent, "sessions");
31
33
  }
32
34
 
33
- function* walkJsonl(dir: string): Generator<string> {
35
+ async function walkJsonl(dir: string, out: string[]): Promise<void> {
34
36
  let entries;
35
37
  try {
36
- entries = readdirSync(dir, { withFileTypes: true });
38
+ entries = await readdir(dir, { withFileTypes: true });
37
39
  } catch {
38
40
  return;
39
41
  }
40
42
  for (const e of entries) {
41
43
  const p = join(dir, e.name);
42
- if (e.isDirectory()) yield* walkJsonl(p);
43
- else if (e.name.endsWith(".jsonl")) yield p;
44
+ if (e.isDirectory()) await walkJsonl(p, out);
45
+ else if (e.name.endsWith(".jsonl")) out.push(p);
44
46
  }
45
47
  }
46
48
 
@@ -53,13 +55,7 @@ function extractText(content: unknown): string {
53
55
  .join("\n");
54
56
  }
55
57
 
56
- function parseSession(path: string, mtime: number): PeekSession | null {
57
- let raw: string;
58
- try {
59
- raw = readFileSync(path, "utf8");
60
- } catch {
61
- return null;
62
- }
58
+ function parseSession(path: string, raw: string, mtime: number): PeekSession | null {
63
59
  let cwd = "";
64
60
  let time = "";
65
61
  let name = "";
@@ -101,36 +97,48 @@ function parseSession(path: string, mtime: number): PeekSession | null {
101
97
  msgs,
102
98
  mtime,
103
99
  first: msgs[0].text.replace(/\s+/g, " ").slice(0, 80),
104
- searchText: (msgs.map((m) => m.text).join(" ") + " " + name + " " + cwd).toLowerCase(),
105
100
  };
106
101
  }
107
102
 
108
- /** 全部会话,按最后修改时间倒序;文件没变的直接用缓存 */
109
- export function scanSessions(): PeekSession[] {
110
- const seen = new Set<string>();
111
- const out: PeekSession[] = [];
103
+ // 单个文件:mtime 没变直接用缓存,否则重新读和解析
104
+ async function loadSession(file: string): Promise<PeekSession | null> {
105
+ let mtime: number;
106
+ try {
107
+ mtime = (await stat(file)).mtimeMs;
108
+ } catch {
109
+ return null;
110
+ }
111
+ const hit = sessionCache.get(file);
112
+ if (hit && hit.mtime === mtime) return hit;
113
+ let raw: string;
114
+ try {
115
+ raw = await readFile(file, "utf8");
116
+ } catch {
117
+ return null;
118
+ }
119
+ const parsed = parseSession(file, raw, mtime);
120
+ if (parsed) sessionCache.set(file, parsed);
121
+ return parsed;
122
+ }
112
123
 
113
- for (const file of walkJsonl(sessionsDir())) {
114
- seen.add(file);
115
- let mtime = 0;
116
- try {
117
- mtime = statSync(file).mtimeMs;
118
- } catch {
119
- continue;
120
- }
121
- const hit = sessionCache.get(file);
122
- if (hit && hit.mtime === mtime) {
123
- out.push(hit);
124
- continue;
125
- }
126
- const parsed = parseSession(file, mtime);
127
- if (parsed) {
128
- sessionCache.set(file, parsed);
129
- out.push(parsed);
124
+ /** 全部会话,按最后修改时间倒序;文件没变的直接用缓存。
125
+ * 目录遍历和文件读取都走异步 I/O,几百个会话也不会把 TUI 卡住 */
126
+ export async function scanSessions(): Promise<PeekSession[]> {
127
+ const files: string[] = [];
128
+ await walkJsonl(sessionsDir(), files);
129
+
130
+ const out: PeekSession[] = [];
131
+ let next = 0;
132
+ const worker = async () => {
133
+ while (next < files.length) {
134
+ const s = await loadSession(files[next++]);
135
+ if (s) out.push(s);
130
136
  }
131
- }
137
+ };
138
+ await Promise.all(Array.from({ length: Math.min(READ_CONCURRENCY, files.length) }, worker));
132
139
 
133
140
  // 文件已经不在了的,从缓存里去掉
141
+ const seen = new Set(files);
134
142
  for (const key of [...sessionCache.keys()]) {
135
143
  if (!seen.has(key)) sessionCache.delete(key);
136
144
  }