pi-session-peek 0.5.0 → 0.5.2

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/src/preview.ts ADDED
@@ -0,0 +1,139 @@
1
+ import { Markdown, truncateToWidth, visibleWidth, type MarkdownTheme } from "@earendil-works/pi-tui";
2
+ import { msg } from "./i18n.ts";
3
+ import { anyMatch, highlight, lineHasMatch, type RoleTerms } from "./query.ts";
4
+ import type { PeekMsg, PeekSession } from "./sessions.ts";
5
+ import { padEndVisible } from "./text.ts";
6
+ import type { PeekTheme } from "./theme.ts";
7
+
8
+ // 右栏:把一个会话渲染成预览行,全是纯函数。滚动偏移和"要不要重建"的缓存键由组件持有
9
+
10
+ const MAX_MSGS = 500; // 超长会话只画最后这么多条,更早的命中仍计数并提示
11
+
12
+ /** 每条消息渲染好的行,按宽度缓存。键是消息对象本身,会话从缓存里掉了它也跟着掉 */
13
+ export type RenderCache = WeakMap<PeekMsg, { w: number; lines: string[] }>;
14
+
15
+ // 用 pi 自己的 Markdown 组件渲染一条消息,和主界面里的对话长得一样;参数照抄 pi 的
16
+ // user-message / assistant-message 组件。渲染比较贵,按消息和宽度缓存,换关键词时不用重来
17
+ export function renderMsg(m: PeekMsg, rw: number, t: PeekTheme, mdTheme: MarkdownTheme, cache: RenderCache): string[] {
18
+ const hit = cache.get(m);
19
+ if (hit && hit.w === rw) return hit.lines;
20
+ const md =
21
+ m.role === "user"
22
+ ? new Markdown(
23
+ m.text,
24
+ 1,
25
+ 0,
26
+ mdTheme,
27
+ { color: (s) => t.fg("userMessageText", s), bgColor: (s) => t.bg("userMessageBg", s) },
28
+ { preserveOrderedListMarkers: true, preserveBackslashEscapes: true },
29
+ )
30
+ : new Markdown(m.text, 1, 0, mdTheme);
31
+ const lines = md.render(rw);
32
+ cache.set(m, { w: rw, lines });
33
+ return lines;
34
+ }
35
+
36
+ export interface Preview {
37
+ lines: string[];
38
+ matchLines: number[]; // 含关键词的行(升序),Ctrl+N / Ctrl+P 用
39
+ }
40
+
41
+ // 右栏内容。没有会话时只有一行提示
42
+ export function buildPreview(
43
+ s: PeekSession | undefined,
44
+ rt: RoleTerms,
45
+ rw: number,
46
+ showTools: boolean,
47
+ t: PeekTheme,
48
+ mdTheme: MarkdownTheme,
49
+ cache: RenderCache,
50
+ ): Preview {
51
+ if (!s) return { lines: [t.fg("dim", msg("noMatch"))], matchLines: [] };
52
+
53
+ const lines: string[] = [];
54
+ const matchLines: number[] = [];
55
+ // 消息数只算有文字的,纯工具轮次不计
56
+ const textCount = s.msgs.reduce((n, m) => n + (m.text ? 1 : 0), 0);
57
+ lines.push(
58
+ truncateToWidth(
59
+ t.fg("dim", `${s.cwd} • ${msg("msgCount", { n: textCount })}${s.name ? " • " + s.name : ""}`),
60
+ rw,
61
+ ),
62
+ );
63
+ lines.push("");
64
+
65
+ const hasBody = rt.user.length > 0 || rt.assistant.length > 0;
66
+ const truncated = s.msgs.length > MAX_MSGS;
67
+ const msgs = truncated ? s.msgs.slice(-MAX_MSGS) : s.msgs;
68
+ if (truncated) {
69
+ lines.push(t.fg("dim", msg("truncated", { n: MAX_MSGS })));
70
+ lines.push("");
71
+ }
72
+ if (hasBody && !msgs.some((m) => anyMatch(m.text, rt[m.role]))) {
73
+ const inTruncated = truncated && s.msgs.some((m) => anyMatch(m.text, rt[m.role]));
74
+ lines.push(
75
+ t.fg(
76
+ "warning",
77
+ inTruncated ? msg("hitInTruncated") : msg("hitOnlyMeta"),
78
+ ),
79
+ );
80
+ lines.push("");
81
+ }
82
+
83
+ // 用户消息整块背景色,AI 消息只有一条细线标签
84
+ const fillLabel = (label: string): string => {
85
+ const w = visibleWidth(label);
86
+ return truncateToWidth(label + " " + "─".repeat(Math.max(2, rw - w - 1)), rw);
87
+ };
88
+
89
+ let prevRole: PeekMsg["role"] | undefined; // 上一条画出来的消息的角色
90
+ for (const m of msgs) {
91
+ const tools = showTools ? (m.tools ?? []) : [];
92
+ if (!m.text && !tools.length) continue; // 工具隐藏时,只有工具调用的 AI 轮次整条跳过
93
+ const terms = rt[m.role]; // user: 的词只在用户消息里高亮,ai: 的只在 AI 回复里
94
+ const isMatch = terms.length > 0 && anyMatch(m.text, terms);
95
+ // 紧跟在 AI 文字后面、只有工具调用的轮次不重复画标签,接在上一条正文下面,看起来是同一轮
96
+ const continues = m.role === "assistant" && prevRole === "assistant" && !m.text;
97
+ if (continues) {
98
+ lines.pop();
99
+ } else if (m.role === "user") {
100
+ lines.push(
101
+ t.bg("userMessageBg", padEndVisible(t.bold(t.fg("accent", fillLabel(msg("you")))), rw)),
102
+ );
103
+ } else {
104
+ lines.push(t.fg("muted", fillLabel(msg("ai"))));
105
+ }
106
+ const head = lines.length - 1;
107
+ // 先渲染再高亮:往 markdown 源码里插转义码会把链接、代码块的语法弄坏
108
+ const before = matchLines.length;
109
+ if (m.text) {
110
+ for (const l of renderMsg(m, rw, t, mdTheme, cache)) {
111
+ if (isMatch && lineHasMatch(l, terms)) matchLines.push(lines.length);
112
+ lines.push(isMatch ? highlight(l, terms, t) : l);
113
+ }
114
+ }
115
+ // 关键词被折行拆开时哪一行都找不到,退回到消息标题行,别把这条消息漏掉
116
+ if (isMatch && matchLines.length === before) matchLines.push(head);
117
+ for (const tool of tools) {
118
+ lines.push(t.fg("dim", truncateToWidth(` ⚙ ${tool.name}${tool.summary ? " " + tool.summary : ""}`, rw)));
119
+ }
120
+ lines.push("");
121
+ prevRole = m.role;
122
+ }
123
+
124
+ return { lines, matchLines };
125
+ }
126
+
127
+ // 刚建好的预览该停在哪:有关键词时滚到第一个命中处(上面留两行),没有时滚到底部看最新消息
128
+ export function initialOffset(p: Preview, H: number): number {
129
+ if (p.matchLines.length) return Math.max(0, p.matchLines[0] - 2);
130
+ return Math.max(0, p.lines.length - H);
131
+ }
132
+
133
+ // Ctrl+N / Ctrl+P:当前视口 [top, bottom] 之外的下一个 / 上一个命中行,到头了回绕。同一屏里的多个命中算一处,
134
+ // 否则一段里连着几行都命中要按好几次才过得去。没有命中返回 undefined
135
+ export function nextMatchTarget(matchLines: number[], top: number, bottom: number, dir: 1 | -1): number | undefined {
136
+ if (!matchLines.length) return undefined;
137
+ if (dir === 1) return matchLines.find((l) => l > bottom) ?? matchLines[0];
138
+ return [...matchLines].reverse().find((l) => l < top) ?? matchLines[matchLines.length - 1];
139
+ }
package/src/query.ts CHANGED
@@ -1,8 +1,10 @@
1
+ import { applySgr, ANSI_SEQ_RE, splitAnsi, type SgrStyle } from "./ansi.ts";
1
2
  import { msg, type MsgKey } from "./i18n.ts";
