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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.2
4
+
5
+ - Wheel over the list now scrolls the list like a scrollbar instead of moving the selection. The selection stays where it is and comes back into view on `↑` / `↓`, a click, a new filter or a delete.
6
+ - Search: plain terms no longer match the working directory, only the conversation and the session name. With the current-directory scope every session shares the same path prefix, so a word from the path used to match all of them. Use `dir:` (or `cwd:`) to search paths.
7
+ - Internal: the picker component is split into `preview`, `list` and `drag-select` modules, escape-sequence helpers are consolidated in `ansi.ts`, and the theme is typed against pi's `Theme`. No behaviour change.
8
+
3
9
  ## 0.5.1
4
10
 
5
11
  - 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.
package/README.md CHANGED
@@ -12,7 +12,7 @@ Search your [pi](https://pi.dev) session history by what was actually said, read
12
12
 
13
13
  - Opens a two-pane picker with `/peek` or `/peek <keyword>`: sessions on the left, the full conversation on the right.
14
14
  - Renders the conversation with pi's own Markdown renderer, so headings, code blocks with syntax highlighting, tables and lists look the same as in the main transcript.
15
- - Filters as you type across conversation text, session name and working directory. Tool call arguments and results are excluded from the search, so a keyword only matches sessions that actually discussed it.
15
+ - Filters as you type across conversation text and session name. The working directory is searched only with an explicit `dir:`, so a word from a project path does not match every session in that project. Tool call arguments and results are excluded from the search, so a keyword only matches sessions that actually discussed it.
16
16
  - Requires every space-separated keyword to match, and shows a snippet around the first hit in the list.
17
17
  - Understands `"exact phrases"`, `either|or`, `-exclude`, `name:` / `dir:` / `user:` / `ai:` prefixes and `/regex/`.
18
18
  - Limits results to recently active sessions with `@7d`, `@24h`, `@2w` or `@1m`.
@@ -80,7 +80,7 @@ In TUI mode, run `/peek` and start typing. Press `Enter` on a session to continu
80
80
  | --- | --- |
81
81
  | Click a session in the list | Select it |
82
82
  | Double-click a session | Resume it (same as `Enter`) |
83
- | Wheel over the list | Move the selection |
83
+ | Wheel over the list | Scroll the list like a scrollbar; the selection stays where it is. `↑` `↓`, a click or a new filter bring it back into view. |
84
84
  | Wheel over the preview | Scroll the preview three lines per notch (`Alt` for five times faster) |
85
85
  | Click the scope label in the header | Toggle current directory tree ↔ all projects (same as `Tab`) |
86
86
  | Click the search box or the rename line | Move the cursor |
@@ -98,7 +98,7 @@ The filter is case-insensitive and matches anywhere in the text. Whitespace sepa
98
98
  | `token undefined` | Sessions containing both `token` and `undefined`, in any order and any message |
99
99
  | `"token undefined"` | The exact phrase. Whitespace inside the quotes matches any run of whitespace, including a line break. |
100
100
  | `vue\|react` | Sessions containing `vue` or `react` |
101
- | `-draft` | Sessions that do not contain `draft` anywhere (messages, name or directory) |
101
+ | `-draft` | Sessions that do not contain `draft` in any message or in the session name |
102
102
  | `name:auth` | Session name only. `dir:` or `cwd:` for the working directory. |
103
103
  | `user:deploy` | Only the messages you wrote. `ai:` or `assistant:` for the replies. |
104
104
  | `/\bfoo\d+\b/` | JavaScript regular expression, case-insensitive, `^` and `$` match line boundaries. Use `\s` instead of a space. |
@@ -107,7 +107,7 @@ The filter is case-insensitive and matches anywhere in the text. Whitespace sepa
107
107
 
108
108
  Quotes are the escape hatch: `"-foo"`, `"a|b"`, `"name:x"` and `"/x/"` search for those characters literally. A regex that fails to compile is searched as plain text. Only positive terms are highlighted in the preview, and `name:` / `dir:` terms never touch the conversation, so `name:auth` alone shows the full conversation with no highlights.
109
109
 
110
- The search index holds user and assistant messages, the session name and the working directory. It does not hold tool call arguments or tool results, and the tool lines shown by `Ctrl+T` are never matched or highlighted.
110
+ The search index holds user and assistant messages and the session name. The working directory is matched only by `dir:` / `cwd:` terms: with the current-directory scope every session shares the same path prefix, so a plain word from the path would match all of them. The index does not hold tool call arguments or tool results, and the tool lines shown by `Ctrl+T` are never matched or highlighted.
111
111
 
112
112
  ## 🌐 Language
113
113
 
@@ -134,13 +134,19 @@ On Windows PowerShell: `$env:PI_SESSION_PEEK_LANG = "en"` before starting pi. Th
134
134
 
135
135
  ```text
136
136
  pi-session-peek/
137
- ├── index.ts # Registers /peek and the startup flags, injects delete / rename / fork
137
+ ├── index.ts # Registers /peek and the startup flags, injects delete / rename / fork / copy
138
138
  ├── src/
139
- │ ├── peek-component.ts # The two-pane TUI component
139
+ │ ├── peek-component.ts # The two-pane component: state, key and mouse routing, header / footer, pane composition
140
+ │ ├── preview.ts # Right pane: preview lines and hit lines for a session, initial offset, Ctrl+N/P targets
141
+ │ ├── list.ts # Left pane: two rows per session, viewport following
142
+ │ ├── drag-select.ts # Drag-selection state machine: press / drag / release / auto-scroll
143
+ │ ├── selection.ts # Selection geometry: ranges, inverse-video highlight, text extraction
144
+ │ ├── layout.ts # Pane widths and body height
140
145
  │ ├── sessions.ts # Scans and parses session JSONL, rename / delete helpers
141
- │ ├── query.ts # Query parsing, highlighting, hit snippets
146
+ │ ├── query.ts # Query parsing, matching, highlighting, hit snippets
142
147
  │ ├── mouse.ts # Mouse reporting for pi's regular (non-fullscreen) mode
143
- │ ├── selection.ts # Pane-confined drag selection: ranges, highlight, text extraction
148
+ │ ├── ansi.ts # Escape-sequence tokenising, SGR tracking, input sanitising
149
+ │ ├── theme.ts # The subset of pi's Theme the picker uses
144
150
  │ ├── i18n.ts # Chinese / English UI strings and locale detection
145
151
  │ └── text.ts # Path, time and width helpers
146
152
  ├── scripts/screenshot.mjs # Optional: renders a synthetic screenshot from component output
package/README.zh-CN.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  - `/peek` 或 `/peek 关键词` 打开左右分栏的选择器:左边是会话列表,右边是完整对话。
14
14
  - 右栏用 pi 自己的 Markdown 渲染器画,标题、带语法高亮的代码块、表格、列表和主界面里的对话一个样子。
15
- - 边打字边过滤,搜的是对话正文、会话名和工作目录。工具调用的参数和结果不进搜索索引,所以只会命中真正讨论过这个词的会话。
15
+ - 边打字边过滤,搜的是对话正文和会话名。工作目录只在显式写 `dir:` 时才搜,项目路径里的词不会把整个项目的会话都命中。工具调用的参数和结果不进搜索索引,所以只会命中真正讨论过这个词的会话。
16
16
  - 空格分隔多个关键词,全部命中才显示;列表里直接显示第一个命中处的片段。
17
17
  - 支持 `"精确短语"`、`a|b` 任一命中、`-排除`、`name:` / `dir:` / `user:` / `ai:` 限定范围,以及 `/正则/`。
18
18
  - `@7d`、`@24h`、`@2w`、`@1m` 只看最近活动过的会话。
@@ -80,7 +80,7 @@ pi 扩展以你的用户权限运行,只装信得过的包。
80
80
  | --- | --- |
81
81
  | 点列表里的会话 | 选中 |
82
82
  | 双击会话 | 进入,和 `Enter` 一样 |
83
- | 在列表上滚滚轮 | 换选中项 |
83
+ | 在列表上滚滚轮 | 像滚动条一样滚列表,选中项不变;`↑` `↓`、点击或改过滤词会把选中项带回视野 |
84
84
  | 在预览上滚滚轮 | 预览每格滚三行,按住 `Alt` 快五倍 |
85
85
  | 点头部的范围字样 | 当前目录树 ↔ 全部项目,和 `Tab` 一样 |
86
86
  | 点搜索框或改名行 | 移动光标 |
@@ -98,7 +98,7 @@ pi 扩展以你的用户权限运行,只装信得过的包。
98
98
  | `token undefined` | 同时包含 `token` 和 `undefined` 的会话,顺序不限,可以在不同的消息里 |
99
99
  | `"token undefined"` | 精确短语。引号里的空白可以匹配正文里的任意一段空白,包括换行 |
100
100
  | `vue\|react` | 包含 `vue` 或 `react` 的会话 |
101
- | `-draft` | 哪里都不含 `draft` 的会话(消息、会话名、目录都算) |
101
+ | `-draft` | 消息和会话名里都不含 `draft` 的会话 |
102
102
  | `name:auth` | 只在会话名里找。`dir:` 或 `cwd:` 只看工作目录 |
103
103
  | `user:deploy` | 只看你发的消息。`ai:` 或 `assistant:` 只看回复 |
104
104
  | `/\bfoo\d+\b/` | JavaScript 正则,大小写不敏感,`^` 和 `$` 匹配行首行尾。空格用 `\s` 代替 |
@@ -107,7 +107,7 @@ pi 扩展以你的用户权限运行,只装信得过的包。
107
107
 
108
108
  引号是万能转义:`"-foo"`、`"a|b"`、`"name:x"`、`"/x/"` 都按字面搜这些字符。正则写错了就当普通文字搜。预览里只高亮正向的词,`name:` / `dir:` 的词不碰对话正文,所以只输入 `name:auth` 时右栏显示完整对话、没有高亮。
109
109
 
110
- 索引里有用户和助手的消息、会话名、工作目录。没有工具调用的参数和结果,`Ctrl+T` 显示的工具行也不参与匹配和高亮。
110
+ 索引里有用户和助手的消息和会话名。工作目录只由 `dir:` / `cwd:` 匹配:当前目录树范围下所有会话的路径都带同一段前缀,普通词一碰到路径就会命中全部会话。没有工具调用的参数和结果,`Ctrl+T` 显示的工具行也不参与匹配和高亮。
111
111
 
112
112
  ## 🌐 界面语言
113
113
 
@@ -134,13 +134,19 @@ Windows PowerShell 里先执行 `$env:PI_SESSION_PEEK_LANG = "en"` 再启动 pi
134
134
 
135
135
  ```text
136
136
  pi-session-peek/
137
- ├── index.ts # 注册 /peek 和启动参数,注入删除 / 重命名 / 分叉
137
+ ├── index.ts # 注册 /peek 和启动参数,注入删除 / 重命名 / 分叉 / 复制
138
138
  ├── src/
139
- │ ├── peek-component.ts # 双栏 TUI 组件
139
+ │ ├── peek-component.ts # 双栏组件:状态、按键和鼠标路由、头尾、双栏拼接
140
+ │ ├── preview.ts # 右栏:按会话生成预览行和命中行、初始滚动位置、Ctrl+N/P 跳转目标
141
+ │ ├── list.ts # 左栏:每个会话两行、视口跟随
142
+ │ ├── drag-select.ts # 拖选状态机:按下 / 拖动 / 松开 / 自动滚动
143
+ │ ├── selection.ts # 选区几何:区间、反显、取文字
144
+ │ ├── layout.ts # 分栏宽度和主体高度
140
145
  │ ├── sessions.ts # 扫描解析会话 JSONL,重命名 / 删除
141
- │ ├── query.ts # 查询解析、高亮、命中片段
146
+ │ ├── query.ts # 查询解析、匹配、高亮、命中片段
142
147
  │ ├── mouse.ts # 常规(非全屏)模式下的鼠标上报
143
- │ ├── selection.ts # 限定在一栏内的拖选:区间、反显、取文字
148
+ │ ├── ansi.ts # 转义序列分词、SGR 跟踪、输入净化
149
+ │ ├── theme.ts # 选择器用到的 pi Theme 子集
144
150
  │ ├── i18n.ts # 中英文界面文案和语言检测
145
151
  │ └── text.ts # 路径、时间、宽度工具
146
152
  ├── scripts/screenshot.mjs # 可选:用组件渲染输出生成一张合成截图
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-session-peek",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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",
package/src/ansi.ts ADDED
@@ -0,0 +1,73 @@
1
+ // 终端转义序列相关的工具,三种用途分开:
2
+ // 1. ANSI_SEQ_RE / splitAnsi:把 pi-tui 渲染出来的一行拆成"可见文字 / 序列"两类片段,序列原样保留、
3
+ // 绝不吞可见字。高亮(query.ts)和反显(selection.ts)都靠它,两边必须用同一个正则,
4
+ // 否则"记为命中的行"和"高亮到的行"会对不上
5
+ // 2. applySgr:只跟踪 SGR 里高亮会碰到的几项(前景色、粗体 / 暗淡、下划线),高亮结束时把外层样式恢复回去
6
+ // 3. sanitizeText:解析会话 JSONL 时净化输入。比 1 宽得多:DCS / PM / SOS、没终止的 OSC、孤立的 ESC、
7
+ // C0 控制字符全去掉,宁可多删。每个 ANSI_SEQ_RE 能匹配的序列都在它的覆盖范围内,反之不然
8
+ // scripts/screenshot.mjs 有一个自己的 SGR → CSS 状态机(要斜体和背景色),用途不同,不共用
9
+
10
+ /** pi-tui 会产生的三类序列:CSI(颜色等)、OSC(超链接)、APC。带 g,只用于 matchAll / replace,两者都不受 lastIndex 影响 */
11
+ export const ANSI_SEQ_RE = /\x1b\[[0-9;?<>=!]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b_[^\x07\x1b]*(?:\x07|\x1b\\)/g;
12
+
13
+ /** 行尾连续的序列(39m、49m 之类的收尾码) */
14
+ export const ANSI_TAIL_RE = new RegExp(`(?:${ANSI_SEQ_RE.source})+$`);
15
+
16
+ export interface AnsiSegment {
17
+ text: string;
18
+ esc: boolean; // true:这一段是一个完整的转义序列
19
+ }
20
+
21
+ /** 一行拆成可见文本和转义序列交替的片段,按顺序拼回去就是原样 */
22
+ export function splitAnsi(line: string): AnsiSegment[] {
23
+ const segs: AnsiSegment[] = [];
24
+ let last = 0;
25
+ for (const m of line.matchAll(ANSI_SEQ_RE)) {
26
+ if (m.index > last) segs.push({ text: line.slice(last, m.index), esc: false });
27
+ segs.push({ text: m[0], esc: true });
28
+ last = m.index + m[0].length;
29
+ }
30
+ if (last < line.length) segs.push({ text: line.slice(last), esc: false });
31
+ return segs;
32
+ }
33
+
34
+ /** 是不是 SGR(CSI … m):只有它会改样式 */
35
+ export const isSgr = (seq: string): boolean => seq.startsWith("\x1b[") && seq.endsWith("m");
36
+
37
+ // 只跟踪高亮会碰到的几项:前景色、粗体 / 暗淡、下划线
38
+ export interface SgrStyle {
39
+ fg: string;
40
+ bold: boolean;
41
+ dim: boolean;
42
+ underline: boolean;
43
+ }
44
+
45
+ export function applySgr(st: SgrStyle, seq: string): void {
46
+ const m = /^\x1b\[([0-9;]*)m$/.exec(seq);
47
+ if (!m) return;
48
+ const p = m[1] === "" ? [0] : m[1].split(";").map(Number);
49
+ for (let i = 0; i < p.length; i++) {
50
+ const c = p[i];
51
+ if (c === 0) {
52
+ st.fg = "";
53
+ st.bold = st.dim = st.underline = false;
54
+ } else if (c === 1) st.bold = true;
55
+ else if (c === 2) st.dim = true;
56
+ else if (c === 22) st.bold = st.dim = false;
57
+ else if (c === 4) st.underline = true;
58
+ else if (c === 24) st.underline = false;
59
+ else if ((c >= 30 && c <= 37) || (c >= 90 && c <= 97)) st.fg = `\x1b[${c}m`;
60
+ else if (c === 39) st.fg = "";
61
+ else if (c === 38 || c === 48) {
62
+ // 扩展色 38;5;n / 38;2;r;g;b;背景色(48)只需要跳过它的参数
63
+ const n = p[i + 1] === 5 ? 2 : p[i + 1] === 2 ? 4 : 0;
64
+ if (c === 38 && n) st.fg = `\x1b[${p.slice(i, i + n + 1).join(";")}m`;
65
+ i += n;
66
+ }
67
+ }
68
+ }
69
+
70
+ // 终端转义序列(CSI / OSC / DCS / APC 等)和除 \t \n 外的 C0 控制字符。正文里带这些会原样写到屏幕上:
71
+ // 排版错乱、宽度算错,OSC 52 之类的还能改剪贴板 / 窗口标题
72
+ const CONTROL_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|[\]P_^X][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-Z\\-_])|[\x00-\x08\x0b-\x1f\x7f]/g;
73
+ export const sanitizeText = (s: string) => s.replace(/\r\n?/g, "\n").replace(CONTROL_RE, "");
@@ -0,0 +1,119 @@
1
+ import { SEP } from "./layout.ts";
2
+ import type { Cell, Pane, Selection } from "./selection.ts";
3
+
4
+ // 鼠标拖选的状态机:按下(begin)→ 拖动(move)→ 松开(release),中间拖出预览上下边时自动滚动。
5
+ // 选区本身的几何(起止排序、每行列区间、反显、取文字)是 selection.ts 的纯函数,这里只管"现在选到哪了"。
6
+ // 坐标一律是主体行号(组件 y 减去 BODY_TOP);组件通过 host 提供分栏尺寸和滚动位置,并接收自动滚动的写回
7
+
8
+ const AUTO_SCROLL_MS = 50; // 拖选拖出预览上下边时自动滚动的节奏,和 pi-tui 一样
9
+
10
+ export interface DragHost {
11
+ /** 最近一次 render 的分栏尺寸和两栏当前的滚动偏移 */
12
+ geometry(): { lw: number; rw: number; H: number; listOffset: number; previewOffset: number };
13
+ /** 预览总行数,自动滚动到底时停 */
14
+ previewLength(): number;
15
+ /** 自动滚动写回预览偏移;紧接着的 move 用新值算格 */
16
+ setPreviewOffset(n: number): void;
17
+ /** 自动滚动改了状态,让组件重画(invalidate + requestRender) */
18
+ changed(): void;
19
+ }
20
+
21
+ export class DragSelect {
22
+ sel?: Selection; // 鼠标拖出来的选区,只在一栏里
23
+ dragging = false; // 左键还按着
24
+ selMoved = false; // 按下之后拖过没有;没拖过的选区不显示也不算数(那是单击)
25
+ dragX = 0; // 最近一次拖动的列,自动滚动时焦点跟着它
26
+ autoScrollDir = 0; // 拖出预览上下边:-1 往上 1 往下
27
+ autoScrollTimer?: ReturnType<typeof setInterval>;
28
+
29
+ constructor(private host: DragHost) {}
30
+
31
+ // 拖过的才算选区,只按下没拖的是单击
32
+ active(): Selection | undefined {
33
+ return this.sel && this.selMoved ? this.sel : undefined;
34
+ }
35
+
36
+ clear(): void {
37
+ this.sel = undefined;
38
+ this.dragging = false;
39
+ this.selMoved = false;
40
+ this.stopAutoScroll();
41
+ }
42
+
43
+ // 屏幕位置换成某一栏的内容坐标:行是内容的绝对行号,列夹在这一栏里,拖到栏外也只选栏内的字
44
+ cellAt(pane: Pane, x: number, bodyRow: number): Cell {
45
+ const { lw, rw, H, listOffset, previewOffset } = this.host.geometry();
46
+ const row = Math.max(0, Math.min(H - 1, bodyRow));
47
+ if (pane === "list") return { row: listOffset * 2 + row, col: Math.max(0, Math.min(lw - 1, x)) };
48
+ return { row: previewOffset + row, col: Math.max(0, Math.min(rw - 1, x - lw - SEP)) };
49
+ }
50
+
51
+ // 按下:可能是拖选的起点,拖起来才算数
52
+ begin(pane: Pane, x: number, bodyRow: number): void {
53
+ const cell = this.cellAt(pane, x, bodyRow);
54
+ this.sel = { pane, anchor: cell, focus: { ...cell } };
55
+ this.dragging = true;
56
+ this.dragX = x;
57
+ }
58
+
59
+ // 拖到某个位置:焦点跟过去。返回有没有变
60
+ move(x: number, bodyRow: number): boolean {
61
+ this.dragX = x;
62
+ const changed = this.dragTo(x, bodyRow);
63
+ if (changed) this.selMoved = true;
64
+ return changed;
65
+ }
66
+
67
+ private dragTo(x: number, bodyRow: number): boolean {
68
+ if (!this.sel) return false;
69
+ const cell = this.cellAt(this.sel.pane, x, bodyRow);
70
+ if (cell.row === this.sel.focus.row && cell.col === this.sel.focus.col) return false;
71
+ this.sel.focus = cell;
72
+ return true;
73
+ }
74
+
75
+ // 指针在预览上边(-1)/ 下边(1)时开始自动滚,回到里面(0)停
76
+ autoScroll(dir: -1 | 0 | 1): void {
77
+ this.autoScrollDir = dir;
78
+ if (!dir) {
79
+ this.stopAutoScroll();
80
+ return;
81
+ }
82
+ if (this.autoScrollTimer) return;
83
+ this.autoScrollTimer = setInterval(() => this.tick(), AUTO_SCROLL_MS);
84
+ this.autoScrollTimer.unref?.();
85
+ }
86
+
87
+ // 每次滚一行,焦点跟着指针:指针在上边就是视口第一行,在下边就是最后一行
88
+ private tick(): void {
89
+ const { H, previewOffset } = this.host.geometry();
90
+ const maxOff = Math.max(0, this.host.previewLength() - H);
91
+ const next = Math.max(0, Math.min(maxOff, previewOffset + this.autoScrollDir));
92
+ if (!this.sel || !this.dragging || next === previewOffset) {
93
+ this.stopAutoScroll();
94
+ return;
95
+ }
96
+ this.host.setPreviewOffset(next);
97
+ if (this.dragTo(this.dragX, this.autoScrollDir < 0 ? -1 : H)) this.selMoved = true;
98
+ this.host.changed();
99
+ }
100
+
101
+ // 松开:没拖过就是单击,选区作废。返回是不是我们在跟踪的那次按下
102
+ release(): boolean {
103
+ if (!this.dragging) return false;
104
+ this.dragging = false;
105
+ this.stopAutoScroll();
106
+ if (!this.selMoved) this.sel = undefined;
107
+ return true;
108
+ }
109
+
110
+ private stopAutoScroll(): void {
111
+ if (this.autoScrollTimer) clearInterval(this.autoScrollTimer);
112
+ this.autoScrollTimer = undefined;
113
+ this.autoScrollDir = 0;
114
+ }
115
+
116
+ dispose(): void {
117
+ this.stopAutoScroll();
118
+ }
119
+ }
package/src/i18n.ts CHANGED
@@ -27,7 +27,7 @@ const zh = {
27
27
  hitPos: " 命中 {k}/{n}",
28
28
  truncated: "(会话过长,仅显示最后 {n} 条)",
29
29
  hitInTruncated: "关键词命中在未显示的更早消息中",
30
- hitOnlyMeta: "关键词仅命中会话名或目录,对话正文无匹配",
30
+ hitOnlyMeta: "关键词仅命中会话名,对话正文无匹配",
31
31
  you: "👤 你",
32
32
  ai: "🤖 AI",
33
33
  renamePrefix: "✏ 重命名: ",
@@ -70,7 +70,7 @@ const en: Record<keyof typeof zh, string> = {
70
70
  hitPos: " hit {k}/{n}",
71
71
  truncated: "(long session, showing the last {n} messages)",
72
72
  hitInTruncated: "Keyword hits are in earlier messages not shown here",
73
- hitOnlyMeta: "Keyword matched only the session name or directory, not the conversation",
73
+ hitOnlyMeta: "Keyword matched only the session name, not the conversation",
74
74
  you: "👤 You",
75
75
  ai: "🤖 AI",
76
76
  renamePrefix: "✏ Rename: ",
package/src/layout.ts ADDED
@@ -0,0 +1,18 @@
1
+ // 双栏选择器的几何:头部占几行、分隔线几列、主体多高、左右栏多宽。
2
+ // 组件、左栏、拖选都要用,单独放一个文件,免得它们反向 import 组件
3
+
4
+ export const BODY_TOP = 3; // 头部、搜索框、分隔线之后才是双栏主体
5
+ export const SEP = 3; // 两栏之间 " │ " 占的列数
6
+
7
+ // 主体高度。每次都读 process.stdout.rows,终端拉伸后布局跟着变;读不到时用启动时记下的行数
8
+ export function bodyRows(termRows: number): number {
9
+ const rows = process.stdout.rows || termRows || 24;
10
+ return Math.max(8, Math.min(rows - 12, 30));
11
+ }
12
+
13
+ // 左栏占 40%,夹在 26 到 56 列之间;右栏拿剩下的,至少 20 列
14
+ export function paneWidths(width: number): { lw: number; rw: number } {
15
+ const lw = Math.max(26, Math.min(56, Math.floor(width * 0.4)));
16
+ const rw = Math.max(20, width - lw - SEP);
17
+ return { lw, rw };
18
+ }
package/src/list.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { anyMatch, highlight, snippet, type RoleTerms } from "./query.ts";
3
+ import type { PeekSession } from "./sessions.ts";
4
+ import { fmtTime, padEndVisible } from "./text.ts";
5
+ import type { PeekTheme } from "./theme.ts";
6
+
7
+ // 左栏:每个会话两行,全是纯函数。滚动偏移由组件持有,这里只算"选中项要留在视口里时偏移该是多少"
8
+
9
+ // 主体 height 行能放下几个会话(每个两行)
10
+ export const visibleItems = (height: number): number => Math.max(1, Math.floor(height / 2));
11
+
12
+ // 选中项跑出视口时把偏移挪过去,其余情况不动
13
+ export function followSelection(selected: number, offset: number, visible: number): number {
14
+ if (selected < offset) return selected;
15
+ if (selected >= offset + visible) return selected - visible + 1;
16
+ return offset;
17
+ }
18
+
19
+ // 一个会话的两行:时间 + 目录(有正文关键词时加命中数),首条消息或命中片段
20
+ export function listRows(s: PeekSession, sel: boolean, lw: number, rt: RoleTerms, hasBody: boolean, t: PeekTheme): [string, string] {
21
+ const time = fmtTime(s.time);
22
+ const cwdTail = s.cwd.replace(/\\/g, "/").split("/").slice(-2).join("/");
23
+ const hitBadge = hasBody
24
+ ? ` ·${s.msgs.reduce((n, m) => n + (anyMatch(m.text, rt[m.role]) ? 1 : 0), 0)}`
25
+ : "";
26
+ const l1 = `${sel ? "›" : " "} ${time} ${cwdTail}${hitBadge}`;
27
+ const snip = hasBody ? snippet(s.msgs, rt, lw) : undefined;
28
+ const l2 = snip !== undefined
29
+ ? ` ${highlight(snip.text, rt[snip.role], t)}`
30
+ : ` ${s.first}${s.name ? ` [${s.name}]` : ""}`;
31
+ if (sel) {
32
+ return [
33
+ t.bg("selectedBg", padEndVisible(t.fg("accent", truncateToWidth(l1, lw)), lw)),
34
+ t.bg("selectedBg", padEndVisible(truncateToWidth(l2, lw), lw)),
35
+ ];
36
+ }
37
+ return [padEndVisible(l1, lw), padEndVisible(t.fg("dim", truncateToWidth(l2, lw)), lw)];
38
+ }
39
+
40
+ // 从 offset 起画满 height 行,每个会话两行,没会话的位置留空
41
+ export function buildList(
42
+ filtered: PeekSession[],
43
+ selected: number,
44
+ offset: number,
45
+ height: number,
46
+ lw: number,
47
+ rt: RoleTerms,
48
+ hasBody: boolean,
49
+ t: PeekTheme,
50
+ ): string[] {
51
+ const rows: string[] = [];
52
+ for (let i = 0, n = visibleItems(height); i < n; i++) {
53
+ const idx = offset + i;
54
+ const s = filtered[idx];
55
+ if (!s) {
56
+ rows.push(" ".repeat(lw), " ".repeat(lw));
57
+ continue;
58
+ }
59
+ rows.push(...listRows(s, idx === selected, lw, rt, hasBody, t));
60
+ }
61
+ // 每项两行,height 是奇数时会少一行,补空行,否则最后一行右栏会顶到左边
62
+ while (rows.length < height) rows.push(" ".repeat(lw));
63
+ return rows.slice(0, height);
64
+ }