pi-session-peek 0.5.0 → 0.5.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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1
4
+
5
+ - 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.
6
+ - While a delete or rename is still running, `Ctrl+D`, `Ctrl+R`, `Enter`, `Ctrl+O` and double-click are ignored, so the same file cannot be deleted twice or resumed mid-delete.
7
+ - Delete: if a trash command times out but the file is already gone, the result is reported as "moved to trash" instead of falling through to a permanent delete.
8
+ - The scan follows the session directory pi is actually using (`--session-dir`, `$PI_CODING_AGENT_SESSION_DIR` or `sessionDir` in settings), and forks are written there too. With the default layout all projects are still listed.
9
+ - The parse cache is keyed on file size as well as mtime, so two quick writes within the same timestamp tick no longer serve stale content.
10
+ - Rename strips newlines from the new name, matching pi's own `/name`, so the appended record always stays on one line.
11
+
3
12
  ## 0.5.0
4
13
 
5
14
  - Mouse support in both panes. Click a session to select it, double-click to resume, wheel over the list to move the selection, wheel over the preview to scroll it (`Alt` for five times faster), click the scope label to toggle it, click the search box or rename line to move the cursor. Pi only enables mouse reporting in fullscreen mode, so in the regular mode the picker turns it on itself while open and locates itself on screen with a cursor position query; the terminal's own text selection is unavailable meanwhile (usually `Shift` restores it). `PI_SESSION_PEEK_MOUSE=0` disables it.
package/README.md CHANGED
@@ -128,7 +128,7 @@ On Windows PowerShell: `$env:PI_SESSION_PEEK_LANG = "en"` before starting pi. Th
128
128
  - The mouse needs a terminal that supports SGR mouse reporting and the cursor position report (`CSI 6 n`). Windows Terminal, iTerm2, kitty, WezTerm, Alacritty, GNOME Terminal and VS Code all do; the legacy Windows console does not.
129
129
  - The preview shows the last 500 messages of very long sessions. Hits in earlier messages are still counted and announced.
130
130
  - `pi --peek` without a value is rejected by pi at startup. Use `pi --rp` to open without a keyword.
131
- - Only sessions under `~/.pi/agent/sessions` (or `$PI_CODING_AGENT_DIR/sessions`) are scanned.
131
+ - With the default layout every project under `~/.pi/agent/sessions` (or `$PI_CODING_AGENT_DIR/sessions`) is scanned. If pi runs with a custom session directory (`--session-dir`, `$PI_CODING_AGENT_SESSION_DIR` or `sessionDir` in settings), only that directory is scanned and forks are written there.
132
132
 
133
133
  ## 🗂️ Package layout
134
134
 
package/README.zh-CN.md CHANGED
@@ -128,7 +128,7 @@ Windows PowerShell 里先执行 `$env:PI_SESSION_PEEK_LANG = "en"` 再启动 pi
128
128
  - 鼠标需要终端支持 SGR 鼠标上报和光标位置回报(`CSI 6 n`)。Windows Terminal、iTerm2、kitty、WezTerm、Alacritty、GNOME Terminal、VS Code 都支持,老式 Windows 控制台不支持。
129
129
  - 特别长的会话预览只显示最后 500 条消息,更早的命中仍会计数并提示。
130
130
  - `pi --peek` 不带值会被 pi 在启动时拒绝,不带关键词请用 `pi --rp`。
131
- - 只扫描 `~/.pi/agent/sessions`(或 `$PI_CODING_AGENT_DIR/sessions`)下的会话。
131
+ - pi 默认布局下扫描 `~/.pi/agent/sessions`(或 `$PI_CODING_AGENT_DIR/sessions`)下所有项目的会话。pi 用了自定义会话目录(`--session-dir`、`$PI_CODING_AGENT_SESSION_DIR` 或 settings 里的 `sessionDir`)时只扫描那个目录,分叉也写到那里。
132
132
 
133
133
  ## 🗂️ 目录结构
134
134
 
package/index.ts CHANGED
@@ -3,7 +3,7 @@ import { basename } from "node:path";
3
3
  import { msg } from "./src/i18n.ts";
4
4
  import { attachMouse } from "./src/mouse.ts";
5
5
  import { PeekComponent } from "./src/peek-component.ts";
