pi-lazy-panel 0.1.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/LICENSE +21 -0
- package/README.md +104 -0
- package/README.zh.md +104 -0
- package/docs/design.md +235 -0
- package/docs/keybindings.md +172 -0
- package/docs/keybindings.zh.md +172 -0
- package/package.json +67 -0
- package/src/actions/session-actions.ts +585 -0
- package/src/actions/tree-actions.ts +161 -0
- package/src/config/config.ts +155 -0
- package/src/config/keymap.ts +219 -0
- package/src/config/keys.ts +279 -0
- package/src/config/pi-settings.ts +54 -0
- package/src/constants.ts +48 -0
- package/src/data/changelog.ts +63 -0
- package/src/data/content.ts +178 -0
- package/src/data/search.ts +183 -0
- package/src/data/sessions.ts +148 -0
- package/src/data/tree-fold.ts +163 -0
- package/src/data/tree.ts +258 -0
- package/src/i18n/index.ts +103 -0
- package/src/i18n/locales/en.json +341 -0
- package/src/i18n/locales/zh.json +341 -0
- package/src/index.ts +155 -0
- package/src/types.ts +346 -0
- package/src/ui/app.ts +2892 -0
- package/src/ui/frame.ts +116 -0
- package/src/ui/mouse-input.ts +197 -0
- package/src/ui/mouse.ts +99 -0
- package/src/ui/panes/content-pane.ts +132 -0
- package/src/ui/panes/sessions-pane.ts +161 -0
- package/src/ui/panes/tree-pane.ts +152 -0
- package/src/ui/search-highlight.ts +108 -0
- package/src/ui/tree-lines.ts +111 -0
- package/src/ui/tree-outline.ts +80 -0
- package/src/ui/widgets/changelog-dialog.ts +196 -0
- package/src/ui/widgets/compact-dialog.ts +29 -0
- package/src/ui/widgets/confirm-dialog.ts +98 -0
- package/src/ui/widgets/export-dialog.ts +62 -0
- package/src/ui/widgets/footer.ts +117 -0
- package/src/ui/widgets/fork-dialog.ts +28 -0
- package/src/ui/widgets/help-overlay.ts +220 -0
- package/src/ui/widgets/import-dialog.ts +32 -0
- package/src/ui/widgets/input-dialog.ts +136 -0
- package/src/ui/widgets/label-dialog.ts +28 -0
- package/src/ui/widgets/new-session-dialog.ts +29 -0
- package/src/ui/widgets/prompt-bar.ts +92 -0
- package/src/ui/widgets/rename-dialog.ts +29 -0
- package/src/ui/widgets/restore-dialog.ts +60 -0
- package/src/ui/widgets/search-bar.ts +65 -0
- package/src/ui/widgets/select-dialog.ts +193 -0
- package/src/ui/widgets/session-info-dialog.ts +189 -0
- package/src/ui/widgets/tree-dialog.ts +293 -0
- package/src/utils/format.ts +83 -0
- package/src/utils/paths.ts +33 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions pane (left, top).
|
|
3
|
+
*
|
|
4
|
+
* Lists `SessionRow[]`. Each row uses two lines:
|
|
5
|
+
*
|
|
6
|
+
* › FilmRecall 09-20 22:18
|
|
7
|
+
* opus-4 · ~/code/myself/FilmRecall · 128 msgs
|
|
8
|
+
*
|
|
9
|
+
* The cursor row is highlighted; sessions picked with space have no marker glyph
|
|
10
|
+
* — their title is tinted accent so selected rows stand out in a long list — and
|
|
11
|
+
* the header starts with "3 selected". While a `/` search is active in the pane the
|
|
12
|
+
* matching rows get their hits painted (see ../search-highlight.ts) and the
|
|
13
|
+
* header counts them ("2/7 matches"); the list itself is never filtered.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
18
|
+
import { t } from "../../i18n/index.ts";
|
|
19
|
+
import type { ListScope, SearchView, SessionRow, SessionSortMode } from "../../types.ts";
|
|
20
|
+
import { formatShortDate, shortenPath } from "../../utils/format.ts";
|
|
21
|
+
import { fit, frame, metaBudget } from "../frame.ts";
|
|
22
|
+
import { highlightLine, matchStyle, searchMeta } from "../search-highlight.ts";
|
|
23
|
+
|
|
24
|
+
export interface SessionsPaneProps {
|
|
25
|
+
rows: SessionRow[];
|
|
26
|
+
cursor: number;
|
|
27
|
+
/** First visible row; defaults to a cursor-centered window when omitted (keyboard). */
|
|
28
|
+
first?: number;
|
|
29
|
+
focused: boolean;
|
|
30
|
+
scope: ListScope;
|
|
31
|
+
sort: SessionSortMode;
|
|
32
|
+
selected: Set<string>;
|
|
33
|
+
/** Active `/` search of this pane: matching rows are highlighted, the header shows the count. */
|
|
34
|
+
search?: SearchView;
|
|
35
|
+
/** Frame title; the panel passes "[1] SESSIONS" so the jump key is visible. */
|
|
36
|
+
title?: string;
|
|
37
|
+
theme: Theme;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Each session takes two lines; the tree / mouse hit-test share this constant. */
|
|
41
|
+
export const SESSIONS_ROW_HEIGHT = 2;
|
|
42
|
+
|
|
43
|
+
export function renderSessionsPane(p: SessionsPaneProps, width: number, height: number): string[] {
|
|
44
|
+
const { theme } = p;
|
|
45
|
+
const inner = width - 2;
|
|
46
|
+
const visibleRows = Math.max(1, Math.floor((height - 2) / SESSIONS_ROW_HEIGHT));
|
|
47
|
+
const body: string[] = [];
|
|
48
|
+
|
|
49
|
+
if (p.rows.length === 0) {
|
|
50
|
+
body.push(theme.fg("muted", ` ${t("pane.sessionsEmpty")}`));
|
|
51
|
+
} else {
|
|
52
|
+
// 滚轮滚动时用给定的 first(不动光标);否则按光标居中。
|
|
53
|
+
const first = clampFirst(p.first ?? scrollOffset(p.cursor, p.rows.length, visibleRows), p.rows.length, visibleRows);
|
|
54
|
+
for (let i = first; i < Math.min(p.rows.length, first + visibleRows); i++) {
|
|
55
|
+
const row = p.rows[i]!;
|
|
56
|
+
const isCursor = i === p.cursor;
|
|
57
|
+
const lines = renderRow(row, inner, isCursor, p);
|
|
58
|
+
// 搜索命中的行:两行里出现的关键词都加高亮,光标所在的当前匹配再加强调。
|
|
59
|
+
const search = p.search;
|
|
60
|
+
if (search?.matches.has(i)) {
|
|
61
|
+
const style = matchStyle(theme, i === search.current);
|
|
62
|
+
body.push(...lines.map((l) => highlightLine(l, search.terms, style)));
|
|
63
|
+
} else {
|
|
64
|
+
body.push(...lines);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const title = p.title ?? "SESSIONS";
|
|
70
|
+
const budget = metaBudget(width, title);
|
|
71
|
+
const selected = selectedMeta(p.selected.size);
|
|
72
|
+
const rest = Math.max(0, budget - (selected ? visibleWidth(selected) + 3 : 0));
|
|
73
|
+
const base = p.search ? searchMeta(p.search.position, p.search.total, rest) : sessionsMeta(p.rows.length, p.cursor, p.scope, p.sort, rest);
|
|
74
|
+
// 有多选时标题右侧先显示 "3 selected",剩下的空间再放位置 / 搜索计数。
|
|
75
|
+
const meta = selected && visibleWidth(selected) <= budget ? (base && rest > 0 ? `${selected} · ${base}` : selected) : base;
|
|
76
|
+
return frame(body, {
|
|
77
|
+
width,
|
|
78
|
+
height,
|
|
79
|
+
title,
|
|
80
|
+
meta,
|
|
81
|
+
border: (s) => theme.fg(p.focused ? "borderAccent" : "border", s),
|
|
82
|
+
titleStyle: (s) => (p.focused ? theme.bold(theme.fg("accent", s)) : theme.fg("muted", s)),
|
|
83
|
+
metaStyle: (s) => theme.fg("dim", s),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderRow(row: SessionRow, inner: number, isCursor: boolean, p: SessionsPaneProps): string[] {
|
|
88
|
+
const { theme } = p;
|
|
89
|
+
const indent = " ".repeat(row.threadDepth ?? 0);
|
|
90
|
+
// 光标标记 ›(占第一列,第二列留空对齐);多选不再画图标,只靠标题着色区分。
|
|
91
|
+
const isSelected = p.selected.has(row.file);
|
|
92
|
+
const marker = `${isCursor ? "›" : " "} `;
|
|
93
|
+
const date = formatShortDate(row.updatedAt);
|
|
94
|
+
const emptyTitle = t("pane.emptySession");
|
|
95
|
+
const title = row.name ?? row.preview ?? emptyTitle;
|
|
96
|
+
|
|
97
|
+
// line 1: marker + indent + title ....... date
|
|
98
|
+
const rightW = visibleWidth(date) + 1;
|
|
99
|
+
const titleW = Math.max(1, inner - visibleWidth(marker) - visibleWidth(indent) - rightW);
|
|
100
|
+
const titleText = truncateToWidth(title || emptyTitle, titleW, "…", true);
|
|
101
|
+
|
|
102
|
+
// line 2: model · cwd · N msgs
|
|
103
|
+
const details = [row.model ?? "", shortenPath(row.cwd), t("pane.msgs", { count: row.messageCount })].filter(Boolean).join(" · ");
|
|
104
|
+
const line2Raw = ` ${indent}${truncateToWidth(details, Math.max(1, inner - 2 - visibleWidth(indent)), "…", false)}`;
|
|
105
|
+
|
|
106
|
+
if (isCursor) {
|
|
107
|
+
const hl = (s: string) => theme.bg("selectedBg", fit(s, inner));
|
|
108
|
+
// 光标行整行反白;被选中时标题额外着 accent 色。
|
|
109
|
+
const titleSpan = isSelected ? theme.bold(theme.fg("accent", titleText)) : theme.bold(titleText);
|
|
110
|
+
return [
|
|
111
|
+
hl(theme.bold(theme.fg("accent", marker[0]!)) + marker.slice(1) + indent + titleSpan + " " + theme.fg("dim", date)),
|
|
112
|
+
hl(theme.fg("muted", line2Raw)),
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
// 选中行:标题用 accent 色,一眼能从列表里扫出来;未选中用普通 text 色。
|
|
116
|
+
const titleStyle = isSelected ? (s: string) => theme.fg("accent", s) : (s: string) => theme.fg("text", s);
|
|
117
|
+
return [
|
|
118
|
+
fit(marker + indent + titleStyle(titleText) + " " + theme.fg("dim", date), inner),
|
|
119
|
+
fit(theme.fg("muted", line2Raw), inner),
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Header labels of the list scope (localised), long form and the short form used when space is tight. */
|
|
124
|
+
export function scopeLabels(scope: ListScope): { long: string; short: string } {
|
|
125
|
+
return scope === "all"
|
|
126
|
+
? { long: t("scope.allLong"), short: t("scope.allShort") }
|
|
127
|
+
: { long: t("scope.currentLong"), short: t("scope.currentShort") };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Header meta for the sessions pane: "1/15 · Current · recent".
|
|
132
|
+
*
|
|
133
|
+
* 宽度不够时按顺序降级(去掉排序 → 缩短 scope → 只留位置),保证任何宽度下
|
|
134
|
+
* 用户都能看到当前 scope 和会话数量,而不是整段消失。
|
|
135
|
+
*/
|
|
136
|
+
export function sessionsMeta(total: number, cursor: number, scope: ListScope, sort: SessionSortMode, budget: number): string {
|
|
137
|
+
const label = scopeLabels(scope);
|
|
138
|
+
const sortName = t(`sort.${sort}`);
|
|
139
|
+
const pos = `${Math.min(cursor + 1, total)}/${total}`;
|
|
140
|
+
const candidates = total
|
|
141
|
+
? [`${pos} · ${label.long} · ${sortName}`, `${pos} · ${label.long}`, `${pos} · ${label.short}`, pos]
|
|
142
|
+
: [label.long, label.short];
|
|
143
|
+
return candidates.find((c) => visibleWidth(c) <= budget) ?? candidates[candidates.length - 1]!;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** "3 selected" while sessions are multi-selected with space, "" otherwise. */
|
|
147
|
+
export function selectedMeta(count: number): string {
|
|
148
|
+
return count > 0 ? t("pane.selected", { count }) : "";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** First visible index so that `cursor` stays inside a window of `visible` rows. */
|
|
152
|
+
export function scrollOffset(cursor: number, total: number, visible: number): number {
|
|
153
|
+
if (total <= visible) return 0;
|
|
154
|
+
const half = Math.floor(visible / 2);
|
|
155
|
+
return Math.min(Math.max(0, cursor - half), total - visible);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Clamp a first-visible index to a valid window start (0 .. last possible window). */
|
|
159
|
+
export function clampFirst(first: number, total: number, visible: number): number {
|
|
160
|
+
return Math.max(0, Math.min(first, Math.max(0, total - visible)));
|
|
161
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree pane (left, bottom).
|
|
3
|
+
*
|
|
4
|
+
* Lists the `TreeRow[]` of the session highlighted in the sessions pane as a
|
|
5
|
+
* folded outline (prefixes from ../tree-outline.ts, fold rules in
|
|
6
|
+
* ../../data/tree-fold.ts):
|
|
7
|
+
*
|
|
8
|
+
* › • 22:18 [system]
|
|
9
|
+
* • 22:18 user: hi
|
|
10
|
+
* • 22:19 assistant: Hi! I'm ready to help with ...
|
|
11
|
+
* ▸ 22:20 user: try again
|
|
12
|
+
* ▾ • 22:21 [system]
|
|
13
|
+
* • 22:21 user: hi
|
|
14
|
+
* • 22:21 assistant: …
|
|
15
|
+
*
|
|
16
|
+
* A `▸` row is a folded side branch (its rows are hidden; `z` unfolds it), `▾`
|
|
17
|
+
* an open one, `─` a dead-end alternative; rows inside a branch are indented
|
|
18
|
+
* two columns per level, at most four levels (`… ` beyond). Side branches start
|
|
19
|
+
* folded, the active branch open. The pane renders whatever rows it is given —
|
|
20
|
+
* the panel hides the folded descendants and passes the outline prefixes; the
|
|
21
|
+
* full tree with pi-style guide lines lives in the tree dialog (`a`,
|
|
22
|
+
* ../widgets/tree-dialog.ts). The cursor is highlighted, nodes off the active
|
|
23
|
+
* branch are dimmed. While a `/` search is active in the pane the matching
|
|
24
|
+
* rows get their hits painted (see ../search-highlight.ts) and the header
|
|
25
|
+
* counts them; folded matches are unfolded by the panel when jumped to.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
30
|
+
import { t } from "../../i18n/index.ts";
|
|
31
|
+
import type { SearchView, TreeFilter, TreeRow } from "../../types.ts";
|
|
32
|
+
import { formatTime } from "../../utils/format.ts";
|
|
33
|
+
import { fit, frame, metaBudget } from "../frame.ts";
|
|
34
|
+
import { highlightLine, matchStyle, type RowHighlight, searchMeta } from "../search-highlight.ts";
|
|
35
|
+
import { type OutlinePrefix, treeOutline } from "../tree-outline.ts";
|
|
36
|
+
import { clampFirst, scrollOffset } from "./sessions-pane.ts";
|
|
37
|
+
|
|
38
|
+
export interface TreePaneProps {
|
|
39
|
+
/** Rows to list (folded branches already hidden). */
|
|
40
|
+
rows: TreeRow[];
|
|
41
|
+
/** Outline prefix per entry id (see ../tree-outline.ts); derived from `rows` alone when omitted. */
|
|
42
|
+
outline?: ReadonlyMap<string, OutlinePrefix>;
|
|
43
|
+
cursor: number;
|
|
44
|
+
/** First visible row; defaults to a cursor-centered window when omitted (keyboard). */
|
|
45
|
+
first?: number;
|
|
46
|
+
focused: boolean;
|
|
47
|
+
/** Active tree filter (set in the tree dialog); shown in the header when it is not the default. */
|
|
48
|
+
filter?: TreeFilter;
|
|
49
|
+
/** Active `/` search of this pane (indices into `rows`): matching rows are highlighted, the header shows the count. */
|
|
50
|
+
search?: SearchView;
|
|
51
|
+
/** Shown when no session is selected / loading. */
|
|
52
|
+
emptyMessage?: string;
|
|
53
|
+
/** Frame title; the panel passes "[2] TREE" so the jump key is visible. */
|
|
54
|
+
title?: string;
|
|
55
|
+
theme: Theme;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const NO_PREFIX: OutlinePrefix = { indent: "", marker: "" };
|
|
59
|
+
|
|
60
|
+
export function renderTreePane(p: TreePaneProps, width: number, height: number): string[] {
|
|
61
|
+
const { theme } = p;
|
|
62
|
+
const inner = width - 2;
|
|
63
|
+
const visible = Math.max(1, height - 2);
|
|
64
|
+
const body: string[] = [];
|
|
65
|
+
|
|
66
|
+
if (p.rows.length === 0) {
|
|
67
|
+
body.push(theme.fg("muted", ` ${p.emptyMessage ?? t("pane.treeEmpty")}`));
|
|
68
|
+
} else {
|
|
69
|
+
const outline = p.outline ?? treeOutline(p.rows, new Set());
|
|
70
|
+
// 滚轮滚动时用给定的 first(不动光标);否则按光标居中。
|
|
71
|
+
const first = clampFirst(p.first ?? scrollOffset(p.cursor, p.rows.length, visible), p.rows.length, visible);
|
|
72
|
+
for (let i = first; i < Math.min(p.rows.length, first + visible); i++) {
|
|
73
|
+
const row = p.rows[i]!;
|
|
74
|
+
const search = p.search;
|
|
75
|
+
const highlight = search?.matches.has(i) ? { terms: search.terms, current: i === search.current } : undefined;
|
|
76
|
+
body.push(renderTreeRow(row, styleOutline(outline.get(row.entryId) ?? NO_PREFIX, theme), inner, i === p.cursor, theme, highlight));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const title = p.title ?? "TREE";
|
|
81
|
+
const budget = metaBudget(width, title);
|
|
82
|
+
const meta = p.search ? searchMeta(p.search.position, p.search.total, budget) : treeMeta(p.rows.length, p.cursor, p.filter ?? "default", budget);
|
|
83
|
+
return frame(body, {
|
|
84
|
+
width,
|
|
85
|
+
height,
|
|
86
|
+
title,
|
|
87
|
+
...(meta ? { meta } : {}),
|
|
88
|
+
border: (s) => theme.fg(p.focused ? "borderAccent" : "border", s),
|
|
89
|
+
titleStyle: (s) => (p.focused ? theme.bold(theme.fg("accent", s)) : theme.fg("muted", s)),
|
|
90
|
+
metaStyle: (s) => theme.fg("dim", s),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Header meta of the tree pane: "2/12", or "2/12 · user-only" while a filter
|
|
96
|
+
* other than the default is active (the filter is chosen in the tree dialog,
|
|
97
|
+
* so the pane says why rows are missing). Degrades to the position alone when
|
|
98
|
+
* the header is too narrow; an empty tree only names the filter.
|
|
99
|
+
*/
|
|
100
|
+
export function treeMeta(total: number, cursor: number, filter: TreeFilter, budget: number): string {
|
|
101
|
+
const pos = total ? `${Math.min(cursor + 1, total)}/${total}` : "";
|
|
102
|
+
const name = t(`filter.${filter}`);
|
|
103
|
+
const candidates = filter === "default" ? [pos] : total ? [`${pos} · ${name}`, pos] : [name, ""];
|
|
104
|
+
return candidates.find((c) => visibleWidth(c) <= budget) ?? "";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Indent as dim as guide lines; the fold marker a notch brighter so a folded branch still shows on a dimmed row. */
|
|
108
|
+
function styleOutline(prefix: OutlinePrefix, theme: Theme): string {
|
|
109
|
+
return theme.fg("dim", prefix.indent) + theme.fg("muted", prefix.marker);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* One tree line, laid out like pi's /tree: `› ` cursor marker, the (already
|
|
114
|
+
* styled) outline or guide-line prefix, `• ` on the active path, `[label]`,
|
|
115
|
+
* time, `role: ` (omitted for system rows whose text already is a
|
|
116
|
+
* `[system]`-style tag) and the text truncated to what is left. Shared with
|
|
117
|
+
* the tree dialog. With `highlight` (a row matching the pane's search) the
|
|
118
|
+
* terms are painted on top of the finished line.
|
|
119
|
+
*/
|
|
120
|
+
export function renderTreeRow(row: TreeRow, prefix: string, inner: number, isCursor: boolean, theme: Theme, highlight?: RowHighlight): string {
|
|
121
|
+
const marker = isCursor ? "› " : " ";
|
|
122
|
+
// 和 pi 一样,活动路径上的节点在文字前加 `• `。
|
|
123
|
+
const path = row.onActiveBranch ? "• " : "";
|
|
124
|
+
const label = row.label ? `[${row.label}] ` : "";
|
|
125
|
+
const time = `${formatTime(row.timestamp)} `;
|
|
126
|
+
const role = row.role === "system" ? "" : `${t(`role.${row.role}`)}: `;
|
|
127
|
+
const prefixW =
|
|
128
|
+
visibleWidth(marker) + visibleWidth(prefix) + visibleWidth(path) + visibleWidth(label) + visibleWidth(time) + visibleWidth(role);
|
|
129
|
+
const text = truncateToWidth(row.text, Math.max(1, inner - prefixW), "…", false);
|
|
130
|
+
|
|
131
|
+
const roleStyle = (s: string) =>
|
|
132
|
+
row.role === "user"
|
|
133
|
+
? theme.fg("userMessageText", s)
|
|
134
|
+
: row.role === "assistant"
|
|
135
|
+
? theme.fg("accent", s)
|
|
136
|
+
: theme.fg("muted", s);
|
|
137
|
+
const branchStyle = (s: string) => (row.onActiveBranch ? s : theme.fg("dim", s));
|
|
138
|
+
// system 行([system] / [branch summary]: …)整行用 muted,和 pi 的 dim 标签一致。
|
|
139
|
+
const textStyle = (s: string) => (row.role === "system" ? theme.fg("muted", s) : branchStyle(s));
|
|
140
|
+
|
|
141
|
+
const line =
|
|
142
|
+
theme.fg("accent", marker) +
|
|
143
|
+
prefix +
|
|
144
|
+
theme.fg("accent", path) +
|
|
145
|
+
theme.fg("warning", label) +
|
|
146
|
+
theme.fg("dim", time) +
|
|
147
|
+
branchStyle(roleStyle(role)) +
|
|
148
|
+
textStyle(text);
|
|
149
|
+
const out = isCursor ? theme.bg("selectedBg", fit(line, inner)) : fit(line, inner);
|
|
150
|
+
// 搜索命中:整行画完后再按列把关键词加上高亮,前后的颜色和光标背景都保留。
|
|
151
|
+
return highlight ? highlightLine(out, highlight.terms, matchStyle(theme, highlight.current)) : out;
|
|
152
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search-match highlighting shared by the three panes (pure functions).
|
|
3
|
+
*
|
|
4
|
+
* A pane renders its row / line exactly as before — colours, cursor background,
|
|
5
|
+
* truncation — and then paints the matches on top by visible column, the way
|
|
6
|
+
* pi's own transcript search does: the line is cut into before / match / after
|
|
7
|
+
* with pi-tui's ANSI-aware `sliceByColumn`, only the plain text runs of the
|
|
8
|
+
* match get the match style, and the "after" slice starts with the styles that
|
|
9
|
+
* were active there, so a colour or cursor background the match interrupted
|
|
10
|
+
* carries on afterwards. Matches use the theme's search colours; the current
|
|
11
|
+
* match (the one the cursor is on) is additionally bold + inverse so it stands
|
|
12
|
+
* out from the other matches, like in lazygit / pi.
|
|
13
|
+
*
|
|
14
|
+
* 高亮不改变面板原来的画法:先按原样画好整行,再按可见列把命中的片段切出来加样式
|
|
15
|
+
* (pi 自己的全屏搜索就是这么做的),所以命中前后的颜色和光标行的背景都不会断掉。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { sliceByColumn, stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
|
|
20
|
+
import { findMatchRanges } from "../data/search.ts";
|
|
21
|
+
import { t } from "../i18n/index.ts";
|
|
22
|
+
|
|
23
|
+
/** How one rendered row is highlighted: the terms to mark, and whether it is the current match. */
|
|
24
|
+
export interface RowHighlight {
|
|
25
|
+
terms: string[];
|
|
26
|
+
current: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** SGR / CSI / OSC sequences, so the styling can skip them and wrap only the text between. */
|
|
30
|
+
const ANSI_RE = /\x1b\[[0-9;?:<=>]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Style of a match: the theme's search colours, underlined for the other
|
|
34
|
+
* matches, bold + inverse for the current one (pi's own choice; the inverse
|
|
35
|
+
* shows even on a theme whose search background equals the cursor background).
|
|
36
|
+
*/
|
|
37
|
+
export function matchStyle(theme: Theme, current: boolean): (text: string) => string {
|
|
38
|
+
const colours = (s: string) => theme.bg("searchMatchBg", theme.fg("searchMatchText", s));
|
|
39
|
+
return current ? (s) => theme.bold(theme.inverse(colours(s))) : (s) => theme.underline(colours(s));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Paint every occurrence of `terms` in the already-rendered `line` with
|
|
44
|
+
* `style`, keeping its visible width and the styles around the matches.
|
|
45
|
+
*/
|
|
46
|
+
export function highlightLine(line: string, terms: string[], style: (text: string) => string): string {
|
|
47
|
+
const plain = stripTerminalSequences(line);
|
|
48
|
+
const ranges = findMatchRanges(plain, terms);
|
|
49
|
+
if (ranges.length === 0) return line;
|
|
50
|
+
const width = visibleWidth(plain);
|
|
51
|
+
let out = line;
|
|
52
|
+
// 从右往左处理,前面片段的列号不受已经改过的部分影响。
|
|
53
|
+
for (let i = ranges.length - 1; i >= 0; i--) {
|
|
54
|
+
const r = ranges[i]!;
|
|
55
|
+
const startCol = visibleWidth(plain.slice(0, r.start));
|
|
56
|
+
const endCol = Math.min(width, startCol + visibleWidth(plain.slice(r.start, r.end)));
|
|
57
|
+
if (endCol <= startCol) continue;
|
|
58
|
+
// sliceByColumn 会丢掉最后一个可见字符之后的转义序列(光标行末尾的背景色复位、上一轮画在行尾的高亮结束码),
|
|
59
|
+
// 每一轮都先剥下来、切完再接回去。
|
|
60
|
+
const [body, tail] = splitTail(out);
|
|
61
|
+
const before = sliceByColumn(body, 0, startCol, true);
|
|
62
|
+
const match = sliceByColumn(body, startCol, endCol - startCol, true);
|
|
63
|
+
const after = sliceByColumn(body, endCol, Math.max(0, width - endCol), true);
|
|
64
|
+
out = `${before}${styleText(match, style)}${after}${tail}`;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Split off the escape sequences after the last visible character of `line`. */
|
|
70
|
+
function splitTail(line: string): [body: string, tail: string] {
|
|
71
|
+
let end = line.length;
|
|
72
|
+
for (;;) {
|
|
73
|
+
const m = TRAILING_ANSI_RE.exec(line.slice(0, end));
|
|
74
|
+
if (!m) break;
|
|
75
|
+
end -= m[0].length;
|
|
76
|
+
}
|
|
77
|
+
return [line.slice(0, end), line.slice(end)];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One escape sequence at the very end of a string. */
|
|
81
|
+
const TRAILING_ANSI_RE = /(?:\x1b\[[0-9;?:<=>]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\))$/;
|
|
82
|
+
|
|
83
|
+
/** Apply `style` to the plain text runs of `text`, leaving its escape sequences in place. */
|
|
84
|
+
function styleText(text: string, style: (text: string) => string): string {
|
|
85
|
+
let out = "";
|
|
86
|
+
let last = 0;
|
|
87
|
+
for (const m of text.matchAll(ANSI_RE)) {
|
|
88
|
+
if (m.index > last) out += style(text.slice(last, m.index));
|
|
89
|
+
out += m[0];
|
|
90
|
+
last = m.index + m[0].length;
|
|
91
|
+
}
|
|
92
|
+
if (last < text.length) out += style(text.slice(last));
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Header meta while a search is active: "2/7 matches", "7 matches" when the
|
|
98
|
+
* cursor is not on a match, "no matches" for none; shortened to fit `budget`.
|
|
99
|
+
*/
|
|
100
|
+
export function searchMeta(position: number, total: number, budget: number): string {
|
|
101
|
+
const candidates =
|
|
102
|
+
total === 0
|
|
103
|
+
? [t("search.none"), "0/0"]
|
|
104
|
+
: position > 0
|
|
105
|
+
? [t("search.matches", { position, total }), `${position}/${total}`]
|
|
106
|
+
: [t("search.total", { total }), `${total}`];
|
|
107
|
+
return candidates.find((c) => visibleWidth(c) <= budget) ?? candidates[candidates.length - 1]!;
|
|
108
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree guide lines, drawn the way pi's own `/tree` selector draws them:
|
|
3
|
+
*
|
|
4
|
+
* [system]
|
|
5
|
+
* ├⊟ [hello] user: hi
|
|
6
|
+
* │ assistant: Hello!
|
|
7
|
+
* ├⊞ user: hi (folded: its descendants are not listed)
|
|
8
|
+
* └─ [branch summary]: …
|
|
9
|
+
*
|
|
10
|
+
* Pure functions over `TreeRow[]` (pre-order, linked by `parentId`); used by
|
|
11
|
+
* the full tree dialog (the small pane draws the lighter outline of
|
|
12
|
+
* ./tree-outline.ts instead). Rules copied from pi (`tree-selector.js`
|
|
13
|
+
* flattenTree):
|
|
14
|
+
* - a node whose parent has several children gets a connector (├ / └)
|
|
15
|
+
* - the indent grows by one level at a branch point and once more for the
|
|
16
|
+
* first generation after it; a single-child chain stays at the same level
|
|
17
|
+
* - descendants of a connector row carry a `│` gutter in that column while
|
|
18
|
+
* later siblings follow, a blank column once the last sibling started
|
|
19
|
+
* - `⊟` on the connector marks a node that has children, `─` a leaf and `⊞`
|
|
20
|
+
* a folded node (see ../data/tree-fold.ts); a folded root without a
|
|
21
|
+
* connector gets a `⊞ ` marker after its prefix, as in pi
|
|
22
|
+
* Every level is 3 columns wide.
|
|
23
|
+
*
|
|
24
|
+
* 树线的计算和 pi 的 /tree 一致;这里只产出前缀字符串,配色由调用方决定。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { treeChildren } from "../data/tree-fold.ts";
|
|
28
|
+
import type { TreeRow } from "../types.ts";
|
|
29
|
+
|
|
30
|
+
/** One `│` (or blank) column carried by descendants of a connector row. */
|
|
31
|
+
interface Gutter {
|
|
32
|
+
position: number;
|
|
33
|
+
show: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Guide-line prefix of every row, aligned with `rows`. Rows whose `parentId`
|
|
38
|
+
* does not name a row are treated as roots. `folded` rows are drawn with `⊞`
|
|
39
|
+
* (their descendants are expected to be missing from `rows` already).
|
|
40
|
+
*/
|
|
41
|
+
export function treePrefixes(rows: TreeRow[], folded: ReadonlySet<string> = new Set()): string[] {
|
|
42
|
+
const children = treeChildren(rows);
|
|
43
|
+
const roots = children.get(undefined) ?? [];
|
|
44
|
+
const multipleRoots = roots.length > 1;
|
|
45
|
+
const out = new Map<string, string>();
|
|
46
|
+
|
|
47
|
+
// 和 pi 一样用显式栈做先序遍历,避免深树递归爆栈。
|
|
48
|
+
interface Frame {
|
|
49
|
+
row: TreeRow;
|
|
50
|
+
indent: number;
|
|
51
|
+
justBranched: boolean;
|
|
52
|
+
showConnector: boolean;
|
|
53
|
+
isLast: boolean;
|
|
54
|
+
gutters: Gutter[];
|
|
55
|
+
isVirtualRootChild: boolean;
|
|
56
|
+
}
|
|
57
|
+
const stack: Frame[] = [];
|
|
58
|
+
for (let i = roots.length - 1; i >= 0; i--) {
|
|
59
|
+
stack.push({
|
|
60
|
+
row: roots[i]!,
|
|
61
|
+
indent: multipleRoots ? 1 : 0,
|
|
62
|
+
justBranched: multipleRoots,
|
|
63
|
+
showConnector: multipleRoots,
|
|
64
|
+
isLast: i === roots.length - 1,
|
|
65
|
+
gutters: [],
|
|
66
|
+
isVirtualRootChild: multipleRoots,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
while (stack.length) {
|
|
70
|
+
const f = stack.pop()!;
|
|
71
|
+
const kids = children.get(f.row.entryId) ?? [];
|
|
72
|
+
const displayIndent = multipleRoots ? Math.max(0, f.indent - 1) : f.indent;
|
|
73
|
+
const connector = f.showConnector && !f.isVirtualRootChild;
|
|
74
|
+
// 只有分支段的起点(showConnector)才可能被折叠;多根时的根没有连接符,折叠标记跟在前缀后面。
|
|
75
|
+
const isFolded = f.showConnector && folded.has(f.row.entryId);
|
|
76
|
+
const prefix = buildPrefix(displayIndent, connector ? displayIndent - 1 : -1, f.isLast, kids.length > 0, isFolded, f.gutters);
|
|
77
|
+
out.set(f.row.entryId, isFolded && !connector ? `${prefix}⊞ ` : prefix);
|
|
78
|
+
|
|
79
|
+
const multipleChildren = kids.length > 1;
|
|
80
|
+
// 分叉处子节点缩进 +1;分叉后的第一代再 +1 做视觉分组;单链不缩进。
|
|
81
|
+
const childIndent = multipleChildren || (f.justBranched && f.indent > 0) ? f.indent + 1 : f.indent;
|
|
82
|
+
const childGutters = connector ? [...f.gutters, { position: Math.max(0, displayIndent - 1), show: !f.isLast }] : f.gutters;
|
|
83
|
+
for (let i = kids.length - 1; i >= 0; i--) {
|
|
84
|
+
stack.push({
|
|
85
|
+
row: kids[i]!,
|
|
86
|
+
indent: childIndent,
|
|
87
|
+
justBranched: multipleChildren,
|
|
88
|
+
showConnector: multipleChildren,
|
|
89
|
+
isLast: i === kids.length - 1,
|
|
90
|
+
gutters: childGutters,
|
|
91
|
+
isVirtualRootChild: false,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return rows.map((r) => out.get(r.entryId) ?? "");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildPrefix(levels: number, connectorLevel: number, isLast: boolean, hasChildren: boolean, isFolded: boolean, gutters: Gutter[]): string {
|
|
99
|
+
const chars: string[] = [];
|
|
100
|
+
for (let level = 0; level < levels; level++) {
|
|
101
|
+
const gutter = gutters.find((g) => g.position === level);
|
|
102
|
+
if (gutter) {
|
|
103
|
+
chars.push(gutter.show ? "│" : " ", " ", " ");
|
|
104
|
+
} else if (level === connectorLevel) {
|
|
105
|
+
chars.push(isLast ? "└" : "├", isFolded ? "⊞" : hasChildren ? "⊟" : "─", " ");
|
|
106
|
+
} else {
|
|
107
|
+
chars.push(" ", " ", " ");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return chars.join("");
|
|
111
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outline prefixes of the tree pane: a triangle on the rows that can fold and
|
|
3
|
+
* plain indentation below them — the lightweight cousin of the guide lines in
|
|
4
|
+
* ./tree-lines.ts, which the tree dialog keeps. Drawn like lazygit's file
|
|
5
|
+
* tree: the marker of a branch sits where its siblings' text starts and the
|
|
6
|
+
* rows inside the branch line up under the branch's own text.
|
|
7
|
+
*
|
|
8
|
+
* › • 22:18 [system] depth 0: a lone root is not a branch, nothing in front of it
|
|
9
|
+
* • 22:18 user: hi
|
|
10
|
+
* • 22:19 assistant: … (branch point)
|
|
11
|
+
* ▸ 22:20 user: try again folded side branch, its rows are hidden
|
|
12
|
+
* ▾ • 22:21 [system] open branch on the active path
|
|
13
|
+
* • 22:21 user: hi rows inside it sit one level (2 columns) deeper, under the branch's text
|
|
14
|
+
* ─ 22:22 [branch summary] dead-end sibling: no children, nothing to fold
|
|
15
|
+
* ▾ • 22:22 user: hi
|
|
16
|
+
* • 22:22 assistant: …
|
|
17
|
+
*
|
|
18
|
+
* Rules (pure functions over pre-order `TreeRow[]`; which rows fold is decided
|
|
19
|
+
* in ../data/tree-fold.ts):
|
|
20
|
+
* - depth grows by one below every foldable row; a chain and a dead end stay
|
|
21
|
+
* at their parent's depth, so a linear conversation is completely flat
|
|
22
|
+
* - every level is 2 columns of indent; only a segment start carries a
|
|
23
|
+
* marker (`▾ ` open, `▸ ` folded, `─ ` no children), which pushes its own
|
|
24
|
+
* text 2 columns right — exactly where the rows inside it start
|
|
25
|
+
* - past MAX_DEPTH the outer levels are replaced by `… `, so the prefix never
|
|
26
|
+
* grows beyond four levels
|
|
27
|
+
*
|
|
28
|
+
* 面板用三角 + 缩进代替树线;只在段头下面多缩进一层,线性对话完全不缩进。
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { foldableIds, forkChildIds, treeChildren } from "../data/tree-fold.ts";
|
|
32
|
+
import type { TreeRow } from "../types.ts";
|
|
33
|
+
|
|
34
|
+
/** Columns per depth level. */
|
|
35
|
+
export const INDENT = " ";
|
|
36
|
+
|
|
37
|
+
/** Depths 0..MAX_DEPTH are drawn as they are (four levels); deeper rows are capped with `… `. */
|
|
38
|
+
export const MAX_DEPTH = 3;
|
|
39
|
+
|
|
40
|
+
/** Replaces the outer levels of a row deeper than MAX_DEPTH. */
|
|
41
|
+
export const ELLIPSIS = "… ";
|
|
42
|
+
|
|
43
|
+
export const MARK_OPEN = "▾ ";
|
|
44
|
+
export const MARK_FOLDED = "▸ ";
|
|
45
|
+
/** A segment start without children: an alternative that was never continued. */
|
|
46
|
+
export const MARK_LEAF = "─ ";
|
|
47
|
+
|
|
48
|
+
export interface OutlinePrefix {
|
|
49
|
+
/** Indentation: `INDENT` per level, `… ` standing in for the outer levels past MAX_DEPTH. */
|
|
50
|
+
indent: string;
|
|
51
|
+
/** Fold marker of a segment start, "" on every other row. */
|
|
52
|
+
marker: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Outline prefix of every row keyed by entry id, computed over the whole tree
|
|
57
|
+
* (not just the rows left after folding) so nothing shifts when a branch
|
|
58
|
+
* folds; `folded` picks `▸ ` over `▾ `.
|
|
59
|
+
*/
|
|
60
|
+
export function treeOutline(rows: TreeRow[], folded: ReadonlySet<string>, maxDepth = MAX_DEPTH): Map<string, OutlinePrefix> {
|
|
61
|
+
const children = treeChildren(rows);
|
|
62
|
+
const forkChildren = forkChildIds(rows, children);
|
|
63
|
+
const foldable = foldableIds(rows);
|
|
64
|
+
const depths = new Map<string, number>();
|
|
65
|
+
const out = new Map<string, OutlinePrefix>();
|
|
66
|
+
// 先序:父节点的深度已经算好;父节点可折叠时子节点深一层,否则同层。
|
|
67
|
+
for (const r of rows) {
|
|
68
|
+
const parentDepth = r.parentId !== undefined ? depths.get(r.parentId) : undefined;
|
|
69
|
+
const depth = parentDepth === undefined ? 0 : parentDepth + (foldable.has(r.parentId!) ? 1 : 0);
|
|
70
|
+
depths.set(r.entryId, depth);
|
|
71
|
+
const capped = depth > maxDepth;
|
|
72
|
+
const indent = (capped ? ELLIPSIS : "") + INDENT.repeat(capped ? Math.max(0, maxDepth - 1) : depth);
|
|
73
|
+
let marker = "";
|
|
74
|
+
if (forkChildren.has(r.entryId)) {
|
|
75
|
+
marker = !children.get(r.entryId)?.length ? MARK_LEAF : folded.has(r.entryId) ? MARK_FOLDED : MARK_OPEN;
|
|
76
|
+
}
|
|
77
|
+
out.set(r.entryId, { indent, marker });
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|