2
3
  import type { PeekMsg, PeekSession } from "./sessions.ts";
4
+ import { stylePrefix, type PeekTheme } from "./theme.ts";
3
5
 
4
6
  // 搜索框语法。空白分隔多个词,全部满足才算(AND)。每个词可以是:
5
- // foo 子串,大小写不敏感
7
+ // foo 子串,大小写不敏感,在对话正文和会话名里找;不看工作目录(见 termHits)
6
8
  // "foo bar" 短语,引号里的一段空白匹配正文里任意一段空白(含换行)
7
9
  // a|b 任一命中(OR);要搜字面的 | 就加引号
8
10
  // /re/ 正则(JS 语法,自动带 i 和 m 标志,不能含空白,用 \s 代替);写错了按普通文字搜
@@ -124,11 +126,13 @@ function termHits(s: PeekSession, t: Term): boolean {
124
126
  case "assistant":
125
127
  return s.msgs.some((m) => m.role === t.field && hit(t.re, m.text));
126
128
  default:
127
- return hit(t.re, s.name) || hit(t.re, s.cwd) || s.msgs.some((m) => hit(t.re, m.text));
129
+ // 普通词不看工作目录:当前目录树范围下所有会话的路径都带同一段前缀,路径里的词会命中全部会话。
130
+ // 要搜路径用 dir:。pi 自带的选择器会把 cwd 拼进搜索文本,这里是有意不同
131
+ return hit(t.re, s.name) || s.msgs.some((m) => hit(t.re, m.text));
128
132
  }
