pi-session-peek 0.4.1 → 0.5.0
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 +5 -0
- package/README.md +18 -1
- package/README.zh-CN.md +18 -1
- package/index.ts +14 -1
- package/package.json +1 -1
- package/src/i18n.ts +8 -2
- package/src/mouse.ts +207 -0
- package/src/peek-component.ts +314 -34
- package/src/selection.ts +96 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- Drag to select text inside one pane. The selection never crosses the divider: dragging from the list into the preview, or past either edge, keeps selecting the pane where the drag started, and lines wrap at that pane's width. Dragging below or above the preview scrolls it. Nothing is copied on release; `Ctrl+C` copies the selection (and closes the picker as before when there is none), a click anywhere clears it.
|
|
7
|
+
|
|
3
8
|
## 0.4.1
|
|
4
9
|
|
|
5
10
|
- `Ctrl+T` shows tool calls in the preview: one dim line per call with the tool name and its main argument (command, path, URL, query…), placed under the reply that made it. Off by default. Tool results are still not stored, and the tool lines are never searched or highlighted.
|
package/README.md
CHANGED
|
@@ -74,6 +74,21 @@ In TUI mode, run `/peek` and start typing. Press `Enter` on a session to continu
|
|
|
74
74
|
| `Ctrl+D` | Delete, confirmed with `y` or `Enter`. Moves the file to the system trash whenever one is available (`trash` CLI, then the Windows Recycle Bin via PowerShell, Finder on macOS, `gio trash` / `trash-put` on Linux). Deletes permanently only if none of those work. |
|
|
75
75
|
| `Esc` `Ctrl+C` | Close |
|
|
76
76
|
|
|
77
|
+
## 🖱️ Mouse
|
|
78
|
+
|
|
79
|
+
| Action | Effect |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| Click a session in the list | Select it |
|
|
82
|
+
| Double-click a session | Resume it (same as `Enter`) |
|
|
83
|
+
| Wheel over the list | Move the selection |
|
|
84
|
+
| Wheel over the preview | Scroll the preview three lines per notch (`Alt` for five times faster) |
|
|
85
|
+
| Click the scope label in the header | Toggle current directory tree ↔ all projects (same as `Tab`) |
|
|
86
|
+
| Click the search box or the rename line | Move the cursor |
|
|
87
|
+
| Drag inside a pane | Select text in that pane only. Dragging across the divider or past the edge keeps the selection in the pane where it started, and lines wrap within that pane. |
|
|
88
|
+
| `Ctrl+C` with a selection | Copy it to the clipboard. Nothing is copied on release. Without a selection `Ctrl+C` closes as before; a click anywhere clears the selection. |
|
|
89
|
+
|
|
90
|
+
Mouse reporting is switched on only while the picker is open and switched off when it closes, so the rest of pi is unaffected. While it is on, the terminal's own text selection and scrollback are unavailable; most terminals give them back while `Shift` is held, but that selection is the terminal's and spans both panes. Set `PI_SESSION_PEEK_MOUSE=0` to keep the mouse off. In pi's fullscreen mode the picker uses pi's own mouse handling and the variable is ignored.
|
|
91
|
+
|
|
77
92
|
## 🔎 Search syntax
|
|
78
93
|
|
|
79
94
|
The filter is case-insensitive and matches anywhere in the text. Whitespace separates terms, and every term must be satisfied.
|
|
@@ -110,7 +125,7 @@ On Windows PowerShell: `$env:PI_SESSION_PEEK_LANG = "en"` before starting pi. Th
|
|
|
110
125
|
## 🚧 Limitations
|
|
111
126
|
|
|
112
127
|
- TUI mode only.
|
|
113
|
-
-
|
|
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.
|
|
114
129
|
- The preview shows the last 500 messages of very long sessions. Hits in earlier messages are still counted and announced.
|
|
115
130
|
- `pi --peek` without a value is rejected by pi at startup. Use `pi --rp` to open without a keyword.
|
|
116
131
|
- Only sessions under `~/.pi/agent/sessions` (or `$PI_CODING_AGENT_DIR/sessions`) are scanned.
|
|
@@ -124,6 +139,8 @@ pi-session-peek/
|
|
|
124
139
|
│ ├── peek-component.ts # The two-pane TUI component
|
|
125
140
|
│ ├── sessions.ts # Scans and parses session JSONL, rename / delete helpers
|
|
126
141
|
│ ├── query.ts # Query parsing, highlighting, hit snippets
|
|
142
|
+
│ ├── mouse.ts # Mouse reporting for pi's regular (non-fullscreen) mode
|
|
143
|
+
│ ├── selection.ts # Pane-confined drag selection: ranges, highlight, text extraction
|
|
127
144
|
│ ├── i18n.ts # Chinese / English UI strings and locale detection
|
|
128
145
|
│ └── text.ts # Path, time and width helpers
|
|
129
146
|
├── scripts/screenshot.mjs # Optional: renders a synthetic screenshot from component output
|
package/README.zh-CN.md
CHANGED
|
@@ -74,6 +74,21 @@ pi 扩展以你的用户权限运行,只装信得过的包。
|
|
|
74
74
|
| `Ctrl+D` | 删除,按 `y` 或 `Enter` 确认。只要系统有回收站就优先进回收站(`trash` 命令、Windows 用 PowerShell 进回收站、macOS 用 Finder、Linux 用 `gio trash` / `trash-put`),全都不可用才直接删文件。 |
|
|
75
75
|
| `Esc` `Ctrl+C` | 关闭 |
|
|
76
76
|
|
|
77
|
+
## 🖱️ 鼠标
|
|
78
|
+
|
|
79
|
+
| 操作 | 作用 |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| 点列表里的会话 | 选中 |
|
|
82
|
+
| 双击会话 | 进入,和 `Enter` 一样 |
|
|
83
|
+
| 在列表上滚滚轮 | 换选中项 |
|
|
84
|
+
| 在预览上滚滚轮 | 预览每格滚三行,按住 `Alt` 快五倍 |
|
|
85
|
+
| 点头部的范围字样 | 当前目录树 ↔ 全部项目,和 `Tab` 一样 |
|
|
86
|
+
| 点搜索框或改名行 | 移动光标 |
|
|
87
|
+
| 在某一栏里按住拖动 | 只选这一栏的文字。拖过分隔线或拖出边界,选区仍留在起点那一栏里,按这一栏的宽度换行。 |
|
|
88
|
+
| 有选区时按 `Ctrl+C` | 复制到剪贴板。松开鼠标不会自动复制。没有选区时 `Ctrl+C` 仍是关闭;点一下任意位置清掉选区。 |
|
|
89
|
+
|
|
90
|
+
鼠标上报只在选择器打开时开、关闭时关,pi 的其他界面不受影响。开着的时候终端自带的选文字和滚回看历史不能用,多数终端按住 `Shift` 可以照旧,但那是终端自己的选区,会横跨两栏。不想要鼠标就设 `PI_SESSION_PEEK_MOUSE=0`。pi 的全屏模式下走 pi 自己的鼠标分发,这个变量不起作用。
|
|
91
|
+
|
|
77
92
|
## 🔎 搜索语法
|
|
78
93
|
|
|
79
94
|
大小写不敏感,匹配文本中任意位置。空白分隔多个词,每个词都要满足。
|
|
@@ -110,7 +125,7 @@ Windows PowerShell 里先执行 `$env:PI_SESSION_PEEK_LANG = "en"` 再启动 pi
|
|
|
110
125
|
## 🚧 限制
|
|
111
126
|
|
|
112
127
|
- 只支持 TUI 模式。
|
|
113
|
-
-
|
|
128
|
+
- 鼠标需要终端支持 SGR 鼠标上报和光标位置回报(`CSI 6 n`)。Windows Terminal、iTerm2、kitty、WezTerm、Alacritty、GNOME Terminal、VS Code 都支持,老式 Windows 控制台不支持。
|
|
114
129
|
- 特别长的会话预览只显示最后 500 条消息,更早的命中仍会计数并提示。
|
|
115
130
|
- `pi --peek` 不带值会被 pi 在启动时拒绝,不带关键词请用 `pi --rp`。
|
|
116
131
|
- 只扫描 `~/.pi/agent/sessions`(或 `$PI_CODING_AGENT_DIR/sessions`)下的会话。
|
|
@@ -124,6 +139,8 @@ pi-session-peek/
|
|
|
124
139
|
│ ├── peek-component.ts # 双栏 TUI 组件
|
|
125
140
|
│ ├── sessions.ts # 扫描解析会话 JSONL,重命名 / 删除
|
|
126
141
|
│ ├── query.ts # 查询解析、高亮、命中片段
|
|
142
|
+
│ ├── mouse.ts # 常规(非全屏)模式下的鼠标上报
|
|
143
|
+
│ ├── selection.ts # 限定在一栏内的拖选:区间、反显、取文字
|
|
127
144
|
│ ├── i18n.ts # 中英文界面文案和语言检测
|
|
128
145
|
│ └── text.ts # 路径、时间、宽度工具
|
|
129
146
|
├── scripts/screenshot.mjs # 可选:用组件渲染输出生成一张合成截图
|
package/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { getMarkdownTheme, SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { copyToClipboard, getMarkdownTheme, SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { msg } from "./src/i18n.ts";
|
|
4
|
+
import { attachMouse } from "./src/mouse.ts";
|
|
4
5
|
import { PeekComponent } from "./src/peek-component.ts";
|
|
5
6
|
import { deleteSession, renameSession, scanSessions, type PeekSession } from "./src/sessions.ts";
|
|
6
7
|
|
|
@@ -65,6 +66,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
65
66
|
const picked = await ctx.ui.custom<Picked>((tui, theme, _kb, done) => {
|
|
66
67
|
const comp = new PeekComponent(all, ctx.cwd, theme, getMarkdownTheme(), process.stdout.rows || 24, initial);
|
|
67
68
|
comp.requestRender = () => tui.requestRender();
|
|
69
|
+
// 常规模式 pi 不开鼠标,自己开;全屏模式 pi-tui 会直接调 comp.handleMouse
|
|
70
|
+
const mouse = attachMouse(comp, tui);
|
|
71
|
+
comp.onDispose = () => mouse.dispose();
|
|
72
|
+
// 鼠标拖选后 Ctrl+C 复制。用 pi 自己的复制:原生剪贴板,不行再退到 OSC 52
|
|
73
|
+
comp.onCopy = async (text) => {
|
|
74
|
+
try {
|
|
75
|
+
await copyToClipboard(text);
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
68
81
|
comp.onResume = (s) => {
|
|
69
82
|
lastQuery = comp.getQuery();
|
|
70
83
|
done({ s, action: "resume" });
|
package/package.json
CHANGED
package/src/i18n.ts
CHANGED
|
@@ -32,7 +32,10 @@ const zh = {
|
|
|
32
32
|
ai: "🤖 AI",
|
|
33
33
|
renamePrefix: "✏ 重命名: ",
|
|
34
34
|
confirmDelete: "⚠ 删除会话 [{desc}]?按 y / Enter 确认,任意其他键取消",
|
|
35
|
-
footer: "↑↓ 选择 PgUp/Dn·^U/^F·⇧↑↓ 滚动 ^N/^P 命中 ^T 工具 Tab 范围 ^D 删除 ^R 改名 ^O 分叉 Enter 进入 Esc/^C 关闭",
|
|
35
|
+
footer: "↑↓ 选择 PgUp/Dn·^U/^F·⇧↑↓ 滚动 ^N/^P 命中 ^T 工具 Tab 范围 ^D 删除 ^R 改名 ^O 分叉 Enter 进入 Esc/^C 关闭 🖱 点选·双击进入·拖选·滚轮",
|
|
36
|
+
selHint: "▭ 已选 {n} 行 ^C 复制 点击别处取消",
|
|
37
|
+
copied: "✓ 已复制 {n} 行",
|
|
38
|
+
copyFailed: "✗ 复制失败",
|
|
36
39
|
since: "近{n}{unit}",
|
|
37
40
|
unitH: "小时",
|
|
38
41
|
unitD: "天",
|
|
@@ -72,7 +75,10 @@ const en: Record<keyof typeof zh, string> = {
|
|
|
72
75
|
ai: "🤖 AI",
|
|
73
76
|
renamePrefix: "✏ Rename: ",
|
|
74
77
|
confirmDelete: "⚠ Delete session [{desc}]? y / Enter to confirm, any other key to cancel",
|
|
75
|
-
footer: "↑↓ select PgUp/Dn·^U/^F·⇧↑↓ scroll ^N/^P hits ^T tools Tab scope ^D del ^R rename ^O fork Enter open Esc close",
|
|
78
|
+
footer: "↑↓ select PgUp/Dn·^U/^F·⇧↑↓ scroll ^N/^P hits ^T tools Tab scope ^D del ^R rename ^O fork Enter open Esc close 🖱 click·dbl-click·drag·wheel",
|
|
79
|
+
selHint: "▭ {n} lines selected ^C copy click elsewhere to clear",
|
|
80
|
+
copied: "✓ Copied {n} lines",
|
|
81
|
+
copyFailed: "✗ Copy failed",
|
|
76
82
|
since: "last {n}{unit}",
|
|
77
83
|
unitH: "h",
|
|
78
84
|
unitD: "d",
|
package/src/mouse.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { TuiInputListenerResult, TuiMouseButton, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
// pi 只在全屏模式开鼠标上报,常规模式要自己开:选择器打开时让终端用 SGR 格式上报鼠标(CSI ?1000h ?1006h),
|
|
4
|
+
// 在 TUI 的输入监听里截下 \x1b[<b;x;yM / m,换算成组件内的行列交给 handleMouse,关闭时再关掉。
|
|
5
|
+
//
|
|
6
|
+
// 组件在屏幕上的顶行 pi 自己也不知道:常规模式只做相对光标移动,起始行取决于启动时 shell 的光标停在哪。
|
|
7
|
+
// 所以每次重画之后发一个光标位置查询(CSI 6 n),终端回 \x1b[row;colR。硬件光标停在组件里带焦点的
|
|
8
|
+
// 输入框那一行,减掉这一行在组件里的偏移就是顶行。全屏模式下 pi-tui 自带鼠标分发,这里什么都不做。
|
|
9
|
+
//
|
|
10
|
+
// 开了鼠标上报之后终端自己的选文字 / 滚回看历史就不能用了(多数终端按住 Shift 仍可以),
|
|
11
|
+
// 不想要的话设 PI_SESSION_PEEK_MOUSE=0。
|
|
12
|
+
|
|
13
|
+
// 1002:按着键移动也上报,拖选要用;不认 1002 的终端退到 1000 只报按下松开
|
|
14
|
+
const ENABLE_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
|
|
15
|
+
const DISABLE_MOUSE = "\x1b[?1006l\x1b[?1002l\x1b[?1000l";
|
|
16
|
+
const CURSOR_QUERY = "\x1b[6n";
|
|
17
|
+
const DOUBLE_CLICK_MS = 500; // 和 pi-tui 全屏模式一样
|
|
18
|
+
const QUERY_TIMEOUT_MS = 1000; // 终端不回复就当丢了,允许下次重画再问
|
|
19
|
+
const ALT_WHEEL_MULTIPLIER = 5; // 和 pi-tui 一样,按住 Alt 滚快五倍
|
|
20
|
+
export const DETACH_GRACE_MS = 150; // 关闭后再听一小会儿:终端可能还有已经发出、在路上的鼠标序列
|
|
21
|
+
|
|
22
|
+
const SGR_MOUSE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
|
|
23
|
+
const CURSOR_REPORT = /^\x1b\[(\d+);(\d+)R$/;
|
|
24
|
+
|
|
25
|
+
export interface MouseTarget {
|
|
26
|
+
handleMouse(ev: TuiMouseEvent): TuiMouseEventResult | undefined;
|
|
27
|
+
/** 硬件光标在组件内的行号(0 起);没有焦点时返回 undefined,此时光标不在组件上,测不准 */
|
|
28
|
+
cursorRow(): number | undefined;
|
|
29
|
+
/** 由 attachMouse 赋值,组件每次 render 之后调用 */
|
|
30
|
+
afterRender?: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 用到的 TUI 子集,测试里好造假 */
|
|
34
|
+
export interface MouseHost {
|
|
35
|
+
readonly mode: string;
|
|
36
|
+
terminal: { write(data: string): void; readonly columns: number; readonly rows: number };
|
|
37
|
+
addInputListener(listener: (data: string) => TuiInputListenerResult): () => void;
|
|
38
|
+
requestRender(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MouseBridge {
|
|
42
|
+
/** 常规模式下为 true;全屏模式 pi-tui 自己分发鼠标,这里不介入 */
|
|
43
|
+
readonly active: boolean;
|
|
44
|
+
dispose(): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function decodeButton(b: number): TuiMouseButton {
|
|
48
|
+
switch (b & 3) {
|
|
49
|
+
case 0:
|
|
50
|
+
return "left";
|
|
51
|
+
case 1:
|
|
52
|
+
return "middle";
|
|
53
|
+
case 2:
|
|
54
|
+
return "right";
|
|
55
|
+
default:
|
|
56
|
+
return "none";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function attachMouse(target: MouseTarget, tui: MouseHost, env: NodeJS.ProcessEnv = process.env): MouseBridge {
|
|
61
|
+
if (tui.mode !== "regular" || !tui.terminal || env.PI_SESSION_PEEK_MOUSE === "0") {
|
|
62
|
+
return { active: false, dispose() {} };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let top: number | undefined; // 组件顶行在屏幕上的行号(0 起),没测到之前鼠标事件只吞不发
|
|
66
|
+
let inflight: number | undefined; // 已发出、还没收到回复的查询:发出时光标所在的组件内行号
|
|
67
|
+
let dirty = false; // 查询在途时又重画了,回复之后要再问一次
|
|
68
|
+
let scheduled = false;
|
|
69
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
70
|
+
let pressed: { x: number; y: number } | undefined;
|
|
71
|
+
let moved = false; // 按下之后指针换过格没有;换过就不算单击
|
|
72
|
+
let lastClick: { x: number; y: number; at: number; count: number } | undefined;
|
|
73
|
+
let disposed = false;
|
|
74
|
+
let off: (() => void) | undefined;
|
|
75
|
+
|
|
76
|
+
const clearTimer = (): void => {
|
|
77
|
+
if (timer) clearTimeout(timer);
|
|
78
|
+
timer = undefined;
|
|
79
|
+
};
|
|
80
|
+
const detach = (): void => {
|
|
81
|
+
off?.();
|
|
82
|
+
off = undefined;
|
|
83
|
+
};
|
|
84
|
+
// 关闭后不马上摘监听:查询的回复和路上的鼠标序列都要吃掉,别漏进 pi 的编辑器
|
|
85
|
+
const scheduleDetach = (): void => {
|
|
86
|
+
const t = setTimeout(detach, DETACH_GRACE_MS);
|
|
87
|
+
t.unref?.();
|
|
88
|
+
};
|
|
89
|
+
// 查询结束(收到回复或超时)
|
|
90
|
+
const finishQuery = (): void => {
|
|
91
|
+
inflight = undefined;
|
|
92
|
+
clearTimer();
|
|
93
|
+
if (disposed) scheduleDetach();
|
|
94
|
+
else if (dirty) schedule();
|
|
95
|
+
};
|
|
96
|
+
const query = (): void => {
|
|
97
|
+
scheduled = false;
|
|
98
|
+
if (disposed || inflight !== undefined) return;
|
|
99
|
+
const row = target.cursorRow();
|
|
100
|
+
if (row === undefined) return;
|
|
101
|
+
dirty = false;
|
|
102
|
+
inflight = row;
|
|
103
|
+
tui.terminal.write(CURSOR_QUERY);
|
|
104
|
+
timer = setTimeout(finishQuery, QUERY_TIMEOUT_MS);
|
|
105
|
+
timer.unref?.();
|
|
106
|
+
};
|
|
107
|
+
// 等 pi 把这一帧写完、光标摆好再问
|
|
108
|
+
const schedule = (): void => {
|
|
109
|
+
if (scheduled || disposed) return;
|
|
110
|
+
scheduled = true;
|
|
111
|
+
process.nextTick(query);
|
|
112
|
+
};
|
|
113
|
+
target.afterRender = () => {
|
|
114
|
+
dirty = true;
|
|
115
|
+
schedule();
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
off = tui.addInputListener((data): TuiInputListenerResult => {
|
|
119
|
+
const cpr = CURSOR_REPORT.exec(data);
|
|
120
|
+
if (cpr) {
|
|
121
|
+
if (inflight === undefined) return undefined; // 不是我们问的
|
|
122
|
+
top = Number(cpr[1]) - 1 - inflight;
|
|
123
|
+
finishQuery();
|
|
124
|
+
return { consume: true };
|
|
125
|
+
}
|
|
126
|
+
const m = SGR_MOUSE.exec(data);
|
|
127
|
+
if (!m) return undefined;
|
|
128
|
+
if (top === undefined || disposed) return { consume: true };
|
|
129
|
+
const b = Number(m[1]);
|
|
130
|
+
const sx = Number(m[2]) - 1;
|
|
131
|
+
const sy = Number(m[3]) - 1;
|
|
132
|
+
const release = m[4] === "m";
|
|
133
|
+
const base = {
|
|
134
|
+
x: sx,
|
|
135
|
+
y: sy - top,
|
|
136
|
+
screenX: sx,
|
|
137
|
+
screenY: sy,
|
|
138
|
+
width: tui.terminal.columns,
|
|
139
|
+
height: tui.terminal.rows,
|
|
140
|
+
shift: (b & 4) !== 0,
|
|
141
|
+
alt: (b & 8) !== 0,
|
|
142
|
+
ctrl: (b & 16) !== 0,
|
|
143
|
+
};
|
|
144
|
+
let render = false;
|
|
145
|
+
const dispatch = (ev: TuiMouseEvent): void => {
|
|
146
|
+
const r = target.handleMouse(ev);
|
|
147
|
+
if (!r || !(r.handled || r.capture || r.focus)) return;
|
|
148
|
+
// 和 pi-tui 一样:按下、点击、滚轮默认重画,松开默认不重画
|
|
149
|
+
render ||= r.render ?? ev.type !== "release";
|
|
150
|
+
};
|
|
151
|
+
if (b & 64) {
|
|
152
|
+
const dir = (b & 3) === 0 ? -1 : (b & 3) === 1 ? 1 : 0; // 66 / 67 是横向滚轮,不管
|
|
153
|
+
if (!release && dir) {
|
|
154
|
+
dispatch({ ...base, type: "wheel", button: "none", wheelDelta: dir * (base.alt ? ALT_WHEEL_MULTIPLIER : 1) });
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
const button = decodeButton(b);
|
|
158
|
+
if (b & 32) {
|
|
159
|
+
// 按着键移动。没开 1003,不带键的纯移动不该来,来了也不管
|
|
160
|
+
if (button === "none" || !pressed) return { consume: true };
|
|
161
|
+
if (sx !== pressed.x || sy !== pressed.y) moved = true;
|
|
162
|
+
dispatch({ ...base, type: "drag", button });
|
|
163
|
+
} else if (!release) {
|
|
164
|
+
pressed = { x: sx, y: sy };
|
|
165
|
+
moved = false;
|
|
166
|
+
dispatch({ ...base, type: "press", button });
|
|
167
|
+
} else {
|
|
168
|
+
if (pressed && (sx !== pressed.x || sy !== pressed.y)) moved = true;
|
|
169
|
+
dispatch({ ...base, type: "release", button });
|
|
170
|
+
// 按下松开之间没动过算一次点击;短时间内在同一格连点,次数 1 → 2 → 3 循环
|
|
171
|
+
if (pressed && !moved) {
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
const again = lastClick && now - lastClick.at <= DOUBLE_CLICK_MS && lastClick.x === sx && lastClick.y === sy;
|
|
174
|
+
const count = again && lastClick ? (lastClick.count % 3) + 1 : 1;
|
|
175
|
+
lastClick = { x: sx, y: sy, at: now, count };
|
|
176
|
+
dispatch({ ...base, type: "click", button, clickCount: count });
|
|
177
|
+
}
|
|
178
|
+
pressed = undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (render) tui.requestRender();
|
|
182
|
+
return { consume: true };
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// pi 崩了或被杀时终端会留在鼠标模式,之后点一下 shell 里就冒乱码,退出时尽量关掉
|
|
186
|
+
const restore = (): void => {
|
|
187
|
+
try {
|
|
188
|
+
tui.terminal.write(DISABLE_MOUSE);
|
|
189
|
+
} catch {
|
|
190
|
+
// 退出时 stdout 可能已经关了
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
process.once("exit", restore);
|
|
194
|
+
|
|
195
|
+
const dispose = (): void => {
|
|
196
|
+
if (disposed) return;
|
|
197
|
+
disposed = true;
|
|
198
|
+
process.off("exit", restore);
|
|
199
|
+
target.afterRender = undefined;
|
|
200
|
+
// 选择器开着时切到了全屏模式的话,鼠标已经归 pi-tui 管,别去关
|
|
201
|
+
if (tui.mode === "regular") restore();
|
|
202
|
+
if (inflight === undefined) scheduleDetach();
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
tui.terminal.write(ENABLE_MOUSE);
|
|
206
|
+
return { active: true, dispose };
|
|
207
|
+
}
|
package/src/peek-component.ts
CHANGED
|
@@ -8,14 +8,23 @@ import {
|
|
|
8
8
|
type Component,
|
|
9
9
|
type Focusable,
|
|
10
10
|
type MarkdownTheme,
|
|
11
|
+
type TuiMouseEvent,
|
|
12
|
+
type TuiMouseEventResult,
|
|
11
13
|
} from "@earendil-works/pi-tui";
|
|
12
14
|
import { msg } from "./i18n.ts";
|
|
13
|
-
import { anyMatch, highlight, lineHasMatch, matchesSession, parseQuery, roleTerms, snippet, type ParsedQuery } from "./query.ts";
|
|
15
|
+
import { anyMatch, highlight, lineHasMatch, matchesSession, parseQuery, roleTerms, snippet, type ParsedQuery, type RoleTerms } from "./query.ts";
|
|
16
|
+
import { bounds, highlightColumns, rowColumns, selectionText, type Cell, type Pane, type Selection } from "./selection.ts";
|
|
14
17
|
import type { PeekMsg, PeekSession } from "./sessions.ts";
|
|
15
18
|
import { fmtTime, normPath, padEndVisible } from "./text.ts";
|
|
16
19
|
|
|
17
20
|
// 双栏选择器。文件操作(删除 / 重命名 / 分叉)不在这里做,由 index.ts 通过回调注入
|
|
18
21
|
|
|
22
|
+
const BODY_TOP = 3; // 头部、搜索框、分隔线之后才是双栏主体
|
|
23
|
+
const SEP = 3; // 两栏之间 " │ " 占的列数
|
|
24
|
+
const WHEEL_LINES = 3; // 滚轮每格滚几行预览,和 Shift+↑↓ 一样
|
|
25
|
+
const AUTO_SCROLL_MS = 50; // 拖选拖出预览上下边时自动滚动的节奏,和 pi-tui 一样
|
|
26
|
+
const HINT_MS = 1500; // 底部"已复制"提示停留时间
|
|
27
|
+
|
|
19
28
|
export class PeekComponent implements Component, Focusable {
|
|
20
29
|
private input: Input;
|
|
21
30
|
private scope: "current" | "all";
|
|
@@ -33,6 +42,16 @@ export class PeekComponent implements Component, Focusable {
|
|
|
33
42
|
private renameInput: Input;
|
|
34
43
|
private cachedWidth = -1;
|
|
35
44
|
private cachedLines: string[] = [];
|
|
45
|
+
private layout = { lw: 0, rw: 0, H: 0 }; // 最近一次 render 的分栏尺寸,鼠标定位用
|
|
46
|
+
private scopeSpan: [number, number] = [0, 0]; // 头部"范围"字样占的列区间,点击切换范围
|
|
47
|
+
private sel?: Selection; // 鼠标拖出来的选区,只在一栏里
|
|
48
|
+
private dragging = false; // 左键还按着
|
|
49
|
+
private selMoved = false; // 按下之后拖过没有;没拖过的选区不显示也不算数(那是单击)
|
|
50
|
+
private dragX = 0; // 最近一次拖动的列,自动滚动时焦点跟着它
|
|
51
|
+
private autoScrollDir = 0; // 拖出预览上下边:-1 往上 1 往下
|
|
52
|
+
private autoScrollTimer?: ReturnType<typeof setInterval>;
|
|
53
|
+
private hint?: { text: string; ok: boolean }; // 底部一闪而过的复制结果
|
|
54
|
+
private hintTimer?: ReturnType<typeof setTimeout>;
|
|
36
55
|
private _focused = false;
|
|
37
56
|
|
|
38
57
|
// 由 index.ts 注入
|
|
@@ -41,7 +60,10 @@ export class PeekComponent implements Component, Focusable {
|
|
|
41
60
|
onCancel?: () => void;
|
|
42
61
|
onDelete?: (s: PeekSession) => Promise<boolean>;
|
|
43
62
|
onRename?: (s: PeekSession, name: string) => Promise<boolean>;
|
|
63
|
+
onCopy?: (text: string) => Promise<boolean>; // 拖选后 Ctrl+C
|
|
64
|
+
onDispose?: () => void; // 关闭时
|
|
44
65
|
requestRender?: () => void; // 异步操作完成后让 TUI 重画
|
|
66
|
+
afterRender?: () => void; // 每次 render 之后调用;常规模式的鼠标桥接靠它重新测组件在屏幕上的位置
|
|
45
67
|
|
|
46
68
|
constructor(
|
|
47
69
|
private all: PeekSession[],
|
|
@@ -97,6 +119,33 @@ export class PeekComponent implements Component, Focusable {
|
|
|
97
119
|
this.selected = idx >= 0 ? idx : keepSelection ? Math.min(this.selected, Math.max(0, out.length - 1)) : 0;
|
|
98
120
|
if (!keepSelection) this.listOffset = 0;
|
|
99
121
|
this.previewKey = "";
|
|
122
|
+
this.clearSelection();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 换选中的会话:预览重建,拖选的选区作废
|
|
126
|
+
private select(idx: number): void {
|
|
127
|
+
this.selected = idx;
|
|
128
|
+
this.previewKey = "";
|
|
129
|
+
this.clearSelection();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private clearSelection(): void {
|
|
133
|
+
this.sel = undefined;
|
|
134
|
+
this.dragging = false;
|
|
135
|
+
this.selMoved = false;
|
|
136
|
+
this.stopAutoScroll();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 拖过的才算选区,只按下没拖的是单击
|
|
140
|
+
private activeSel(): Selection | undefined {
|
|
141
|
+
return this.sel && this.selMoved ? this.sel : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
dispose(): void {
|
|
145
|
+
this.stopAutoScroll();
|
|
146
|
+
if (this.hintTimer) clearTimeout(this.hintTimer);
|
|
147
|
+
this.hintTimer = undefined;
|
|
148
|
+
this.onDispose?.();
|
|
100
149
|
}
|
|
101
150
|
|
|
102
151
|
private bodyHeight(): number {
|
|
@@ -152,6 +201,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
152
201
|
// 选中位置留在原地
|
|
153
202
|
this.selected = Math.min(this.selected, Math.max(0, this.filtered.length - 1));
|
|
154
203
|
this.previewKey = "";
|
|
204
|
+
this.clearSelection();
|
|
155
205
|
}
|
|
156
206
|
this.invalidate();
|
|
157
207
|
this.requestRender?.();
|
|
@@ -161,6 +211,11 @@ export class PeekComponent implements Component, Focusable {
|
|
|
161
211
|
return;
|
|
162
212
|
}
|
|
163
213
|
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
214
|
+
// 有拖选的选区时 Ctrl+C 是复制,和终端里的习惯一样;没有才是关闭
|
|
215
|
+
if (matchesKey(data, Key.ctrl("c")) && this.activeSel()) {
|
|
216
|
+
this.copySelection();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
164
219
|
this.onCancel?.();
|
|
165
220
|
return;
|
|
166
221
|
}
|
|
@@ -189,16 +244,14 @@ export class PeekComponent implements Component, Focusable {
|
|
|
189
244
|
}
|
|
190
245
|
if (matchesKey(data, Key.up)) {
|
|
191
246
|
if (this.selected > 0) {
|
|
192
|
-
this.selected
|
|
193
|
-
this.previewKey = "";
|
|
247
|
+
this.select(this.selected - 1);
|
|
194
248
|
this.invalidate();
|
|
195
249
|
}
|
|
196
250
|
return;
|
|
197
251
|
}
|
|
198
252
|
if (matchesKey(data, Key.down)) {
|
|
199
253
|
if (this.selected < this.filtered.length - 1) {
|
|
200
|
-
this.selected
|
|
201
|
-
this.previewKey = "";
|
|
254
|
+
this.select(this.selected + 1);
|
|
202
255
|
this.invalidate();
|
|
203
256
|
}
|
|
204
257
|
return;
|
|
@@ -228,6 +281,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
228
281
|
if (matchesKey(data, Key.ctrl("t"))) {
|
|
229
282
|
this.showTools = !this.showTools;
|
|
230
283
|
this.previewKey = "";
|
|
284
|
+
this.clearSelection();
|
|
231
285
|
this.invalidate();
|
|
232
286
|
return;
|
|
233
287
|
}
|
|
@@ -256,6 +310,202 @@ export class PeekComponent implements Component, Focusable {
|
|
|
256
310
|
this.invalidate();
|
|
257
311
|
}
|
|
258
312
|
|
|
313
|
+
// 硬件光标在组件内的行号:搜索框在第 1 行,改名时在最后一行。常规模式的鼠标桥接靠它算组件顶行
|
|
314
|
+
cursorRow(): number | undefined {
|
|
315
|
+
if (!this._focused || !this.layout.H) return undefined;
|
|
316
|
+
return this.renaming ? BODY_TOP + this.layout.H + 1 : 1;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 鼠标:左栏按下选中、双击进入、滚轮换选中项;右栏滚轮滚预览;点头部"范围"切换;点输入框移光标;
|
|
320
|
+
// 在任一栏里按住拖动是选文字,只选这一栏,Ctrl+C 复制。
|
|
321
|
+
// 全屏模式由 pi-tui 按布局直接调,常规模式由 mouse.ts 换算成组件内坐标后调。坐标以组件左上角为原点
|
|
322
|
+
handleMouse(ev: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
323
|
+
const { lw, H } = this.layout;
|
|
324
|
+
if (!H) return undefined; // 还没画过,不知道分栏在哪
|
|
325
|
+
const inList = ev.x < lw;
|
|
326
|
+
const bodyRow = ev.y - BODY_TOP;
|
|
327
|
+
const inBody = bodyRow >= 0 && bodyRow < H;
|
|
328
|
+
const itemAt = (): number => (inList && inBody ? this.listOffset + (bodyRow >> 1) : -1); // 每项两行
|
|
329
|
+
|
|
330
|
+
if (ev.type === "wheel") {
|
|
331
|
+
const delta = ev.wheelDelta ?? 0;
|
|
332
|
+
if (!delta) return undefined;
|
|
333
|
+
let changed = this.confirmingDelete; // 和按键一样,滚一下就算取消确认,免得删错换过去的那条
|
|
334
|
+
this.confirmingDelete = false;
|
|
335
|
+
if (inList) {
|
|
336
|
+
if (!this.renaming) {
|
|
337
|
+
// 改名中换了会话就改错对象了
|
|
338
|
+
const next = Math.max(0, Math.min(this.filtered.length - 1, this.selected + delta));
|
|
339
|
+
if (next !== this.selected) {
|
|
340
|
+
this.select(next);
|
|
341
|
+
changed = true;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
const maxOff = Math.max(0, this.previewLines.length - H);
|
|
346
|
+
const next = Math.max(0, Math.min(maxOff, this.previewOffset + delta * WHEEL_LINES));
|
|
347
|
+
if (next !== this.previewOffset) {
|
|
348
|
+
this.previewOffset = next;
|
|
349
|
+
changed = true;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (changed) this.invalidate();
|
|
353
|
+
return { handled: true, render: changed };
|
|
354
|
+
}
|
|
355
|
+
if (ev.type === "drag") {
|
|
356
|
+
if (!this.dragging || !this.sel || ev.button !== "left") return undefined;
|
|
357
|
+
this.dragX = ev.x;
|
|
358
|
+
const changed = this.dragTo(ev.x, ev.y);
|
|
359
|
+
if (changed) this.selMoved = true;
|
|
360
|
+
// 右栏拖出上下边就自动滚;左栏不滚,列表是跟着选中项走的
|
|
361
|
+
if (this.sel.pane === "preview") this.setAutoScroll(bodyRow < 0 ? -1 : bodyRow >= H ? 1 : 0);
|
|
362
|
+
if (changed) this.invalidate();
|
|
363
|
+
return { handled: true, render: changed };
|
|
364
|
+
}
|
|
365
|
+
if (ev.button !== "left") return undefined;
|
|
366
|
+
if (ev.type === "release") {
|
|
367
|
+
if (!this.dragging) return undefined;
|
|
368
|
+
this.dragging = false;
|
|
369
|
+
this.stopAutoScroll();
|
|
370
|
+
if (!this.selMoved) this.sel = undefined; // 没拖过就是单击
|
|
371
|
+
return { handled: true, render: false };
|
|
372
|
+
}
|
|
373
|
+
if (ev.type === "click") {
|
|
374
|
+
// 单击在 press 里已经选中了,双击才进入
|
|
375
|
+
const s = this.filtered[itemAt()];
|
|
376
|
+
if (!s) return undefined;
|
|
377
|
+
if (!this.renaming && (ev.clickCount ?? 1) >= 2) this.onResume?.(s);
|
|
378
|
+
return { handled: true, render: false };
|
|
379
|
+
}
|
|
380
|
+
if (ev.type !== "press") return undefined;
|
|
381
|
+
|
|
382
|
+
// 删除确认和已有的选区:点一下就没了,和按键一样
|
|
383
|
+
let changed = this.confirmingDelete || this.activeSel() !== undefined;
|
|
384
|
+
this.confirmingDelete = false;
|
|
385
|
+
this.clearSelection();
|
|
386
|
+
let handled = changed;
|
|
387
|
+
if (this.renaming) {
|
|
388
|
+
if (ev.y === BODY_TOP + H + 1) {
|
|
389
|
+
// 改名行:点哪里光标就去哪里
|
|
390
|
+
this.renameInput.handleMouse({ ...ev, x: ev.x - visibleWidth(msg("renamePrefix")), y: 0 });
|
|
391
|
+
handled = changed = true;
|
|
392
|
+
}
|
|
393
|
+
} else if (ev.y === 1) {
|
|
394
|
+
this.input.handleMouse({ ...ev, y: 0 });
|
|
395
|
+
handled = changed = true;
|
|
396
|
+
} else if (ev.y === 0) {
|
|
397
|
+
if (ev.x >= this.scopeSpan[0] && ev.x < this.scopeSpan[1]) {
|
|
398
|
+
this.scope = this.scope === "all" ? "current" : "all";
|
|
399
|
+
this.refilter();
|
|
400
|
+
handled = changed = true;
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
const idx = itemAt();
|
|
404
|
+
if (idx >= 0 && idx < this.filtered.length && idx !== this.selected) {
|
|
405
|
+
this.select(idx);
|
|
406
|
+
changed = true;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (inBody) {
|
|
410
|
+
// 两栏里按下都可能是拖选的起点,拖起来才算数;接管后面的拖动和松开
|
|
411
|
+
const pane: Pane = inList ? "list" : "preview";
|
|
412
|
+
const cell = this.cellAt(pane, ev.x, bodyRow);
|
|
413
|
+
this.sel = { pane, anchor: cell, focus: { ...cell } };
|
|
414
|
+
this.dragging = true;
|
|
415
|
+
this.dragX = ev.x;
|
|
416
|
+
handled = true;
|
|
417
|
+
}
|
|
418
|
+
if (!handled) return undefined;
|
|
419
|
+
if (changed) this.invalidate();
|
|
420
|
+
return inBody ? { handled: true, capture: true, render: changed } : { handled: true, render: changed };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// 屏幕位置换成某一栏的内容坐标:行是内容的绝对行号,列夹在这一栏里,拖到栏外也只选栏内的字
|
|
424
|
+
private cellAt(pane: Pane, x: number, bodyRow: number): Cell {
|
|
425
|
+
const { lw, rw, H } = this.layout;
|
|
426
|
+
const row = Math.max(0, Math.min(H - 1, bodyRow));
|
|
427
|
+
if (pane === "list") return { row: this.listOffset * 2 + row, col: Math.max(0, Math.min(lw - 1, x)) };
|
|
428
|
+
return { row: this.previewOffset + row, col: Math.max(0, Math.min(rw - 1, x - lw - SEP)) };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// 拖到某个位置:焦点跟过去。返回有没有变
|
|
432
|
+
private dragTo(x: number, y: number): boolean {
|
|
433
|
+
if (!this.sel) return false;
|
|
434
|
+
const cell = this.cellAt(this.sel.pane, x, y - BODY_TOP);
|
|
435
|
+
if (cell.row === this.sel.focus.row && cell.col === this.sel.focus.col) return false;
|
|
436
|
+
this.sel.focus = cell;
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private setAutoScroll(dir: number): void {
|
|
441
|
+
this.autoScrollDir = dir;
|
|
442
|
+
if (!dir) {
|
|
443
|
+
this.stopAutoScroll();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (this.autoScrollTimer) return;
|
|
447
|
+
this.autoScrollTimer = setInterval(() => this.autoScrollTick(), AUTO_SCROLL_MS);
|
|
448
|
+
this.autoScrollTimer.unref?.();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// 每次滚一行,焦点跟着指针:指针在上边就是视口第一行,在下边就是最后一行
|
|
452
|
+
private autoScrollTick(): void {
|
|
453
|
+
const { H } = this.layout;
|
|
454
|
+
const maxOff = Math.max(0, this.previewLines.length - H);
|
|
455
|
+
const next = Math.max(0, Math.min(maxOff, this.previewOffset + this.autoScrollDir));
|
|
456
|
+
if (!this.sel || !this.dragging || next === this.previewOffset) {
|
|
457
|
+
this.stopAutoScroll();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
this.previewOffset = next;
|
|
461
|
+
if (this.dragTo(this.dragX, this.autoScrollDir < 0 ? BODY_TOP - 1 : BODY_TOP + H)) this.selMoved = true;
|
|
462
|
+
this.invalidate();
|
|
463
|
+
this.requestRender?.();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private stopAutoScroll(): void {
|
|
467
|
+
if (this.autoScrollTimer) clearInterval(this.autoScrollTimer);
|
|
468
|
+
this.autoScrollTimer = undefined;
|
|
469
|
+
this.autoScrollDir = 0;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// 选区里的纯文本
|
|
473
|
+
private selectionToText(sel: Selection): string {
|
|
474
|
+
const { lw, rw } = this.layout;
|
|
475
|
+
if (sel.pane === "preview") return selectionText(sel, rw, (row) => this.previewLines[row]);
|
|
476
|
+
const { rt, hasBody } = this.listTerms();
|
|
477
|
+
return selectionText(sel, lw, (row) => {
|
|
478
|
+
const s = this.filtered[row >> 1];
|
|
479
|
+
return s && this.listRows(s, (row >> 1) === this.selected, lw, rt, hasBody)[row & 1];
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Ctrl+C:复制选区,清掉高亮,底部闪一下结果
|
|
484
|
+
private copySelection(): void {
|
|
485
|
+
const sel = this.activeSel();
|
|
486
|
+
if (!sel) return;
|
|
487
|
+
const text = this.selectionToText(sel);
|
|
488
|
+
const n = text.split("\n").length;
|
|
489
|
+
this.clearSelection();
|
|
490
|
+
this.invalidate();
|
|
491
|
+
if (!text.trim() || !this.onCopy) return;
|
|
492
|
+
void this.onCopy(text).then((ok) => this.showHint(ok ? msg("copied", { n }) : msg("copyFailed"), ok));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private showHint(text: string, ok: boolean): void {
|
|
496
|
+
this.hint = { text, ok };
|
|
497
|
+
if (this.hintTimer) clearTimeout(this.hintTimer);
|
|
498
|
+
this.hintTimer = setTimeout(() => {
|
|
499
|
+
this.hintTimer = undefined;
|
|
500
|
+
this.hint = undefined;
|
|
501
|
+
this.invalidate();
|
|
502
|
+
this.requestRender?.();
|
|
503
|
+
}, HINT_MS);
|
|
504
|
+
this.hintTimer.unref?.();
|
|
505
|
+
this.invalidate();
|
|
506
|
+
this.requestRender?.();
|
|
507
|
+
}
|
|
508
|
+
|
|
259
509
|
// 预览里跳到当前视口之外的下一个 / 上一个命中行,到头了回绕。同一屏里的多个命中算一处,
|
|
260
510
|
// 否则一段里连着几行都命中要按好几次才过得去
|
|
261
511
|
private jumpMatch(dir: 1 | -1): void {
|
|
@@ -275,11 +525,37 @@ export class PeekComponent implements Component, Focusable {
|
|
|
275
525
|
}
|
|
276
526
|
}
|
|
277
527
|
|
|
528
|
+
// 左栏用的关键词:排除词和 name: / cwd: 不在正文里高亮
|
|
529
|
+
private listTerms(): { rt: RoleTerms; hasBody: boolean } {
|
|
530
|
+
const rt = roleTerms(this.query().terms);
|
|
531
|
+
return { rt, hasBody: rt.user.length > 0 || rt.assistant.length > 0 };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// 左栏一个会话的两行:时间 + 目录(有正文关键词时加命中数),首条消息或命中片段
|
|
535
|
+
private listRows(s: PeekSession, sel: boolean, lw: number, rt: RoleTerms, hasBody: boolean): [string, string] {
|
|
536
|
+
const t = this.theme;
|
|
537
|
+
const time = fmtTime(s.time);
|
|
538
|
+
const cwdTail = s.cwd.replace(/\\/g, "/").split("/").slice(-2).join("/");
|
|
539
|
+
const hitBadge = hasBody
|
|
540
|
+
? ` ·${s.msgs.reduce((n, m) => n + (anyMatch(m.text, rt[m.role]) ? 1 : 0), 0)}`
|
|
541
|
+
: "";
|
|
542
|
+
const l1 = `${sel ? "›" : " "} ${time} ${cwdTail}${hitBadge}`;
|
|
543
|
+
const snip = hasBody ? snippet(s.msgs, rt, lw) : undefined;
|
|
544
|
+
const l2 = snip !== undefined
|
|
545
|
+
? ` ${highlight(snip.text, rt[snip.role], t)}`
|
|
546
|
+
: ` ${s.first}${s.name ? ` [${s.name}]` : ""}`;
|
|
547
|
+
if (sel) {
|
|
548
|
+
return [
|
|
549
|
+
t.bg("selectedBg", padEndVisible(t.fg("accent", truncateToWidth(l1, lw)), lw)),
|
|
550
|
+
t.bg("selectedBg", padEndVisible(truncateToWidth(l2, lw), lw)),
|
|
551
|
+
];
|
|
552
|
+
}
|
|
553
|
+
return [padEndVisible(l1, lw), padEndVisible(t.fg("dim", truncateToWidth(l2, lw)), lw)];
|
|
554
|
+
}
|
|
555
|
+
|
|
278
556
|
// 左栏,每个会话两行
|
|
279
557
|
private buildList(height: number, lw: number): string[] {
|
|
280
|
-
const
|
|
281
|
-
const rt = roleTerms(this.query().terms); // 排除词和 name: / cwd: 不在正文里高亮
|
|
282
|
-
const hasBody = rt.user.length > 0 || rt.assistant.length > 0;
|
|
558
|
+
const { rt, hasBody } = this.listTerms();
|
|
283
559
|
const visible = Math.max(1, Math.floor(height / 2));
|
|
284
560
|
if (this.selected < this.listOffset) this.listOffset = this.selected;
|
|
285
561
|
if (this.selected >= this.listOffset + visible) {
|
|
@@ -294,26 +570,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
294
570
|
rows.push(" ".repeat(lw), " ".repeat(lw));
|
|
295
571
|
continue;
|
|
296
572
|
}
|
|
297
|
-
|
|
298
|
-
const time = fmtTime(s.time);
|
|
299
|
-
const cwdTail = s.cwd.replace(/\\/g, "/").split("/").slice(-2).join("/");
|
|
300
|
-
// 有正文关键词时显示命中的消息数
|
|
301
|
-
const hitBadge = hasBody
|
|
302
|
-
? ` ·${s.msgs.reduce((n, m) => n + (anyMatch(m.text, rt[m.role]) ? 1 : 0), 0)}`
|
|
303
|
-
: "";
|
|
304
|
-
const l1 = `${sel ? "›" : " "} ${time} ${cwdTail}${hitBadge}`;
|
|
305
|
-
// 第二行:有正文关键词时显示命中片段,否则显示首条消息
|
|
306
|
-
const snip = hasBody ? snippet(s.msgs, rt, lw) : undefined;
|
|
307
|
-
const l2 = snip !== undefined
|
|
308
|
-
? ` ${highlight(snip.text, rt[snip.role], t)}`
|
|
309
|
-
: ` ${s.first}${s.name ? ` [${s.name}]` : ""}`;
|
|
310
|
-
if (sel) {
|
|
311
|
-
rows.push(t.bg("selectedBg", padEndVisible(t.fg("accent", truncateToWidth(l1, lw)), lw)));
|
|
312
|
-
rows.push(t.bg("selectedBg", padEndVisible(truncateToWidth(l2, lw), lw)));
|
|
313
|
-
} else {
|
|
314
|
-
rows.push(padEndVisible(l1, lw));
|
|
315
|
-
rows.push(padEndVisible(t.fg("dim", truncateToWidth(l2, lw)), lw));
|
|
316
|
-
}
|
|
573
|
+
rows.push(...this.listRows(s, idx === this.selected, lw, rt, hasBody));
|
|
317
574
|
}
|
|
318
575
|
// 每项两行,height 是奇数时会少一行,补空行,否则最后一行右栏会顶到左边
|
|
319
576
|
while (rows.length < height) rows.push(" ".repeat(lw));
|
|
@@ -441,16 +698,24 @@ export class PeekComponent implements Component, Focusable {
|
|
|
441
698
|
render(width: number): string[] {
|
|
442
699
|
const H = this.bodyHeight();
|
|
443
700
|
const cacheKey = width * 1000 + H; // 宽或高变了都要重画
|
|
444
|
-
if (this.cachedWidth === cacheKey)
|
|
701
|
+
if (this.cachedWidth === cacheKey) {
|
|
702
|
+
// 缓存命中时组件在屏幕上的位置也可能变了(上面多了一行状态提示),照样通知一次,桥接那边自己去重
|
|
703
|
+
this.afterRender?.();
|
|
704
|
+
return this.cachedLines;
|
|
705
|
+
}
|
|
445
706
|
const t = this.theme;
|
|
446
707
|
const lw = Math.max(26, Math.min(56, Math.floor(width * 0.4)));
|
|
447
708
|
const rw = Math.max(20, width - lw - 3);
|
|
709
|
+
this.layout = { lw, rw, H };
|
|
448
710
|
|
|
449
711
|
const out: string[] = [];
|
|
450
712
|
|
|
451
713
|
// 头部:标题、范围、时间过滤、匹配数,然后是搜索框
|
|
452
714
|
const scopeLabel = this.scope === "all" ? msg("scopeAll") : msg("scopeCurrent");
|
|
453
715
|
const { sinceLabel } = this.query();
|
|
716
|
+
// 「范围[Tab]: 当前目录树」整段都能点
|
|
717
|
+
const scopeStart = visibleWidth(msg("title"));
|
|
718
|
+
this.scopeSpan = [scopeStart, scopeStart + visibleWidth(msg("scopeLabel") + scopeLabel)];
|
|
454
719
|
const head =
|
|
455
720
|
t.fg("accent", t.bold(msg("title"))) +
|
|
456
721
|
t.fg("dim", msg("scopeLabel")) +
|
|
@@ -478,17 +743,26 @@ export class PeekComponent implements Component, Focusable {
|
|
|
478
743
|
if (this.previewLines.length > H) info += ` (${this.previewOffset + 1}-${end}/${this.previewLines.length})`;
|
|
479
744
|
const scrollInfo = info ? t.fg("dim", info) : "";
|
|
480
745
|
|
|
746
|
+
const sel = this.activeSel();
|
|
481
747
|
for (let i = 0; i < H; i++) {
|
|
482
|
-
|
|
748
|
+
let l = leftRows[i] ?? " ".repeat(lw);
|
|
483
749
|
let r = rightRows[i] ?? "";
|
|
484
|
-
|
|
485
|
-
|
|
750
|
+
const infoW = i === 0 && scrollInfo ? visibleWidth(scrollInfo) : 0;
|
|
751
|
+
if (infoW) r = truncateToWidth(r, Math.max(0, rw - infoW));
|
|
752
|
+
r = padEndVisible(r, rw - infoW) + (infoW ? scrollInfo : "");
|
|
753
|
+
// 拖选的高亮最后叠上去,反显整格;右栏第一行别盖到右上角的命中信息
|
|
754
|
+
if (sel?.pane === "list") {
|
|
755
|
+
const cols = rowColumns(sel, this.listOffset * 2 + i, l, lw);
|
|
756
|
+
if (cols) l = highlightColumns(l, cols[0], cols[1]);
|
|
757
|
+
} else if (sel?.pane === "preview") {
|
|
758
|
+
const cols = rowColumns(sel, this.previewOffset + i, r, rw - infoW);
|
|
759
|
+
if (cols) r = highlightColumns(r, cols[0], cols[1]);
|
|
486
760
|
}
|
|
487
761
|
// 左栏在 buildList 里已经 pad 到 lw,这里再 pad 会把带样式的行截坏
|
|
488
|
-
out.push(l + t.fg("borderMuted", " │ ") +
|
|
762
|
+
out.push(l + t.fg("borderMuted", " │ ") + r);
|
|
489
763
|
}
|
|
490
764
|
|
|
491
|
-
//
|
|
765
|
+
// 底部:重命名和删除确认时换成对应的操作行,复制结果和选区提示也在这里
|
|
492
766
|
out.push(t.fg("borderMuted", "─".repeat(width)));
|
|
493
767
|
if (this.renaming) {
|
|
494
768
|
const prefix = msg("renamePrefix");
|
|
@@ -503,6 +777,11 @@ export class PeekComponent implements Component, Focusable {
|
|
|
503
777
|
width,
|
|
504
778
|
),
|
|
505
779
|
);
|
|
780
|
+
} else if (this.hint) {
|
|
781
|
+
out.push(truncateToWidth(t.fg(this.hint.ok ? "success" : "error", this.hint.text), width));
|
|
782
|
+
} else if (sel) {
|
|
783
|
+
const [s, e] = bounds(sel);
|
|
784
|
+
out.push(truncateToWidth(t.fg("warning", msg("selHint", { n: e.row - s.row + 1 })), width));
|
|
506
785
|
} else {
|
|
507
786
|
out.push(
|
|
508
787
|
truncateToWidth(
|
|
@@ -514,6 +793,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
514
793
|
|
|
515
794
|
this.cachedWidth = cacheKey;
|
|
516
795
|
this.cachedLines = out;
|
|
796
|
+
this.afterRender?.();
|
|
517
797
|
return out;
|
|
518
798
|
}
|
|
519
799
|
|
package/src/selection.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { sliceByColumn, stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
// 鼠标拖出来的选区。只在一个栏里:行号是这一栏内容的绝对行号(左栏每个会话两行,右栏是预览行),
|
|
4
|
+
// 列号是栏内列号。起止格都算在内,中间的行整行选中,所以从左栏拖到右栏也只会选左栏的字
|
|
5
|
+
|
|
6
|
+
export type Pane = "list" | "preview";
|
|
7
|
+
|
|
8
|
+
export interface Cell {
|
|
9
|
+
row: number;
|
|
10
|
+
col: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Selection {
|
|
14
|
+
pane: Pane;
|
|
15
|
+
anchor: Cell; // 按下的格
|
|
16
|
+
focus: Cell; // 现在拖到的格
|
|
17
|
+
}
|
|
18
|
+
|
|
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
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
23
|
+
|
|
24
|
+
/** 起止格,按阅读顺序 */
|
|
25
|
+
export function bounds(sel: Selection): [Cell, Cell] {
|
|
26
|
+
const a = sel.anchor;
|
|
27
|
+
const f = sel.focus;
|
|
28
|
+
const ordered = a.row < f.row || (a.row === f.row && a.col <= f.col);
|
|
29
|
+
return ordered ? [a, f] : [f, a];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 每个字符占的列区间,宽字符两列
|
|
33
|
+
function cells(line: string): { start: number; end: number }[] {
|
|
34
|
+
const out: { start: number; end: number }[] = [];
|
|
35
|
+
let col = 0;
|
|
36
|
+
for (const { segment } of graphemes.segment(stripTerminalSequences(line))) {
|
|
37
|
+
const w = visibleWidth(segment);
|
|
38
|
+
if (w > 0) out.push({ start: col, end: col + w });
|
|
39
|
+
col += w;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 某一行被选中的列区间 [c0, c1)。边界吸附到整个字符,宽字符不会被切掉一半;
|
|
46
|
+
* 这一行不在选区里返回 undefined。width 是这一栏的宽度
|
|
47
|
+
*/
|
|
48
|
+
export function rowColumns(sel: Selection, row: number, line: string, width: number): [number, number] | undefined {
|
|
49
|
+
const [s, e] = bounds(sel);
|
|
50
|
+
if (row < s.row || row > e.row) return undefined;
|
|
51
|
+
let c0 = row === s.row ? s.col : 0;
|
|
52
|
+
let c1 = row === e.row ? e.col + 1 : width;
|
|
53
|
+
const cs = cells(line);
|
|
54
|
+
const g0 = cs.find((g) => c0 >= g.start && c0 < g.end);
|
|
55
|
+
if (g0) c0 = g0.start;
|
|
56
|
+
const g1 = cs.find((g) => c1 - 1 >= g.start && c1 - 1 < g.end);
|
|
57
|
+
if (g1) c1 = g1.end;
|
|
58
|
+
c0 = Math.max(0, Math.min(c0, width));
|
|
59
|
+
c1 = Math.max(c0, Math.min(c1, width));
|
|
60
|
+
return c1 > c0 ? [c0, c1] : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 整段反显。段里原有的样式码照留,每个样式码后面补一次反显,免得被 0m 之类的重置掉
|
|
64
|
+
function inverse(text: string): string {
|
|
65
|
+
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;
|
|
71
|
+
}
|
|
72
|
+
return out + text.slice(last) + "\x1b[27m";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 给一行的 [c0, c1) 列加反显,其余部分原样 */
|
|
76
|
+
export function highlightColumns(line: string, c0: number, c1: number): string {
|
|
77
|
+
const w = visibleWidth(line);
|
|
78
|
+
const before = sliceByColumn(line, 0, c0, true);
|
|
79
|
+
const mid = sliceByColumn(line, c0, c1 - c0, true);
|
|
80
|
+
const after = sliceByColumn(line, c1, Math.max(0, w - c1), true);
|
|
81
|
+
// sliceByColumn 切到行尾时会丢掉最后那些收尾码(39m、49m 之类),补回去,否则背景色会漏到右边
|
|
82
|
+
const tail = TAIL.exec(line)?.[0] ?? "";
|
|
83
|
+
return before + inverse(mid) + after + tail;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 选区里的纯文本:每行去掉样式和尾部空白,行间用换行。lineAt 给不出的行当空行 */
|
|
87
|
+
export function selectionText(sel: Selection, width: number, lineAt: (row: number) => string | undefined): string {
|
|
88
|
+
const [s, e] = bounds(sel);
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
for (let row = s.row; row <= e.row; row++) {
|
|
91
|
+
const line = lineAt(row) ?? "";
|
|
92
|
+
const cols = rowColumns(sel, row, line, width);
|
|
93
|
+
lines.push(cols ? stripTerminalSequences(sliceByColumn(line, cols[0], cols[1] - cols[0], true)).trimEnd() : "");
|
|
94
|
+
}
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|