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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/README.zh.md +104 -0
  4. package/docs/design.md +235 -0
  5. package/docs/keybindings.md +172 -0
  6. package/docs/keybindings.zh.md +172 -0
  7. package/package.json +67 -0
  8. package/src/actions/session-actions.ts +585 -0
  9. package/src/actions/tree-actions.ts +161 -0
  10. package/src/config/config.ts +155 -0
  11. package/src/config/keymap.ts +219 -0
  12. package/src/config/keys.ts +279 -0
  13. package/src/config/pi-settings.ts +54 -0
  14. package/src/constants.ts +48 -0
  15. package/src/data/changelog.ts +63 -0
  16. package/src/data/content.ts +178 -0
  17. package/src/data/search.ts +183 -0
  18. package/src/data/sessions.ts +148 -0
  19. package/src/data/tree-fold.ts +163 -0
  20. package/src/data/tree.ts +258 -0
  21. package/src/i18n/index.ts +103 -0
  22. package/src/i18n/locales/en.json +341 -0
  23. package/src/i18n/locales/zh.json +341 -0
  24. package/src/index.ts +155 -0
  25. package/src/types.ts +346 -0
  26. package/src/ui/app.ts +2892 -0
  27. package/src/ui/frame.ts +116 -0
  28. package/src/ui/mouse-input.ts +197 -0
  29. package/src/ui/mouse.ts +99 -0
  30. package/src/ui/panes/content-pane.ts +132 -0
  31. package/src/ui/panes/sessions-pane.ts +161 -0
  32. package/src/ui/panes/tree-pane.ts +152 -0
  33. package/src/ui/search-highlight.ts +108 -0
  34. package/src/ui/tree-lines.ts +111 -0
  35. package/src/ui/tree-outline.ts +80 -0
  36. package/src/ui/widgets/changelog-dialog.ts +196 -0
  37. package/src/ui/widgets/compact-dialog.ts +29 -0
  38. package/src/ui/widgets/confirm-dialog.ts +98 -0
  39. package/src/ui/widgets/export-dialog.ts +62 -0
  40. package/src/ui/widgets/footer.ts +117 -0
  41. package/src/ui/widgets/fork-dialog.ts +28 -0
  42. package/src/ui/widgets/help-overlay.ts +220 -0
  43. package/src/ui/widgets/import-dialog.ts +32 -0
  44. package/src/ui/widgets/input-dialog.ts +136 -0
  45. package/src/ui/widgets/label-dialog.ts +28 -0
  46. package/src/ui/widgets/new-session-dialog.ts +29 -0
  47. package/src/ui/widgets/prompt-bar.ts +92 -0
  48. package/src/ui/widgets/rename-dialog.ts +29 -0
  49. package/src/ui/widgets/restore-dialog.ts +60 -0
  50. package/src/ui/widgets/search-bar.ts +65 -0
  51. package/src/ui/widgets/select-dialog.ts +193 -0
  52. package/src/ui/widgets/session-info-dialog.ts +189 -0
  53. package/src/ui/widgets/tree-dialog.ts +293 -0
  54. package/src/utils/format.ts +83 -0
  55. package/src/utils/paths.ts +33 -0
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Tree data adapter.
3
+ *
4
+ * Builds `TreeRow[]` for a selected session by walking its entry tree
5
+ * (mirrors what pi's `/tree` shows). No UI code here.
6
+ */
7
+
8
+ import { SessionManager, type SessionEntry, type SessionTreeNode } from "@earendil-works/pi-coding-agent";
9
+ import type { TreeFilter, TreeRow } from "../types.ts";
10
+ import { singleLine } from "../utils/format.ts";
11
+ import { resolveContentLeaf } from "./content.ts";
12
+ import { filterTreeRows } from "./tree-fold.ts";
13
+
14
+ /** Load the entry tree for a session file. */
15
+ export async function loadTree(sessionFile: string): Promise<TreeRow[]> {
16
+ const manager = SessionManager.open(sessionFile);
17
+ const activeIds = new Set(manager.getBranch(resolveContentLeaf(manager)).map((e) => e.id));
18
+ const leafIds = effectiveLeafIds(manager);
19
+ const rows: TreeRow[] = [];
20
+ // 行的 parentId 指向最近的一个“也是行”的祖先:label 这类不显示的条目被跳过,
21
+ // UI 画树线时才不会出现指向不存在节点的父引用。
22
+ const visit = (node: SessionTreeNode, parentRowId: string | undefined) => {
23
+ const row = toRow(node, parentRowId, activeIds.has(node.entry.id), leafIds.has(node.entry.id));
24
+ if (row) rows.push(row);
25
+ const nextParent = row ? row.entryId : parentRowId;
26
+ for (const child of node.children) visit(child, nextParent);
27
+ };
28
+ for (const root of manager.getTree()) visit(root, undefined);
29
+ return rows;
30
+ }
31
+
32
+ function toRow(node: SessionTreeNode, parentRowId: string | undefined, onActiveBranch: boolean, isLeaf: boolean): TreeRow | undefined {
33
+ const entry = node.entry;
34
+ const described = describeEntry(entry);
35
+ if (!described) return undefined;
36
+ const row: TreeRow = {
37
+ entryId: entry.id,
38
+ role: described.role,
39
+ text: described.text,
40
+ timestamp: Date.parse(entry.timestamp),
41
+ onActiveBranch,
42
+ kind: described.kind,
43
+ };
44
+ if (parentRowId) row.parentId = parentRowId;
45
+ if (node.label) row.label = node.label;
46
+ if (isLeaf) row.isLeaf = true;
47
+ return row;
48
+ }
49
+
50
+ interface Described {
51
+ role: TreeRow["role"];
52
+ kind: TreeRow["kind"];
53
+ text: string;
54
+ }
55
+
56
+ /**
57
+ * Convert a session entry into role/kind/text; `undefined` = never shown.
58
+ *
59
+ * Kinds follow pi's /tree filters: `message` (user / assistant), `tool`
60
+ * (tool results), `system` (system prompts, bash executions, compactions,
61
+ * branch summaries — structural rows pi always shows; branches in real
62
+ * sessions hang off the system prompts, so hiding them would flatten the
63
+ * tree), `meta` (bookkeeping pi hides by default). Texts use pi's bracket
64
+ * style (`[system]`, `[branch summary]: …`) so the dialog reads like /tree.
65
+ *
66
+ * 分类和 pi 一致:system 提示词 / 压缩 / 分支摘要是树的骨架,默认要显示;
67
+ * 只有 model / thinking / name 这些记账条目默认隐藏。
68
+ */
69
+ function describeEntry(entry: SessionEntry): Described | undefined {
70
+ switch (entry.type) {
71
+ case "message": {
72
+ const m = entry.message;
73
+ switch (m.role) {
74
+ case "user":
75
+ return { role: "user", kind: "message", text: textOf(m.content) };
76
+ case "assistant": {
77
+ const text = assistantText(m.content);
78
+ // 没有文本、没有工具调用、没有思考的空回复(中断/失败产生),
79
+ // 归到 meta:默认过滤下隐藏,`a` 全部模式仍可见。
80
+ if (text === "(empty)") return { role: "assistant", kind: "meta", text };
81
+ return { role: "assistant", kind: "message", text };
82
+ }
83
+ case "toolResult":
84
+ return { role: "tool", kind: "tool", text: `${m.toolName}: ${textOf(m.content)}` };
85
+ case "bashExecution": {
86
+ const command = (m as { command?: string }).command ?? "";
87
+ return { role: "system", kind: "system", text: `[bash]: ${singleLine(command)}` };
88
+ }
89
+ default:
90
+ // system prompts(pi 的类型里没列 "system",实际文件里有)和其他 AgentMessage:`[system]` 这种标签。
91
+ return { role: "system", kind: "system", text: `[${String(m.role)}]` };
92
+ }
93
+ }
94
+ case "custom_message":
95
+ if (!entry.display) return undefined;
96
+ return { role: "user", kind: "message", text: `[${entry.customType}] ${textOf(entry.content)}` };
97
+ case "compaction":
98
+ return { role: "system", kind: "system", text: `[compaction: ${Math.round(entry.tokensBefore / 1000)}k tokens]` };
99
+ case "branch_summary":
100
+ return { role: "system", kind: "system", text: `[branch summary]: ${singleLine(entry.summary)}` };
101
+ case "model_change":
102
+ return { role: "system", kind: "meta", text: `[model: ${entry.modelId}]` };
103
+ case "thinking_level_change":
104
+ return { role: "system", kind: "meta", text: `[thinking: ${entry.thinkingLevel}]` };
105
+ case "session_info":
106
+ return { role: "system", kind: "meta", text: `[name: ${entry.name ?? ""}]` };
107
+ case "custom":
108
+ case "label":
109
+ return undefined;
110
+ }
111
+ }
112
+
113
+ type Part = { type: string; text?: string; name?: string };
114
+
115
+ function textOf(content: string | Part[]): string {
116
+ if (typeof content === "string") return singleLine(content);
117
+ return singleLine(
118
+ content
119
+ .map((p) => (p.type === "text" ? (p.text ?? "") : p.type === "image" ? "[image]" : ""))
120
+ .filter(Boolean)
121
+ .join(" "),
122
+ );
123
+ }
124
+
125
+ function assistantText(content: Part[]): string {
126
+ const text = singleLine(
127
+ content
128
+ .filter((p) => p.type === "text")
129
+ .map((p) => p.text ?? "")
130
+ .join(" "),
131
+ );
132
+ if (text) return text;
133
+ const tools = content.filter((p) => p.type === "toolCall").map((p) => p.name ?? "tool");
134
+ if (tools.length) return `→ ${tools.join(", ")}`;
135
+ if (content.some((p) => p.type === "thinking")) return "(thinking)";
136
+ return "(empty)";
137
+ }
138
+
139
+ /**
140
+ * Full text of one entry, for the clipboard (mirrors what /tree ctrl+x copies).
141
+ *
142
+ * - message: bash executions copy the command; other messages copy their text
143
+ * parts, an assistant message without text falls back to its error message
144
+ * - custom_message: its text parts
145
+ * - compaction / branch_summary: the summary
146
+ * - everything else (labels, model changes…): nothing
147
+ *
148
+ * Returns `undefined` when the entry is missing or has no text.
149
+ */
150
+ export function loadNodeText(sessionFile: string, entryId: string): string | undefined {
151
+ const manager = SessionManager.open(sessionFile);
152
+ const entry = manager.getEntry(entryId);
153
+ if (!entry) return undefined;
154
+ let text: string | undefined;
155
+ switch (entry.type) {
156
+ case "message": {
157
+ const m = entry.message as { role: string; command?: string; content?: string | Part[]; errorMessage?: string };
158
+ if (m.role === "bashExecution") text = m.command;
159
+ else if (m.content !== undefined) {
160
+ text = fullText(m.content);
161
+ // 和 /tree 一致:没有正文的 assistant 回复复制它的错误信息。
162
+ if (!text && m.role === "assistant") text = m.errorMessage;
163
+ }
164
+ break;
165
+ }
166
+ case "custom_message":
167
+ text = fullText(entry.content);
168
+ break;
169
+ case "compaction":
170
+ case "branch_summary":
171
+ text = entry.summary;
172
+ break;
173
+ default:
174
+ break;
175
+ }
176
+ return text?.trim() ? text : undefined;
177
+ }
178
+
179
+ /** Concatenate the text parts of a message verbatim (no whitespace collapsing). */
180
+ function fullText(content: string | Part[]): string {
181
+ if (typeof content === "string") return content;
182
+ return content.map((p) => (p.type === "text" ? (p.text ?? "") : "")).join("");
183
+ }
184
+
185
+ /** Entry types that only record metadata; moving the leaf past them never changes the conversation. */
186
+ const BOOKKEEPING_TYPES: ReadonlySet<SessionEntry["type"]> = new Set([
187
+ "label",
188
+ "session_info",
189
+ "model_change",
190
+ "thinking_level_change",
191
+ "custom",
192
+ ]);
193
+
194
+ /**
195
+ * Would restoring to `entryId` leave the conversation where it already is?
196
+ *
197
+ * True when the entry is the session's leaf, or when it sits on the active
198
+ * branch with nothing but bookkeeping entries after it (pi appends labels,
199
+ * /name, model / thinking changes and extension state as new leaves, so right
200
+ * after `T` the last message is no longer the raw leaf). User messages are
201
+ * exempt: pi restores those by moving the leaf to their parent and putting the
202
+ * prompt back into the editor, which is a real change.
203
+ *
204
+ * 光标停在活动叶子上时 Enter 不应该再 restore 一次,否则会把刚打的 label /
205
+ * 刚切的模型这些尾部条目甩到分支外。
206
+ */
207
+ export function isEffectiveLeaf(manager: Pick<SessionManager, "getLeafId" | "getBranch">, entryId: string): boolean {
208
+ return effectiveLeafIds(manager).has(entryId);
209
+ }
210
+
211
+ /**
212
+ * Every entry `isEffectiveLeaf` holds for, computed in one pass: the leaf
213
+ * itself, then the entries above it for as long as everything after them is
214
+ * bookkeeping (user prompts never qualify, see `isEffectiveLeaf`).
215
+ *
216
+ * 从叶子沿活动分支往上走一次算出整组,loadTree 给每行标 isLeaf 时不用逐行重算。
217
+ */
218
+ export function effectiveLeafIds(manager: Pick<SessionManager, "getLeafId" | "getBranch">): Set<string> {
219
+ const ids = new Set<string>();
220
+ const leafId = manager.getLeafId();
221
+ if (!leafId) return ids;
222
+ ids.add(leafId);
223
+ const branch = manager.getBranch(leafId);
224
+ for (let i = branch.length - 1; i >= 0; i--) {
225
+ const entry = branch[i]!;
226
+ if (entry.id !== leafId && !isUserPrompt(entry)) ids.add(entry.id);
227
+ // 这一条不是记账条目:再往上的节点后面就不再"只剩记账条目"了。
228
+ if (!BOOKKEEPING_TYPES.has(entry.type)) break;
229
+ }
230
+ return ids;
231
+ }
232
+
233
+ /** Entries pi restores into the editor (the leaf moves to their parent), so restoring to them is never a no-op. */
234
+ function isUserPrompt(entry: SessionEntry): boolean {
235
+ return entry.type === "custom_message" || (entry.type === "message" && entry.message.role === "user");
236
+ }
237
+
238
+ /**
239
+ * Filter tree rows (mirrors /tree ctrl+d/t/u/l/a: default hides bookkeeping
240
+ * only, no-tools also drops tool results). Rows whose parent was filtered out
241
+ * are re-parented to their nearest kept ancestor (see `filterTreeRows`), so
242
+ * the result is still a forest the tree lines can be drawn from.
243
+ */
244
+ export function applyTreeFilter(rows: TreeRow[], filter: TreeFilter): TreeRow[] {
245
+ if (filter === "all") return rows;
246
+ return filterTreeRows(rows, (r) => {
247
+ switch (filter) {
248
+ case "default":
249
+ return r.kind !== "meta";
250
+ case "no-tools":
251
+ return r.kind !== "meta" && r.kind !== "tool";
252
+ case "user-only":
253
+ return r.kind === "message" && r.role === "user";
254
+ case "labeled":
255
+ return r.label !== undefined;
256
+ }
257
+ });
258
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * i18n — the only module that knows about i18next and the locale files.
3
+ *
4
+ * 整个插件的 UI 文案都走这里的 `t()`。语言在一次会话里固定:`initI18n()` 打开面板前调用一次,
5
+ * 按系统语言(环境变量 / Intl)决定用中文还是英文,没覆盖到的语言回退英文(fallback)。
6
+ *
7
+ * 用法约定(重要):
8
+ * - 不要在模块顶层用 `t()` 计算 `export const`——那会在 import 期求值,可能早于 `initI18n()`。
9
+ * 需要文案的地方一律写成函数,渲染时才调用 `t()`(见 config/keymap.ts 的 getter 们)。
10
+ * - `t()` 在未初始化时会惰性初始化(按系统语言),保证任何调用点都能拿到文案。
11
+ *
12
+ * 翻译文件在 ./locales/{en,zh}.json,用 `fs` 读取(不走 JSON import,省掉 tsconfig 的坑,
13
+ * 也让 scripts/i18n-scan.mjs 和运行时读同一份格式)。英文是基准语言(source of truth)。
14
+ */
15
+
16
+ import { readFileSync } from "node:fs";
17
+ import i18next from "i18next";
18
+
19
+ /** Languages we ship. English is the fallback for everything else. */
20
+ export type Locale = "en" | "zh";
21
+ export const SUPPORTED_LOCALES: readonly Locale[] = ["en", "zh"];
22
+ export const FALLBACK_LOCALE: Locale = "en";
23
+
24
+ /** Options accepted by `t` (interpolation values, `count` for plurals). */
25
+ export type TOptions = Record<string, unknown>;
26
+
27
+ let initialized = false;
28
+
29
+ /** Read one locale file as a plain object (throws only if the shipped file is missing/corrupt). */
30
+ function loadLocale(locale: Locale): Record<string, unknown> {
31
+ const url = new URL(`./locales/${locale}.json`, import.meta.url);
32
+ return JSON.parse(readFileSync(url, "utf8")) as Record<string, unknown>;
33
+ }
34
+
35
+ /**
36
+ * Detect the UI language from the environment.
37
+ *
38
+ * 优先看 POSIX 的 locale 环境变量(LC_ALL > LC_MESSAGES > LANG > LANGUAGE),
39
+ * 没有就退回 `Intl` 解析出来的运行时 locale;只要以 `zh` 开头就用中文,其余一律英文。
40
+ */
41
+ export function detectLocale(env: NodeJS.ProcessEnv = process.env): Locale {
42
+ const fromEnv = env.LC_ALL || env.LC_MESSAGES || env.LANG || env.LANGUAGE || "";
43
+ const raw = fromEnv || intlLocale();
44
+ return normalizeLocale(raw);
45
+ }
46
+
47
+ /** Runtime locale from `Intl`, e.g. "zh-CN"; "" when it cannot be determined. */
48
+ function intlLocale(): string {
49
+ try {
50
+ return Intl.DateTimeFormat().resolvedOptions().locale ?? "";
51
+ } catch {
52
+ return "";
53
+ }
54
+ }
55
+
56
+ /** Map a raw locale tag ("zh_CN.UTF-8", "zh-Hans", "en_US") to a shipped language. */
57
+ export function normalizeLocale(raw: string): Locale {
58
+ return /^zh\b/i.test(raw.replace(/[_.]/g, "-")) ? "zh" : FALLBACK_LOCALE;
59
+ }
60
+
61
+ /**
62
+ * Initialise i18next with both locale files (synchronously — inline resources,
63
+ * no async backend). Pass `lng` to force a language (tests use "en"); omit it to
64
+ * follow the system. Idempotent: a second call just switches the language.
65
+ */
66
+ export function initI18n(lng?: Locale): Locale {
67
+ const language = lng ?? detectLocale();
68
+ if (initialized) {
69
+ if (i18next.language !== language) void i18next.changeLanguage(language);
70
+ return language;
71
+ }
72
+ i18next.init({
73
+ lng: language,
74
+ fallbackLng: FALLBACK_LOCALE,
75
+ supportedLngs: [...SUPPORTED_LOCALES],
76
+ // 同步初始化:资源内联、没有异步 backend,init 返回时 t() 已可用。
77
+ initImmediate: false,
78
+ // 终端不是 HTML,关掉 HTML 转义,否则 `'` / `<` 会被转义成实体。
79
+ interpolation: { escapeValue: false },
80
+ resources: {
81
+ en: { translation: loadLocale("en") },
82
+ zh: { translation: loadLocale("zh") },
83
+ },
84
+ });
85
+ initialized = true;
86
+ return language;
87
+ }
88
+
89
+ /** The active language (initialising lazily to the system language if needed). */
90
+ export function currentLocale(): Locale {
91
+ if (!initialized) initI18n();
92
+ return (i18next.language as Locale) ?? FALLBACK_LOCALE;
93
+ }
94
+
95
+ /**
96
+ * Translate `key`, interpolating `options` (and using `options.count` for
97
+ * plurals). Lazily initialises i18n on first use so no call site can run before
98
+ * a language is set. A missing key falls back to English, then to the key text.
99
+ */
100
+ export function t(key: string, options?: TOptions): string {
101
+ if (!initialized) initI18n();
102
+ return i18next.t(key, options ?? {}) as string;
103
+ }