129
133
  }
130
134
 
131
- // 每个词都要满足:普通词在会话名、cwd 或某条消息里出现,排除词则哪里都不出现;带前缀的只看对应的地方。
135
+ // 每个词都要满足:普通词在会话名或某条消息里出现,排除词则哪里都不出现;带前缀的只看对应的地方。
132
136
  // 正文不预存小写副本,直接在原文上用不区分大小写的正则匹配
133
137
  export function matchesSession(s: PeekSession, terms: Term[]): boolean {
134
138
  return terms.every((t) => termHits(s, t) !== t.negate);
@@ -165,70 +169,20 @@ function nextMatch(text: string, terms: Term[], from: number): [number, number]
165
169
  return [best, len];
166
170
  }
167
171
 
168
- // pi-tui 会产生的三类转义序列:CSI(颜色等)、OSC(超链接)、APC
169
- const ESC_RE = /\x1b\[[0-9;?]*[A-Za-z]|\x1b[\]_][^\x07\x1b]*(?:\x07|\x1b\\)/g;
170
-
171
- // 把样式函数拆出前缀,pi 的 Markdown 组件也是这么做的
172
- function stylePrefix(fn: (s: string) => string): string {
173
- const s = fn(" ");
174
- const i = s.indexOf(" ");
175
- return i >= 0 ? s.slice(0, i) : "";
176
- }
177
-
178
- // 只跟踪高亮会碰到的几项:前景色、粗体 / 暗淡、下划线
179
- interface Sgr {
180
- fg: string;
181
- bold: boolean;
182
- dim: boolean;
183
- underline: boolean;
184
- }
185
-
186
- function applySgr(st: Sgr, seq: string): void {
187
- const m = /^\x1b\[([0-9;]*)m$/.exec(seq);
188
- if (!m) return;
189
- const p = m[1] === "" ? [0] : m[1].split(";").map(Number);
190
- for (let i = 0; i < p.length; i++) {
191
- const c = p[i];
192
- if (c === 0) {
193
- st.fg = "";
194
- st.bold = st.dim = st.underline = false;
195
- } else if (c === 1) st.bold = true;
196
- else if (c === 2) st.dim = true;
197
- else if (c === 22) st.bold = st.dim = false;
198
- else if (c === 4) st.underline = true;
199
- else if (c === 24) st.underline = false;
200
- else if ((c >= 30 && c <= 37) || (c >= 90 && c <= 97)) st.fg = `\x1b[${c}m`;
201
- else if (c === 39) st.fg = "";
202
- else if (c === 38 || c === 48) {
203
- // 扩展色 38;5;n / 38;2;r;g;b;背景色(48)只需要跳过它的参数
204
- const n = p[i + 1] === 5 ? 2 : p[i + 1] === 2 ? 4 : 0;
205
- if (c === 38 && n) st.fg = `\x1b[${p.slice(i, i + n + 1).join(";")}m`;
206
- i += n;
207
- }
208
- }
209
- }
210
-
211
- // 渲染后的一行(可带 ANSI)里有没有命中,预览按行记命中用
172
+ // 渲染后的一行(可带 ANSI)里有没有命中,预览按行记命中用。和 highlight 用同一个正则去掉序列,两边才对得上
212
173
  export function lineHasMatch(line: string, terms: Term[]): boolean {
213
- return terms.length > 0 && anyMatch(line.replace(ESC_RE, ""), terms);
174
+ return terms.length > 0 && anyMatch(line.replace(ANSI_SEQ_RE, ""), terms);
214
175
  }
215
176
 
216
177
  // 在一行文字里高亮命中,返回带 ANSI 的字符串。
217
178
  // 输入可以是已经渲染过、带样式的行:转义序列不参与匹配也不会被切断;高亮结束时把原来的
218
179
  // 前景色 / 粗体 / 下划线恢复回去,所以放在标题、链接等样式里面也不会把后面的文字弄丢样式。
219
180
  // 不用主题自带的 searchMatchBg:dark/light 主题里它和 selectedBg 同色,看不出来
220
- export function highlight(line: string, terms: Term[], theme: any): string {
181
+ export function highlight(line: string, terms: Term[], theme: PeekTheme): string {
221
182
  if (!terms.length || !line) return line;
222
183
 
223
184
  // 拆成可见文本和转义序列两类片段
224
- const segs: { text: string; esc: boolean }[] = [];
225
- let last = 0;
226
- for (const m of line.matchAll(ESC_RE)) {
227
- if (m.index > last) segs.push({ text: line.slice(last, m.index), esc: false });
228
- segs.push({ text: m[0], esc: true });
229
- last = m.index + m[0].length;
230
- }
231
- if (last < line.length) segs.push({ text: line.slice(last), esc: false });
185
+ const segs = splitAnsi(line);
232
186
 
233
187
  let visible = "";
234
188
  for (const s of segs) if (!s.esc) visible += s.text;
@@ -243,7 +197,7 @@ export function highlight(line: string, terms: Term[], theme: any): string {
243
197
  if (!ranges.length) return line;
244
198
 
245
199
  const on = "\x1b[1m\x1b[4m" + stylePrefix((s) => theme.fg("warning", s));
246
- const st: Sgr = { fg: "", bold: false, dim: false, underline: false };
200
+ const st: SgrStyle = { fg: "", bold: false, dim: false, underline: false };
247
201
  const off = () =>
248
202
  (st.underline ? "" : "\x1b[24m") +
249
203
  (st.bold ? "" : "\x1b[22m" + (st.dim ? "\x1b[2m" : "")) +
package/src/selection.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { sliceByColumn, stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { ANSI_TAIL_RE, isSgr, splitAnsi } from "./ansi.ts";
2
3
 
3
4
  // 鼠标拖出来的选区。只在一个栏里:行号是这一栏内容的绝对行号(左栏每个会话两行,右栏是预览行),
4
5
  // 列号是栏内列号。起止格都算在内,中间的行整行选中,所以从左栏拖到右栏也只会选左栏的字
@@ -16,9 +17,6 @@ export interface Selection {
16
17
  focus: Cell; // 现在拖到的格
17
18
  }
18
19
 
19
- // CSI / OSC / APC 序列,反显时要原样保留
20
- const SEQ = /\x1b\[[0-9;?<>=!]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b_[^\x07\x1b]*(?:\x07|\x1b\\)/g;
21
- const TAIL = new RegExp(`(?:${SEQ.source})+$`); // 行尾的样式收尾码
22
20
  const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
23
21
 
24
22
  /** 起止格,按阅读顺序 */
@@ -60,16 +58,14 @@ export function rowColumns(sel: Selection, row: number, line: string, width: num
60
58
  return c1 > c0 ? [c0, c1] : undefined;
61
59
  }
62
60
 
63
- // 整段反显。段里原有的样式码照留,每个样式码后面补一次反显,免得被 0m 之类的重置掉
61
+ // 整段反显。段里原有的序列照留(反显时要原样保留),每个样式码后面补一次反显,免得被 0m 之类的重置掉
64
62
  function inverse(text: string): string {
65
63
  let out = "\x1b[7m";
66
- let last = 0;
67
- for (const m of text.matchAll(SEQ)) {
68
- out += text.slice(last, m.index) + m[0];
69
- if (m[0].startsWith("\x1b[") && m[0].endsWith("m")) out += "\x1b[7m";
70
- last = m.index + m[0].length;
64
+ for (const seg of splitAnsi(text)) {
65
+ out += seg.text;
66
+ if (seg.esc && isSgr(seg.text)) out += "\x1b[7m";
71
67
  }
72
- return out + text.slice(last) + "\x1b[27m";
68
+ return out + "\x1b[27m";
73
69
  }
74
70
 
75
71
  /** 给一行的 [c0, c1) 列加反显,其余部分原样 */
@@ -79,7 +75,7 @@ export function highlightColumns(line: string, c0: number, c1: number): string {
79
75
  const mid = sliceByColumn(line, c0, c1 - c0, true);
80
76
  const after = sliceByColumn(line, c1, Math.max(0, w - c1), true);
81
77
  // sliceByColumn 切到行尾时会丢掉最后那些收尾码(39m、49m 之类),补回去,否则背景色会漏到右边
82
- const tail = TAIL.exec(line)?.[0] ?? "";
78
+ const tail = ANSI_TAIL_RE.exec(line)?.[0] ?? "";
83
79
  return before + inverse(mid) + after + tail;
84
80
  }
85
81
 
package/src/sessions.ts CHANGED
@@ -2,7 +2,9 @@ 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 { sanitizeText } from "./ansi.ts";
7
+ import { normPath } from "./text.ts";
6
8
 
7
9
  // 读 ~/.pi/agent/sessions 下的会话 JSONL,按 mtime 缓存;重命名和删除也在这里。
8
10
  // 搜索不预存小写全文:会话正文只在 msgs 里放一份,匹配时用不区分大小写的正则直接扫(见 query.ts 的 matchesSession)
@@ -26,18 +28,29 @@ export interface PeekSession {
26
28
  first: string; // 首条消息,列表里显示用
27
29
  msgs: PeekMsg[];
28
30
  mtime: number;
31
+ size: number; // 和 mtime 一起当缓存键:mtime 粒度粗的文件系统上连续两次写入可能同一个 mtime
29
32
  }
30
33
 
31
34
  const sessionCache = new Map<string, PeekSession>();
32
35
  const READ_CONCURRENCY = 16; // 同时读的文件数,太高会把 libuv 线程池排满
33
36
 
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");
37
+ const expandTilde = (p: string) => (p.startsWith("~") ? join(homedir(), p.slice(1)) : p);
38
+
39
+ // 默认布局下所有项目的会话根目录:和 pi 一样认 PI_CODING_AGENT_DIR,下面每个 cwd 一个子目录
40
+ export function sessionsDir(env: NodeJS.ProcessEnv = process.env): string {
41
+ const agent = env.PI_CODING_AGENT_DIR ? expandTilde(env.PI_CODING_AGENT_DIR) : join(homedir(), ".pi", "agent");
38
42
  return join(agent, "sessions");
39
43
  }
40
44
 
45
+ /** 要扫描的根目录。pi 的 getSessionDir() 是参数、环境变量、settings.json 合并后的结果:
46
+ * 默认布局时它是 <root>/sessions/<按 cwd 编码的子目录>,取上一级才能看到所有项目;
47
+ * 用户自定义了目录(--session-dir 等)时就是那个目录本身,直接扫它 */
48
+ export function scanRoot(currentSessionDir: string | undefined, env: NodeJS.ProcessEnv = process.env): string {
49
+ const root = sessionsDir(env);
50
+ if (!currentSessionDir) return root;
51
+ return normPath(dirname(currentSessionDir)) === normPath(root) ? root : currentSessionDir;
52
+ }
53
+
41
54
  async function walkJsonl(dir: string, out: string[]): Promise<void> {
42
55
  let entries;
43
56
  try {
@@ -53,12 +66,14 @@ async function walkJsonl(dir: string, out: string[]): Promise<void> {
53
66
  }
54
67
 
55
68
  function extractText(content: unknown): string {
56
- if (typeof content === "string") return content;
69
+ if (typeof content === "string") return sanitizeText(content);
57
70
  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");
71
+ return sanitizeText(
72
+ content
73
+ .filter((c: any) => c?.type === "text" && typeof c.text === "string")
74
+ .map((c: any) => c.text)
75
+ .join("\n"),
76
+ );
62
77
  }
63
78
 
64
79
  // 工具调用的主参数:按常见键名找第一个字符串,都没有就取第一个字符串参数。不存结果,bash 输出动辄几十 KB
@@ -71,7 +86,7 @@ export function toolSummary(args: unknown): string {
71
86
  let v = TOOL_ARG_KEYS.map((k) => o[k]).find((x) => typeof x === "string" && x.trim());
72
87
  if (typeof v !== "string") v = Object.values(o).find((x) => typeof x === "string" && (x as string).trim());
73
88
  if (typeof v !== "string") return "";
74
- return v.replace(/\s+/g, " ").trim().slice(0, TOOL_SUMMARY_MAX);
89
+ return sanitizeText(v).replace(/\s+/g, " ").trim().slice(0, TOOL_SUMMARY_MAX);
75
90
  }
76
91
 
77
92
  function extractTools(content: unknown): PeekTool[] {
@@ -81,7 +96,7 @@ function extractTools(content: unknown): PeekTool[] {
81
96
  .map((c: any) => ({ name: c.name, summary: toolSummary(c.arguments) }));
82
97
  }
83
98
 
84
- function parseSession(path: string, raw: string, mtime: number): PeekSession | null {
99
+ function parseSession(path: string, raw: string, mtime: number, size: number): PeekSession | null {
85
100
  let cwd = "";
86
101
  let time = "";
87
102
  let name = "";
@@ -98,7 +113,7 @@ function parseSession(path: string, raw: string, mtime: number): PeekSession | n
98
113
  }
99
114
  if (line.includes('"type":"session_info"')) {
100
115
  const o = JSON.parse(line);
101
- if (typeof o.name === "string" && o.name) name = o.name;
116
+ if (typeof o.name === "string") name = sanitizeText(o.name).trim(); // 空名字是显式清掉
102
117
  continue;
103
118
  }
104
119
  // 先用子串粗筛,省掉大部分行的 JSON.parse
@@ -127,6 +142,7 @@ function parseSession(path: string, raw: string, mtime: number): PeekSession | n
127
142
  name,
128
143
  msgs,
129
144
  mtime,
145
+ size,
130
146
  first: firstText.text.replace(/\s+/g, " ").slice(0, 80),
131
147
  };
132
148
  }
@@ -134,29 +150,32 @@ function parseSession(path: string, raw: string, mtime: number): PeekSession | n
134
150
  // 单个文件:mtime 没变直接用缓存,否则重新读和解析
135
151
  async function loadSession(file: string): Promise<PeekSession | null> {
136
152
  let mtime: number;
153
+ let size: number;
137
154
  try {
138
- mtime = (await stat(file)).mtimeMs;
155
+ const st = await stat(file);
156
+ mtime = st.mtimeMs;
157
+ size = st.size;
139
158
  } catch {
140
159
  return null;
141
160
  }
142
161
  const hit = sessionCache.get(file);
143
- if (hit && hit.mtime === mtime) return hit;
162
+ if (hit && hit.mtime === mtime && hit.size === size) return hit;
144
163
  let raw: string;
145
164
  try {
146
165
  raw = await readFile(file, "utf8");
147
166
  } catch {
148
167
  return null;
149
168
  }
150
- const parsed = parseSession(file, raw, mtime);
169
+ const parsed = parseSession(file, raw, mtime, size);
151
170
  if (parsed) sessionCache.set(file, parsed);
152
171
  return parsed;
153
172
  }
154
173
 
155
174
  /** 全部会话,按最后修改时间倒序;文件没变的直接用缓存。
156
175
  * 目录遍历和文件读取都走异步 I/O,几百个会话也不会把 TUI 卡住 */
157
- export async function scanSessions(): Promise<PeekSession[]> {
176
+ export async function scanSessions(root: string = sessionsDir()): Promise<PeekSession[]> {
158
177
  const files: string[] = [];
159
- await walkJsonl(sessionsDir(), files);
178
+ await walkJsonl(root, files);
160
179
 
161
180
  const out: PeekSession[] = [];
162
181
  let next = 0;
@@ -179,6 +198,7 @@ export async function scanSessions(): Promise<PeekSession[]> {
179
198
 
180
199
  // 和 pi 自带的 /name 写同一种 session_info 记录,parentId 用文件里最后一条记录的 id
181
200
  export function renameSession(path: string, name: string): void {
201
+ name = sanitizeText(name).replace(/\n+/g, " ").trim(); // 和 pi 的 appendSessionInfo 一样,换行不能进 JSONL 的一行
182
202
  const raw = readFileSync(path, "utf8");
183
203
  const lines = raw.split("\n");
184
204
  let parentId: string | null = null;
@@ -227,18 +247,21 @@ export function trashCommands(path: string, platform: NodeJS.Platform = process.
227
247
  }
228
248
 
229
249
  // 优先进回收站(能恢复),每个途径都不行再直接删。exec 由调用方传入,方便测试;
230
- // 命令退出码为 0 但文件还在也当失败,继续试下一个
250
+ // 命令退出码为 0 但文件还在也当失败,继续试下一个。命令超时被杀但其实已经把文件移走了的,
251
+ // 事后再看一眼文件,别把"已进回收站"报成"永久删除"
231
252
  export async function deleteSession(
232
253
  path: string,
233
254
  exec: (cmd: string, args: string[]) => Promise<{ code: number }>,
234
255
  platform: NodeJS.Platform = process.platform,
235
256
  ): Promise<DeleteResult> {
257
+ const wasThere = existsSync(path);
236
258
  for (const [cmd, args] of trashCommands(path, platform)) {
237
259
  try {
238
260
  const r = await exec(cmd, args);
239
261
  if (r.code === 0 && !existsSync(path)) return "trash";
240
262
  } catch {
241
263
  }
264
+ if (wasThere && !existsSync(path)) return "trash";
242
265
  }
243
266
  try {
244
267
  rmSync(path, { force: true });
package/src/theme.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+
3
+ // 组件和高亮只用到主题的这三个方法。从 pi 导出的 Theme 取子集:颜色名跟着 pi 的 ThemeColor / ThemeBg 联合走,
4
+ // 写错名字 typecheck 就报;测试里的桩只要有这三个方法就能当主题用。类型导入编译后被擦除,运行时不依赖 pi-coding-agent
5
+ export type PeekTheme = Pick<Theme, "fg" | "bg" | "bold">;
6
+
7
+ // 把样式函数拆出前缀(fn(" ") 里空格前面那段转义码),pi 的 Markdown 组件也是这么做的
8
+ export function stylePrefix(fn: (s: string) => string): string {
9
+ const s = fn(" ");
10
+ const i = s.indexOf(" ");
11
+ return i >= 0 ? s.slice(0, i) : "";
12
+ }