6
- import { deleteSession, renameSession, scanSessions, type PeekSession } from "./src/sessions.ts";
6
+ import { deleteSession, renameSession, scanRoot, scanSessions, sessionsDir, type PeekSession } from "./src/sessions.ts";
7
7
 
8
8
  // 这里只做注册:/peek 命令、--peek / --rp 启动参数,以及把删除 / 重命名 / 分叉注入给组件
9
9
 
@@ -55,7 +55,10 @@ export default function (pi: ExtensionAPI) {
55
55
 
56
56
  // 当前正在用的会话不列出来
57
57
  const currentFile = ctx.sessionManager.getSessionFile();
58
- const all = (await scanSessions()).filter((s) => s.path !== currentFile);
58
+ // 扫描范围跟着 pi 实际在用的会话目录走(--session-dir / 环境变量 / settings.json 都在里面)
59
+ const root = scanRoot(ctx.sessionManager.getSessionDir());
60
+ const customDir = root !== sessionsDir() ? root : undefined;
61
+ const all = (await scanSessions(root)).filter((s) => s.path !== currentFile);
59
62
  if (!all.length) {
60
63
  ctx.ui.notify(msg("noSessions"), "info");
61
64
  return;
@@ -118,7 +121,8 @@ export default function (pi: ExtensionAPI) {
118
121
  if (picked.action === "fork") {
119
122
  // 和 pi --fork 一样:把全部记录复制到新文件,header 里记下 parentSession
120
123
  try {
121
- const forked = SessionManager.forkFrom(picked.s.path, ctx.cwd);
124
+ // 自定义了会话目录时分叉也放进去,别写回默认目录
125
+ const forked = SessionManager.forkFrom(picked.s.path, ctx.cwd, customDir);
122
126
  target = forked.getSessionFile() ?? target;
123
127
  ctx.ui.notify(msg("forked", { file: basename(target) }), "info");
124
128
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-session-peek",
3
- "version": "0.5.0",
3
+ "version": "0.5.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",
@@ -39,6 +39,7 @@ export class PeekComponent implements Component, Focusable {
39
39
  private rendered = new WeakMap<PeekMsg, { w: number; lines: string[] }>(); // 每条消息渲染好的行,按宽度缓存
40
40
  private confirmingDelete = false; // Ctrl+D 之后等待确认
41
41
  private renaming = false; // Ctrl+R 之后正在输入名字
42
+ private busy = false; // 删除 / 重命名的异步回调还没回来:期间不能再删、改名、进入或分叉,免得对同一个文件动两次
42
43
  private renameInput: Input;
43
44
  private cachedWidth = -1;
44
45
  private cachedLines: string[] = [];
@@ -162,13 +163,19 @@ export class PeekComponent implements Component, Focusable {
162
163
  this.renaming = false;
163
164
  this.focused = this._focused;
164
165
  this.invalidate();
165
- if (s && name && this.onRename) {
166
+ if (s && name && this.onRename && !this.busy) {
167
+ this.busy = true;
166
168
  void this.onRename(s, name).then((ok) => {
167
169
  if (ok) {
168
170
  s.name = name;
169
171
  // 名字参与搜索,改完可能就不再匹配当前关键词了
170
172
  this.refilter(true);
171
173
  }
174
+ }).catch(() => {
175
+ // 回调自己负责提示;这里吞掉,别变成 unhandled rejection
176
+ }).finally(() => {
177
+ // 放在 finally:回调抛了错也要解锁,否则删除 / 改名 / 进入全部永久失效
178
+ this.busy = false;
172
179
  this.invalidate();
173
180
  this.requestRender?.();
174
181
  });
@@ -191,7 +198,8 @@ export class PeekComponent implements Component, Focusable {
191
198
  this.invalidate();
192
199
  if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
193
200
  const s = this.filtered[this.selected];
194
- if (s && this.onDelete) {
201
+ if (s && this.onDelete && !this.busy) {
202
+ this.busy = true;
195
203
  void this.onDelete(s).then((ok) => {
196
204
  if (ok) {
197
205
  let i = this.all.indexOf(s);
@@ -203,6 +211,9 @@ export class PeekComponent implements Component, Focusable {
203
211
  this.previewKey = "";
204
212
  this.clearSelection();
205
213
  }
214
+ }).catch(() => {
215
+ }).finally(() => {
216
+ this.busy = false;
206
217
  this.invalidate();
207
218
  this.requestRender?.();
208
219
  });
@@ -220,7 +231,7 @@ export class PeekComponent implements Component, Focusable {
220
231
  return;
221
232
  }
222
233
  if (matchesKey(data, Key.ctrl("d"))) {
223
- if (this.filtered.length && this.onDelete) {
234
+ if (this.filtered.length && this.onDelete && !this.busy) {
224
235
  this.confirmingDelete = true;
225
236
  this.invalidate();
226
237
  }
@@ -228,7 +239,7 @@ export class PeekComponent implements Component, Focusable {
228
239
  }
229
240
  if (matchesKey(data, Key.ctrl("r"))) {
230
241
  const s = this.filtered[this.selected];
231
- if (s && this.onRename) {
242
+ if (s && this.onRename && !this.busy) {
232
243
  this.renaming = true;
233
244
  this.renameInput.setValue(s.name || "");
234
245
  this.focused = this._focused;
@@ -295,12 +306,12 @@ export class PeekComponent implements Component, Focusable {
295
306
  }
296
307
  if (matchesKey(data, Key.enter)) {
297
308
  const s = this.filtered[this.selected];
298
- if (s) this.onResume?.(s);
309
+ if (s && !this.busy) this.onResume?.(s);
299
310
  return;
300
311
  }
301
312
  if (matchesKey(data, Key.ctrl("o"))) {
302
313
  const s = this.filtered[this.selected];
303
- if (s) this.onFork?.(s);
314
+ if (s && !this.busy) this.onFork?.(s);
304
315
  return;
305
316
  }
306
317
  // 其余按键交给搜索框
@@ -374,7 +385,7 @@ export class PeekComponent implements Component, Focusable {
374
385
  // 单击在 press 里已经选中了,双击才进入
375
386
  const s = this.filtered[itemAt()];
376
387
  if (!s) return undefined;
377
- if (!this.renaming && (ev.clickCount ?? 1) >= 2) this.onResume?.(s);
388
+ if (!this.renaming && !this.busy && (ev.clickCount ?? 1) >= 2) this.onResume?.(s);
378
389
  return { handled: true, render: false };
379
390
  }
380
391
  if (ev.type !== "press") return undefined;
package/src/sessions.ts CHANGED
@@ -2,7 +2,8 @@ import { appendFileSync, existsSync, readFileSync, rmSync } from "node:fs";
2
2
  import { readdir, readFile, stat } from "node:fs/promises";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
- import { join } from "node:path";
5
+ import { dirname, join } from "node:path";
6
+ import { normPath } from "./text.ts";
6
7
 
7
8
  // 读 ~/.pi/agent/sessions 下的会话 JSONL,按 mtime 缓存;重命名和删除也在这里。
8
9
  // 搜索不预存小写全文:会话正文只在 msgs 里放一份,匹配时用不区分大小写的正则直接扫(见 query.ts 的 matchesSession)
@@ -26,18 +27,29 @@ export interface PeekSession {
26
27
  first: string; // 首条消息,列表里显示用
27
28
  msgs: PeekMsg[];
28
29
  mtime: number;
30
+ size: number; // 和 mtime 一起当缓存键:mtime 粒度粗的文件系统上连续两次写入可能同一个 mtime
29
31
  }
30
32
 
31
33
  const sessionCache = new Map<string, PeekSession>();
32
34
  const READ_CONCURRENCY = 16; // 同时读的文件数,太高会把 libuv 线程池排满
33
35
 
34
- // 和 pi 一样认 PI_CODING_AGENT_DIR
35
- function sessionsDir(): string {
36
- const env = process.env.PI_CODING_AGENT_DIR;
37
- const agent = env ? (env.startsWith("~") ? join(homedir(), env.slice(1)) : env) : join(homedir(), ".pi", "agent");
36
+ const expandTilde = (p: string) => (p.startsWith("~") ? join(homedir(), p.slice(1)) : p);
37
+
38
+ // 默认布局下所有项目的会话根目录:和 pi 一样认 PI_CODING_AGENT_DIR,下面每个 cwd 一个子目录
39
+ export function sessionsDir(env: NodeJS.ProcessEnv = process.env): string {
40
+ const agent = env.PI_CODING_AGENT_DIR ? expandTilde(env.PI_CODING_AGENT_DIR) : join(homedir(), ".pi", "agent");
38
41
  return join(agent, "sessions");
39
42
  }
40
43
 
44
+ /** 要扫描的根目录。pi 的 getSessionDir() 是参数、环境变量、settings.json 合并后的结果:
45
+ * 默认布局时它是 <root>/sessions/<按 cwd 编码的子目录>,取上一级才能看到所有项目;
46
+ * 用户自定义了目录(--session-dir 等)时就是那个目录本身,直接扫它 */
47
+ export function scanRoot(currentSessionDir: string | undefined, env: NodeJS.ProcessEnv = process.env): string {
48
+ const root = sessionsDir(env);
49
+ if (!currentSessionDir) return root;
50
+ return normPath(dirname(currentSessionDir)) === normPath(root) ? root : currentSessionDir;
51
+ }
52
+
41
53
  async function walkJsonl(dir: string, out: string[]): Promise<void> {
42
54
  let entries;
43
55
  try {
@@ -52,13 +64,20 @@ async function walkJsonl(dir: string, out: string[]): Promise<void> {
52
64
  }
53
65
  }
54
66
 
67
+ // 终端转义序列(CSI / OSC / DCS / APC 等)和除 \t \n 外的 C0 控制字符。正文里带这些会原样写到屏幕上:
68
+ // 排版错乱、宽度算错,OSC 52 之类的还能改剪贴板 / 窗口标题
69
+ const CONTROL_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|[\]P_^X][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-Z\\-_])|[\x00-\x08\x0b-\x1f\x7f]/g;
70
+ export const sanitizeText = (s: string) => s.replace(/\r\n?/g, "\n").replace(CONTROL_RE, "");
71
+
55
72
  function extractText(content: unknown): string {
56
- if (typeof content === "string") return content;
73
+ if (typeof content === "string") return sanitizeText(content);
57
74
  if (!Array.isArray(content)) return "";
58
- return content
59
- .filter((c: any) => c?.type === "text" && typeof c.text === "string")
60
- .map((c: any) => c.text)
61
- .join("\n");
75
+ return sanitizeText(
76
+ content
77
+ .filter((c: any) => c?.type === "text" && typeof c.text === "string")
78
+ .map((c: any) => c.text)
79
+ .join("\n"),
80
+ );
62
81
  }
63
82
 
64
83
  // 工具调用的主参数:按常见键名找第一个字符串,都没有就取第一个字符串参数。不存结果,bash 输出动辄几十 KB
@@ -71,7 +90,7 @@ export function toolSummary(args: unknown): string {
71
90
  let v = TOOL_ARG_KEYS.map((k) => o[k]).find((x) => typeof x === "string" && x.trim());
72
91
  if (typeof v !== "string") v = Object.values(o).find((x) => typeof x === "string" && (x as string).trim());
73
92
  if (typeof v !== "string") return "";
74
- return v.replace(/\s+/g, " ").trim().slice(0, TOOL_SUMMARY_MAX);
93
+ return sanitizeText(v).replace(/\s+/g, " ").trim().slice(0, TOOL_SUMMARY_MAX);
75
94
  }
76
95
 
77
96
  function extractTools(content: unknown): PeekTool[] {
@@ -81,7 +100,7 @@ function extractTools(content: unknown): PeekTool[] {
81
100
  .map((c: any) => ({ name: c.name, summary: toolSummary(c.arguments) }));
82
101
  }
83
102
 
84
- function parseSession(path: string, raw: string, mtime: number): PeekSession | null {
103
+ function parseSession(path: string, raw: string, mtime: number, size: number): PeekSession | null {
85
104
  let cwd = "";
86
105
  let time = "";
87
106
  let name = "";
@@ -98,7 +117,7 @@ function parseSession(path: string, raw: string, mtime: number): PeekSession | n
98
117
  }
99
118
  if (line.includes('"type":"session_info"')) {
100
119
  const o = JSON.parse(line);
101
- if (typeof o.name === "string" && o.name) name = o.name;
120
+ if (typeof o.name === "string") name = sanitizeText(o.name).trim(); // 空名字是显式清掉
102
121
  continue;
103
122
  }
104
123
  // 先用子串粗筛,省掉大部分行的 JSON.parse
@@ -127,6 +146,7 @@ function parseSession(path: string, raw: string, mtime: number): PeekSession | n
127
146
  name,
128
147
  msgs,
129
148
  mtime,
149
+ size,
130
150
  first: firstText.text.replace(/\s+/g, " ").slice(0, 80),
131
151
  };
132
152
  }
@@ -134,29 +154,32 @@ function parseSession(path: string, raw: string, mtime: number): PeekSession | n
134
154
  // 单个文件:mtime 没变直接用缓存,否则重新读和解析
135
155
  async function loadSession(file: string): Promise<PeekSession | null> {
136
156
  let mtime: number;
157
+ let size: number;
137
158
  try {
138
- mtime = (await stat(file)).mtimeMs;
159
+ const st = await stat(file);
160
+ mtime = st.mtimeMs;
161
+ size = st.size;
139
162
  } catch {
140
163
  return null;
141
164
  }
142
165
  const hit = sessionCache.get(file);
143
- if (hit && hit.mtime === mtime) return hit;
166
+ if (hit && hit.mtime === mtime && hit.size === size) return hit;
144
167
  let raw: string;
145
168
  try {
146
169
  raw = await readFile(file, "utf8");
147
170
  } catch {
148
171
  return null;
149
172
  }
150
- const parsed = parseSession(file, raw, mtime);
173
+ const parsed = parseSession(file, raw, mtime, size);
151
174
  if (parsed) sessionCache.set(file, parsed);
152
175
  return parsed;
153
176
  }
154
177
 
155
178
  /** 全部会话,按最后修改时间倒序;文件没变的直接用缓存。
156
179
  * 目录遍历和文件读取都走异步 I/O,几百个会话也不会把 TUI 卡住 */
157
- export async function scanSessions(): Promise<PeekSession[]> {
180
+ export async function scanSessions(root: string = sessionsDir()): Promise<PeekSession[]> {
158
181
  const files: string[] = [];
159
- await walkJsonl(sessionsDir(), files);
182
+ await walkJsonl(root, files);
160
183
 
161
184
  const out: PeekSession[] = [];
162
185
  let next = 0;
@@ -179,6 +202,7 @@ export async function scanSessions(): Promise<PeekSession[]> {
179
202
 
180
203
  // 和 pi 自带的 /name 写同一种 session_info 记录,parentId 用文件里最后一条记录的 id
181
204
  export function renameSession(path: string, name: string): void {
205
+ name = sanitizeText(name).replace(/\n+/g, " ").trim(); // 和 pi 的 appendSessionInfo 一样,换行不能进 JSONL 的一行
182
206
  const raw = readFileSync(path, "utf8");
183
207
  const lines = raw.split("\n");
184
208
  let parentId: string | null = null;
@@ -227,18 +251,21 @@ export function trashCommands(path: string, platform: NodeJS.Platform = process.
227
251
  }
228
252
 
229
253
  // 优先进回收站(能恢复),每个途径都不行再直接删。exec 由调用方传入,方便测试;
230
- // 命令退出码为 0 但文件还在也当失败,继续试下一个
254
+ // 命令退出码为 0 但文件还在也当失败,继续试下一个。命令超时被杀但其实已经把文件移走了的,
255
+ // 事后再看一眼文件,别把"已进回收站"报成"永久删除"
231
256
  export async function deleteSession(
232
257
  path: string,
233
258
  exec: (cmd: string, args: string[]) => Promise<{ code: number }>,
234
259
  platform: NodeJS.Platform = process.platform,
235
260
  ): Promise<DeleteResult> {
261
+ const wasThere = existsSync(path);
236
262
  for (const [cmd, args] of trashCommands(path, platform)) {
237
263
  try {
238
264
  const r = await exec(cmd, args);
239
265
  if (r.code === 0 && !existsSync(path)) return "trash";
240
266
  } catch {
241
267
  }
268
+ if (wasThere && !existsSync(path)) return "trash";
242
269
  }
243
270
  try {
244
271
  rmSync(path, { force: true });