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,279 @@
1
+ /**
2
+ * Key chord parsing and matching on top of pi-tui's `matchesKey`.
3
+ *
4
+ * Pure functions, no UI. Responsibilities:
5
+ * - normalise a user-written chord ("G", "ctrl+d", "gg", "ctrl+w h") into
6
+ * a sequence of pi-tui key ids ("shift+g", "ctrl+d", ["g","g"], ...)
7
+ * - match one raw terminal input chunk against one key id
8
+ * - resolve a (scope, pending keys) pair to an ActionId with multi-key
9
+ * sequence support (vim-style "gg")
10
+ *
11
+ * 键位解析:用户配置里的 "G" 等价于 "shift+g","gg" 会被拆成两步按键序列。
12
+ * 匹配时优先用 pi-tui 的 matchesKey(它已经处理 ctrl/alt/shift/kitty 协议),
13
+ * 再回退到 kitty CSI-u 里解码出来的可打印字符(例如 kitty 终端下的 "?")。
14
+ */
15
+
16
+ import { decodeKittyPrintable, isKeyRelease, type KeyId, matchesKey } from "@earendil-works/pi-tui";
17
+ import { KEY_SCOPES, TREE_DIALOG_SCOPE } from "../constants.ts";
18
+ import type { ActionId, KeyChord, Keymap, KeyScope, PaneKeymap } from "../types.ts";
19
+
20
+ /** Key names pi-tui understands that are longer than one character. */
21
+ const NAMED_KEYS = new Set([
22
+ "escape",
23
+ "esc",
24
+ "enter",
25
+ "return",
26
+ "tab",
27
+ "space",
28
+ "backspace",
29
+ "delete",
30
+ "insert",
31
+ "clear",
32
+ "home",
33
+ "end",
34
+ "pageup",
35
+ "pagedown",
36
+ "up",
37
+ "down",
38
+ "left",
39
+ "right",
40
+ "f1",
41
+ "f2",
42
+ "f3",
43
+ "f4",
44
+ "f5",
45
+ "f6",
46
+ "f7",
47
+ "f8",
48
+ "f9",
49
+ "f10",
50
+ "f11",
51
+ "f12",
52
+ ]);
53
+
54
+ const MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
55
+
56
+ /** Canonical (pi-tui) key ids that make up one chord, in press order. */
57
+ export type KeySequence = string[];
58
+
59
+ /**
60
+ * Normalise one key step. Accepts "G", "shift+g", "Ctrl+D", "Tab", "?".
61
+ * Returns the lowercase pi-tui key id, or undefined when the step is invalid.
62
+ */
63
+ export function normalizeKeyStep(step: string): string | undefined {
64
+ const raw = step.trim();
65
+ if (!raw) return undefined;
66
+ // A bare "+" is a valid symbol key; anything else is split on "+".
67
+ const parts = raw === "+" ? ["+"] : raw.split("+").filter((p) => p.length > 0);
68
+ if (parts.length === 0) return undefined;
69
+ const key = parts[parts.length - 1]!;
70
+ const mods = new Set(parts.slice(0, -1).map((m) => m.toLowerCase()));
71
+ for (const m of mods) if (!MODIFIERS.has(m)) return undefined;
72
+
73
+ let base: string;
74
+ if (key.length === 1) {
75
+ // Uppercase letter is shorthand for shift+letter ("G" -> "shift+g").
76
+ if (key >= "A" && key <= "Z") {
77
+ mods.add("shift");
78
+ base = key.toLowerCase();
79
+ } else {
80
+ base = key;
81
+ }
82
+ } else {
83
+ const lower = key.toLowerCase();
84
+ if (!NAMED_KEYS.has(lower)) return undefined;
85
+ // pi-tui spells these two in camelCase.
86
+ base = lower === "pageup" ? "pageUp" : lower === "pagedown" ? "pageDown" : lower;
87
+ }
88
+ const order = ["ctrl", "shift", "alt", "super"].filter((m) => mods.has(m));
89
+ return order.length ? `${order.join("+")}+${base}` : base;
90
+ }
91
+
92
+ /**
93
+ * Parse a chord into a key sequence.
94
+ * "j" -> ["j"]
95
+ * "ctrl+d" -> ["ctrl+d"]
96
+ * "gg" -> ["g", "g"] (verbatim run of printable chars)
97
+ * "ctrl+w h" -> ["ctrl+w", "h"] (space separated steps)
98
+ * "G" -> ["shift+g"]
99
+ * Returns undefined for chords that cannot be parsed.
100
+ */
101
+ export function parseChord(chord: KeyChord): KeySequence | undefined {
102
+ const trimmed = chord.trim();
103
+ if (!trimmed) return undefined;
104
+ if (trimmed === "space") return ["space"];
105
+ const steps = trimmed.includes(" ") ? trimmed.split(/\s+/) : [trimmed];
106
+ const out: KeySequence = [];
107
+ for (const step of steps) {
108
+ const single = normalizeKeyStep(step);
109
+ if (single) {
110
+ out.push(single);
111
+ continue;
112
+ }
113
+ // Multi-character run like "gg" / "yy" / "zz": every char must be a plain key.
114
+ if (step.length > 1 && !step.includes("+")) {
115
+ const chars = [...step];
116
+ const ids = chars.map((c) => normalizeKeyStep(c));
117
+ if (ids.every((id): id is string => id !== undefined)) {
118
+ out.push(...ids);
119
+ continue;
120
+ }
121
+ }
122
+ return undefined;
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /** Does one raw terminal input chunk match one canonical key id? */
128
+ export function matchesKeyId(data: string, keyId: string): boolean {
129
+ if (isKeyRelease(data)) return false;
130
+ if (matchesKey(data, keyId as KeyId)) return true;
131
+ // kitty 协议下,shift+符号(如 "?")以 CSI-u 形式到达,matchesKey 不识别,
132
+ // 这里解码成可打印字符再比较一次。
133
+ if (keyId.length === 1) {
134
+ const printable = decodeKittyPrintable(data);
135
+ if (printable === keyId) return true;
136
+ }
137
+ return false;
138
+ }
139
+
140
+ /** One compiled binding: action + the key sequence that triggers it. */
141
+ export interface Binding {
142
+ scope: KeyScope;
143
+ action: ActionId;
144
+ seq: KeySequence;
145
+ /** Original chord text, for help display. */
146
+ chord: KeyChord;
147
+ }
148
+
149
+ /** Flatten one scope of the keymap into bindings. Invalid chords are skipped. */
150
+ export function compileScope(scope: KeyScope, keymap: PaneKeymap): Binding[] {
151
+ const out: Binding[] = [];
152
+ for (const [action, chords] of Object.entries(keymap) as Array<[ActionId, KeyChord | KeyChord[] | undefined]>) {
153
+ if (chords === undefined) continue;
154
+ for (const chord of Array.isArray(chords) ? chords : [chords]) {
155
+ const seq = parseChord(chord);
156
+ if (seq) out.push({ scope, action, seq, chord });
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+
162
+ export type ResolveResult =
163
+ | { kind: "action"; action: ActionId; scope: KeyScope }
164
+ | { kind: "pending" }
165
+ | { kind: "none" };
166
+
167
+ /**
168
+ * Scopes consulted for `focus`, innermost first: a pane shadows the global
169
+ * bindings, the tree dialog shadows the tree pane which shadows global.
170
+ *
171
+ * 键位查找顺序:对话框 → tree 面板 → global;面板 → global。
172
+ */
173
+ export function scopeChain(focus: KeyScope): KeyScope[] {
174
+ if (focus === "global") return ["global"];
175
+ if (focus === TREE_DIALOG_SCOPE) return [TREE_DIALOG_SCOPE, "tree", "global"];
176
+ return [focus, "global"];
177
+ }
178
+
179
+ /**
180
+ * Resolve the pressed key sequence against the focused scope first, then the
181
+ * outer ones (see `scopeChain`). Inner bindings shadow outer ones for the same key.
182
+ *
183
+ * `pressed` holds the raw input chunks pressed so far (including the current
184
+ * one). "pending" means some binding starts with this prefix but needs more
185
+ * keys; the caller should buffer and wait.
186
+ */
187
+ export function resolveKeys(bindings: Binding[], focus: KeyScope, pressed: string[]): ResolveResult {
188
+ const order = scopeChain(focus);
189
+ let pending = false;
190
+ for (const scope of order) {
191
+ for (const b of bindings) {
192
+ if (b.scope !== scope) continue;
193
+ const state = matchSequence(b.seq, pressed);
194
+ if (state === "match") return { kind: "action", action: b.action, scope };
195
+ if (state === "prefix") pending = true;
196
+ }
197
+ // 当前 scope 已经有前缀匹配时,不再回退到外层,避免 "g" 被 global 抢走。
198
+ if (pending) return { kind: "pending" };
199
+ }
200
+ return { kind: "none" };
201
+ }
202
+
203
+ function matchSequence(seq: KeySequence, pressed: string[]): "match" | "prefix" | "none" {
204
+ if (pressed.length > seq.length) return "none";
205
+ for (let i = 0; i < pressed.length; i++) {
206
+ if (!matchesKeyId(pressed[i]!, seq[i]!)) return "none";
207
+ }
208
+ return pressed.length === seq.length ? "match" : "prefix";
209
+ }
210
+
211
+ /** Compile a full keymap into a flat binding list. */
212
+ export function compileKeymap(keymap: Keymap): Binding[] {
213
+ return KEY_SCOPES.flatMap((s) => compileScope(s, keymap[s]));
214
+ }
215
+
216
+ /** Human readable label for a chord, used by the footer and help overlay. */
217
+ export function chordLabel(chord: KeyChord): string {
218
+ const seq = parseChord(chord);
219
+ if (!seq) return chord;
220
+ const steps = seq.map((id) =>
221
+ id
222
+ .split("+")
223
+ .map((part) => {
224
+ switch (part) {
225
+ case "ctrl":
226
+ return "Ctrl";
227
+ case "shift":
228
+ return "Shift";
229
+ case "alt":
230
+ return "Alt";
231
+ case "super":
232
+ return "Super";
233
+ case "tab":
234
+ return "Tab";
235
+ case "enter":
236
+ case "return":
237
+ return "Enter";
238
+ case "escape":
239
+ case "esc":
240
+ return "Esc";
241
+ case "space":
242
+ return "Space";
243
+ case "up":
244
+ return "↑";
245
+ case "down":
246
+ return "↓";
247
+ case "left":
248
+ return "←";
249
+ case "right":
250
+ return "→";
251
+ default:
252
+ // f1..f12 read better uppercase.
253
+ return /^f\d{1,2}$/.test(part) ? part.toUpperCase() : part;
254
+ }
255
+ })
256
+ .join("+")
257
+ // "Shift+g" reads better as "G".
258
+ .replace(/(^|\+)Shift\+([a-z])$/, (_m, pre: string, c: string) => `${pre}${c.toUpperCase()}`),
259
+ );
260
+ // vim 风格的纯字母序列(gg / yy)紧挨着显示;带修饰键或命名键的多步序列(ctrl+w h)用空格分开。
261
+ const plainRun = steps.every((s) => [...s].length === 1);
262
+ return steps.join(plainRun ? "" : " ");
263
+ }
264
+
265
+ /** All chords bound to `action` in `scope`, as display labels ("j / ↓"). */
266
+ export function labelsFor(keymap: Keymap, scope: KeyScope, action: ActionId): string[] {
267
+ const chords = keymap[scope][action];
268
+ if (chords === undefined) return [];
269
+ return (Array.isArray(chords) ? chords : [chords]).map(chordLabel);
270
+ }
271
+
272
+ /** Labels for `action` as seen from `focus`: the innermost scope of `scopeChain(focus)` that binds it. */
273
+ export function labelsForFocus(keymap: Keymap, focus: KeyScope, action: ActionId): string[] {
274
+ for (const scope of scopeChain(focus)) {
275
+ const labels = labelsFor(keymap, scope, action);
276
+ if (labels.length) return labels;
277
+ }
278
+ return [];
279
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * pi's own settings that change how the panel behaves.
3
+ *
4
+ * The extension context exposes no settings API, so this reads pi's
5
+ * `~/.pi/agent/settings.json` (plus the project's `.pi/settings.json`)
6
+ * through pi's `SettingsManager`, the same way pi does. `SettingsManager.create`
7
+ * only reads the files (it takes the lock briefly and writes nothing). Only
8
+ * this module should know about pi's settings file.
9
+ *
10
+ * 目前用到两项:`branchSummary.skipPrompt`(为 true 时 pi 内置 /tree 不问 No summary /
11
+ * Summarize,直接不做摘要;面板的 TREE Enter 照做)和 `treeFilterMode`(内置 /tree 打开时的
12
+ * 默认过滤,面板的 TREE 以它作为初始过滤)。读不到就按 pi 的默认值来。
13
+ */
14
+
15
+ import { SettingsManager } from "@earendil-works/pi-coding-agent";
16
+ import type { TreeFilter } from "../types.ts";
17
+
18
+ export interface PiSettings {
19
+ /** `branchSummary.skipPrompt`: skip the "Summarize branch?" menu and restore without a summary. */
20
+ skipBranchSummaryPrompt: boolean;
21
+ /** `treeFilterMode`: the filter pi's /tree opens with, in the panel's naming (`labeled-only` → `labeled`). */
22
+ treeFilter: TreeFilter;
23
+ }
24
+
25
+ /** pi's defaults, used when the settings cannot be read. */
26
+ export const DEFAULT_PI_SETTINGS: PiSettings = { skipBranchSummaryPrompt: false, treeFilter: "default" };
27
+
28
+ /** pi's `treeFilterMode` values mapped to the panel's `TreeFilter`. */
29
+ const TREE_FILTER_OF: Record<string, TreeFilter> = {
30
+ default: "default",
31
+ "no-tools": "no-tools",
32
+ "user-only": "user-only",
33
+ "labeled-only": "labeled",
34
+ all: "all",
35
+ };
36
+
37
+ /**
38
+ * Read the settings the panel cares about.
39
+ * @param cwd session working directory (for the project-level file)
40
+ * @param agentDir pi agent directory (usually ~/.pi/agent)
41
+ * @param projectTrusted whether pi trusts the project (untrusted projects ignore `.pi/settings.json`)
42
+ */
43
+ export function loadPiSettings(cwd: string, agentDir: string, projectTrusted = true): PiSettings {
44
+ try {
45
+ const manager = SettingsManager.create(cwd, agentDir, { projectTrusted });
46
+ return {
47
+ skipBranchSummaryPrompt: manager.getBranchSummarySkipPrompt(),
48
+ // pi 自己已经把非法值归到 "default",这里只是换成面板的命名。
49
+ treeFilter: TREE_FILTER_OF[manager.getTreeFilterMode()] ?? "default",
50
+ };
51
+ } catch {
52
+ return DEFAULT_PI_SETTINGS;
53
+ }
54
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Project-wide constants.
3
+ */
4
+
5
+ /** Extension identifier used for status/widget keys and log prefixes. */
6
+ export const EXTENSION_ID = "lazy-panel";
7
+
8
+ /** Slash command that opens the panel: `/lazy-panel`. */
9
+ export const COMMAND_NAME = "lazy-panel";
10
+
11
+ /** Name of the optional user config file inside the pi agent directory. */
12
+ export const CONFIG_FILE_NAME = "lazy-panel.json";
13
+
14
+ /** Layout ratio for the left column (sessions + tree) vs. the right column (content). */
15
+ export const LEFT_COLUMN_RATIO = 0.25;
16
+
17
+ /** Identifiers for the three focusable panes. */
18
+ export const PANE_IDS = ["sessions", "tree", "content"] as const;
19
+
20
+ /**
21
+ * Sort orders of the sessions pane in the order `s` cycles through them:
22
+ * recent (last update) → created → title (by the title the pane shows — name,
23
+ * else first-message preview — A–Z, empty sessions last) → threaded (forks
24
+ * indented under their parent, like pi's /resume). pi's "fuzzy" order only
25
+ * means something with a search query, so it is not offered here.
26
+ */
27
+ export const SESSION_SORT_MODES = ["recent", "created", "title", "threaded"] as const;
28
+
29
+ /**
30
+ * Key scope of the full tree dialog (`a` in the tree pane). Its bindings sit on
31
+ * top of the tree pane's, which sit on top of the global ones.
32
+ *
33
+ * 树对话框自己的键位 scope:对话框里先查这一层,再查 tree 面板,最后 global。
34
+ */
35
+ export const TREE_DIALOG_SCOPE = "tree-dialog" as const;
36
+
37
+ /** Every key scope of the keymap, in the order the user file lists them. */
38
+ export const KEY_SCOPES = ["global", ...PANE_IDS, TREE_DIALOG_SCOPE] as const;
39
+
40
+ /**
41
+ * Spinner frames for footer progress — a rotating square (正方形旋转). Kept here
42
+ * so slow operations (branch summary, compaction, the changelog dialog) share
43
+ * the same look. The progress text itself is localised via i18n (status.*).
44
+ */
45
+ export const SPINNER_FRAMES = ["◰", "◳", "◲", "◱"] as const;
46
+
47
+ /** How fast the footer spinner advances one frame. */
48
+ export const SPINNER_INTERVAL_MS = 120;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * pi's changelog, as `/changelog` shows it.
3
+ *
4
+ * pi reads `CHANGELOG.md` from its package directory, cuts it into one entry
5
+ * per `## [x.y.z]` heading and prints every entry. The parser
6
+ * (`utils/changelog.js`) is not exported by the package, so it is copied here;
7
+ * `getPackageDir` is exported and points at the running pi's install.
8
+ *
9
+ * 和 pi 内置 /changelog 读同一个文件、同样的切分规则;pi 的 parseChangelog 没有导出,这里照搬。
10
+ */
11
+
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import path from "node:path";
14
+ import { getPackageDir } from "@earendil-works/pi-coding-agent";
15
+
16
+ /** What pi prints when the file has no entries. */
17
+ export const NO_CHANGELOG = "No changelog entries found.";
18
+
19
+ /** Path of the running pi's CHANGELOG.md. */
20
+ export function changelogPath(): string {
21
+ return path.resolve(path.join(getPackageDir(), "CHANGELOG.md"));
22
+ }
23
+
24
+ /**
25
+ * Split changelog markdown into its version entries, in file order. Like pi:
26
+ * a `## ` line starts an entry when it carries a version (`## [0.86.1] - …`),
27
+ * text under a `## ` line without one (e.g. `## [Unreleased]`) is skipped.
28
+ */
29
+ export function parseChangelog(markdown: string): string[] {
30
+ const entries: string[] = [];
31
+ let current: string[] | undefined;
32
+ const flush = () => {
33
+ if (current && current.length > 0) entries.push(current.join("\n").trim());
34
+ };
35
+ for (const line of markdown.split(/\r?\n/)) {
36
+ if (line.startsWith("## ")) {
37
+ flush();
38
+ current = /##\s+\[?(\d+)\.(\d+)\.(\d+)\]?/.test(line) ? [line] : undefined;
39
+ } else if (current) {
40
+ current.push(line);
41
+ }
42
+ }
43
+ flush();
44
+ return entries;
45
+ }
46
+
47
+ /**
48
+ * The markdown the dialog renders: every entry, or pi's "no entries" line.
49
+ * The file lists the newest version first; pi reverses it because its chat
50
+ * shows the bottom, the dialog opens at the top so it keeps the newest first.
51
+ *
52
+ * 和 pi 的一个差异:pi 倒序(最新的在最下面,聊天区看的是底部),弹窗从顶部开始看,所以保持文件顺序、最新的在最上面。
53
+ */
54
+ export function changelogMarkdown(markdown: string): string {
55
+ const entries = parseChangelog(markdown);
56
+ return entries.length > 0 ? entries.join("\n\n") : NO_CHANGELOG;
57
+ }
58
+
59
+ /** Read the running pi's changelog (a missing file reads as "no entries", like pi). */
60
+ export async function loadChangelog(file: string = changelogPath()): Promise<string> {
61
+ if (!existsSync(file)) return NO_CHANGELOG;
62
+ return changelogMarkdown(readFileSync(file, "utf-8"));
63
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Content data adapter.
3
+ *
4
+ * Produces the `ContentBlock[]` shown in the right-hand pane for a session
5
+ * (or a branch ending at a given tree node), distinguishing user vs assistant
6
+ * messages so they can be rendered in separate boxes.
7
+ */
8
+
9
+ import { SessionManager, type SessionEntry } from "@earendil-works/pi-coding-agent";
10
+ import type { ContentBlock, ForkPoint, SessionInfo } from "../types.ts";
11
+ import { normalizeNewlines, singleLine } from "../utils/format.ts";
12
+
13
+ export interface LoadContentOptions {
14
+ sessionFile: string;
15
+ /** If set, show the branch that ends at this entry instead of the active leaf. */
16
+ leafEntryId?: string;
17
+ }
18
+
19
+ export async function loadContent(options: LoadContentOptions): Promise<ContentBlock[]> {
20
+ const manager = SessionManager.open(options.sessionFile);
21
+ const branch = manager.getBranch(options.leafEntryId ?? resolveContentLeaf(manager));
22
+ const blocks: ContentBlock[] = [];
23
+ for (const entry of branch) {
24
+ if (entry.type !== "message") continue;
25
+ const m = entry.message;
26
+ const timestamp = Date.parse(entry.timestamp);
27
+ if (m.role === "user") {
28
+ const markdown = partsToMarkdown(m.content);
29
+ if (markdown) blocks.push({ entryId: entry.id, role: "user", timestamp, markdown });
30
+ } else if (m.role === "assistant") {
31
+ const markdown = assistantToMarkdown(m.content);
32
+ if (markdown) blocks.push({ entryId: entry.id, role: "assistant", timestamp, markdown });
33
+ }
34
+ }
35
+ return blocks;
36
+ }
37
+
38
+ /**
39
+ * Entry id whose branch the panel shows by default.
40
+ *
41
+ * Normally the session's own leaf. Extensions may append bookkeeping entries
42
+ * that move the leaf onto a side branch with no messages at all; in that case
43
+ * fall back to the branch ending at the last message-bearing entry so the
44
+ * user still sees the conversation.
45
+ */
46
+ export function resolveContentLeaf(manager: Pick<SessionManager, "getBranch" | "getEntries" | "getLeafId">): string | undefined {
47
+ const leafId = manager.getLeafId() ?? undefined;
48
+ if (leafId && manager.getBranch(leafId).some(isConversation)) return leafId;
49
+ const entries = manager.getEntries();
50
+ for (let i = entries.length - 1; i >= 0; i--) {
51
+ const e = entries[i]!;
52
+ if (isConversation(e)) return e.id;
53
+ }
54
+ return leafId;
55
+ }
56
+
57
+ /** User / assistant / tool traffic, as opposed to system prompts and bookkeeping entries. */
58
+ function isConversation(entry: SessionEntry): boolean {
59
+ if (entry.type === "custom_message") return entry.display;
60
+ if (entry.type !== "message") return false;
61
+ const role = entry.message.role;
62
+ return role === "user" || role === "assistant" || role === "toolResult";
63
+ }
64
+
65
+ /**
66
+ * Every user message in the file, in order, as fork points for `/fork`'s selector.
67
+ *
68
+ * 照搬 pi 内置 /fork 的 `getUserMessagesForForking`:遍历所有条目(不限活动分支),
69
+ * 只取有文字的 user 消息(只有图片的不列),文本压成一行给选择器显示(选择器再按宽度截断)。
70
+ */
71
+ export async function loadForkPoints(sessionFile: string): Promise<ForkPoint[]> {
72
+ const manager = SessionManager.open(sessionFile);
73
+ const points: ForkPoint[] = [];
74
+ for (const entry of manager.getEntries()) {
75
+ if (entry.type !== "message" || entry.message.role !== "user") continue;
76
+ const text = singleLine(textParts(entry.message.content, " "));
77
+ if (text) points.push({ entryId: entry.id, text });
78
+ }
79
+ return points;
80
+ }
81
+
82
+ /**
83
+ * Text of the last assistant message on the active branch (what `/copy` copies).
84
+ * `undefined` when there is nothing to copy.
85
+ *
86
+ * 照搬 pi 的 `getLastAssistantText`:跳过中止且没有内容的回复,取最后一条回复里的 text 片段
87
+ * 原样拼接(不含思考过程和工具调用);这条回复只有工具调用时算没有可复制的,不再往前找。
88
+ */
89
+ export async function loadLastReply(sessionFile: string): Promise<string | undefined> {
90
+ const manager = SessionManager.open(sessionFile);
91
+ const branch = manager.getBranch(resolveContentLeaf(manager));
92
+ for (let i = branch.length - 1; i >= 0; i--) {
93
+ const entry = branch[i]!;
94
+ if (entry.type !== "message") continue;
95
+ const m = entry.message;
96
+ if (m.role !== "assistant") continue;
97
+ if (m.stopReason === "aborted" && m.content.length === 0) continue;
98
+ return textParts(m.content, "").trim() || undefined;
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ /** The `text` parts of a message joined by `separator` (images, thinking and tool calls left out). */
104
+ function textParts(content: string | Part[], separator: string): string {
105
+ if (typeof content === "string") return normalizeNewlines(content);
106
+ return content
107
+ .filter((p) => p.type === "text")
108
+ .map((p) => normalizeNewlines(p.text ?? ""))
109
+ .join(separator);
110
+ }
111
+
112
+ type Part = { type: string; text?: string; name?: string; arguments?: Record<string, unknown> };
113
+
114
+ function partsToMarkdown(content: string | Part[]): string {
115
+ if (typeof content === "string") return normalizeNewlines(content).trim();
116
+ return content
117
+ .map((p) => (p.type === "text" ? normalizeNewlines(p.text ?? "") : p.type === "image" ? "*[image]*" : ""))
118
+ .filter(Boolean)
119
+ .join("\n\n")
120
+ .trim();
121
+ }
122
+
123
+ function assistantToMarkdown(content: Part[]): string {
124
+ const out: string[] = [];
125
+ for (const p of content) {
126
+ if (p.type === "text" && p.text?.trim()) out.push(normalizeNewlines(p.text).trim());
127
+ else if (p.type === "toolCall") out.push(`*→ ${p.name ?? "tool"}${summarizeArgs(p.arguments)}*`);
128
+ }
129
+ return out.join("\n\n");
130
+ }
131
+
132
+ function summarizeArgs(args: Record<string, unknown> | undefined): string {
133
+ if (!args) return "";
134
+ const first = Object.values(args).find((v) => typeof v === "string") as string | undefined;
135
+ if (!first) return "";
136
+ const line = first.split(/\r?\n/)[0] ?? "";
137
+ return ` ${line.length > 60 ? `${line.slice(0, 60)}…` : line}`;
138
+ }
139
+
140
+ /** Gather the data shown by the Session Info dialog (mirrors /session). */
141
+ export async function loadSessionInfo(sessionFile: string): Promise<SessionInfo | undefined> {
142
+ let manager: SessionManager;
143
+ try {
144
+ manager = SessionManager.open(sessionFile);
145
+ } catch {
146
+ return undefined;
147
+ }
148
+ const header = manager.getHeader();
149
+ let model: string | undefined;
150
+ let messages = 0;
151
+ let tokens = 0;
152
+ let cost = 0;
153
+ let updatedAt = header ? Date.parse(header.timestamp) : 0;
154
+ for (const entry of manager.getEntries()) {
155
+ updatedAt = Math.max(updatedAt, Date.parse(entry.timestamp));
156
+ if (entry.type === "model_change") model = entry.modelId;
157
+ if (entry.type !== "message") continue;
158
+ const m = entry.message as { role: string; usage?: { input?: number; output?: number; cost?: { total?: number } } };
159
+ if (m.role === "user" || m.role === "assistant") messages++;
160
+ if (m.role === "assistant" && m.usage) {
161
+ tokens += (m.usage.input ?? 0) + (m.usage.output ?? 0);
162
+ cost += m.usage.cost?.total ?? 0;
163
+ }
164
+ }
165
+ const info: SessionInfo = {
166
+ messages,
167
+ tokens,
168
+ cost,
169
+ createdAt: header ? Date.parse(header.timestamp) : 0,
170
+ updatedAt,
171
+ path: sessionFile,
172
+ id: manager.getSessionId(),
173
+ };
174
+ const name = manager.getSessionName();
175
+ if (name) info.name = name;
176
+ if (model) info.model = model;
177
+ return info;
178
+ }