pi-session-peek 0.5.1 → 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
@@ -3,6 +3,7 @@ import { readdir, readFile, stat } from "node:fs/promises";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
+ import { sanitizeText } from "./ansi.ts";
6
7
  import { normPath } from "./text.ts";
7
8
 
8
9
  // 读 ~/.pi/agent/sessions 下的会话 JSONL,按 mtime 缓存;重命名和删除也在这里。
@@ -64,11 +65,6 @@ async function walkJsonl(dir: string, out: string[]): Promise<void> {
64
65
  }
65
66
  }
66
67
 
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
-
72
68
  function extractText(content: unknown): string {
73
69
  if (typeof content === "string") return sanitizeText(content);
74
70
  if (!Array.isArray(content)) return "";
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
+ }