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,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session actions — side effects triggered from the sessions pane.
|
|
3
|
+
*
|
|
4
|
+
* Each function wraps the equivalent pi command / API:
|
|
5
|
+
* resume -> ctx.switchSession (done)
|
|
6
|
+
* delete -> remove the .jsonl (prefer `trash` CLI like pi does) (done)
|
|
7
|
+
* rename -> pi.setSessionName / SessionManager.appendSessionInfo (done)
|
|
8
|
+
* copy -> copyToClipboard (the Session Info dialog's `y`) (done)
|
|
9
|
+
* new -> ctx.newSession (+ session_info for /name) (done)
|
|
10
|
+
* fork -> ctx.fork(entryId, { position: "before" }) (done)
|
|
11
|
+
* clone -> ctx.fork(leafId, { position: "at" }) (done)
|
|
12
|
+
* copy-reply -> copyToClipboard(last assistant reply) (`Y`) (done)
|
|
13
|
+
* compact -> ctx.compact({ customInstructions }) on the current session (done)
|
|
14
|
+
* export -> HTML: `pi --export <file> <out>`; JSONL: header + active branch (done)
|
|
15
|
+
* import -> copy into the session dir + ctx.switchSession (done)
|
|
16
|
+
* share -> `pi --export` + `gh gist create --public=false` (done)
|
|
17
|
+
*
|
|
18
|
+
* Destructive / branching / outbound actions (delete, fork, clone, share,
|
|
19
|
+
* overwriting an export) must be confirmed by the caller first (see
|
|
20
|
+
* ../ui/widgets/confirm-dialog.ts). These functions do not prompt.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
24
|
+
import { constants, copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { unlink } from "node:fs/promises";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { basename, dirname, join, parse, resolve } from "node:path";
|
|
28
|
+
import {
|
|
29
|
+
copyToClipboard,
|
|
30
|
+
CURRENT_SESSION_VERSION,
|
|
31
|
+
type ExtensionAPI,
|
|
32
|
+
type ExtensionCommandContext,
|
|
33
|
+
SessionManager,
|
|
34
|
+
} from "@earendil-works/pi-coding-agent";
|
|
35
|
+
import { EXTENSION_ID, SPINNER_FRAMES, SPINNER_INTERVAL_MS } from "../constants.ts";
|
|
36
|
+
import { loadLastReply } from "../data/content.ts";
|
|
37
|
+
import { t } from "../i18n/index.ts";
|
|
38
|
+
import type { DeleteMethod, EnterOutcome, ExportFormat, ExportTarget, ShareResult } from "../types.ts";
|
|
39
|
+
import { resolveUserPath, stripQuotes } from "../utils/paths.ts";
|
|
40
|
+
|
|
41
|
+
/** The slice of the command context `resumeSession` needs (tests pass plain objects). */
|
|
42
|
+
export type ResumeContext = Pick<ExtensionCommandContext, "sessionManager" | "switchSession">;
|
|
43
|
+
|
|
44
|
+
/** Post-switch work, run by pi against the replacement session's own context. */
|
|
45
|
+
export type SwitchOptions = NonNullable<Parameters<ExtensionCommandContext["switchSession"]>[1]>;
|
|
46
|
+
|
|
47
|
+
/** The slice of the command context the file-level actions (delete / rename) need. */
|
|
48
|
+
export type SessionContext = Pick<ExtensionCommandContext, "sessionManager">;
|
|
49
|
+
|
|
50
|
+
/** Is `sessionFile` the session pi currently has open? (Same rule as labelling: compare resolved paths.) */
|
|
51
|
+
export function isCurrentSession(ctx: SessionContext, sessionFile: string): boolean {
|
|
52
|
+
const current = ctx.sessionManager.getSessionFile();
|
|
53
|
+
return current !== undefined && resolve(current) === resolve(sessionFile);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Open a history session file for reading.
|
|
58
|
+
* Throws a readable error when the file is gone or unparsable, so callers can
|
|
59
|
+
* report it before pi starts tearing the current session down.
|
|
60
|
+
*/
|
|
61
|
+
export function openSessionFile(sessionFile: string): SessionManager {
|
|
62
|
+
if (!existsSync(sessionFile)) throw new Error(`session file not found: ${sessionFile}`);
|
|
63
|
+
return SessionManager.open(sessionFile);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Switch pi to `sessionFile` (what `/resume` does when a session is picked).
|
|
68
|
+
*
|
|
69
|
+
* 目标就是当前会话时什么都不做(返回 `unchanged`);否则先确认文件能打开,再交给
|
|
70
|
+
* `ctx.switchSession`。pi 自己会先中断正在输出的回复再切换(和内置 /resume 一样,不额外确认)。
|
|
71
|
+
* 切换成功后传入的 `ctx` 就失效了,后续要在新会话里做的事只能放进 `options.withSession`。
|
|
72
|
+
*
|
|
73
|
+
* Throws when the file cannot be opened or pi / another extension cancelled the switch.
|
|
74
|
+
*/
|
|
75
|
+
export async function resumeSession(ctx: ResumeContext, sessionFile: string, options?: SwitchOptions): Promise<EnterOutcome> {
|
|
76
|
+
if (isCurrentSession(ctx, sessionFile)) return "unchanged";
|
|
77
|
+
openSessionFile(sessionFile);
|
|
78
|
+
const result = await ctx.switchSession(sessionFile, options);
|
|
79
|
+
if (result.cancelled) throw new Error("switch cancelled by pi or an extension");
|
|
80
|
+
return "switched";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** pi's wording when the cursor is on the session it currently has open. */
|
|
84
|
+
export const CURRENT_SESSION_DELETE_ERROR = "Cannot delete the currently active session";
|
|
85
|
+
|
|
86
|
+
export interface DeleteOptions {
|
|
87
|
+
/** Command tried before falling back to `unlink` (default `trash`; tests pass `node fake.cjs` or one that does not exist). */
|
|
88
|
+
trash?: CommandSpec;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Delete one session file (what `/resume`'s ctrl+d does once confirmed).
|
|
93
|
+
*
|
|
94
|
+
* 照搬 pi 内置 /resume 的做法:当前打开的会话拒绝删除;其他会话先试系统的 `trash` 命令
|
|
95
|
+
* (能进回收站就进回收站),命令不存在或失败再 `unlink` 永久删除。返回用的是哪种方式,
|
|
96
|
+
* 面板据此在 footer 里说明。调用方必须先经确认框确认,这里不弹窗。
|
|
97
|
+
*
|
|
98
|
+
* Throws when the file is the current session, does not exist, or neither
|
|
99
|
+
* method could remove it (the unlink error, plus what `trash` said).
|
|
100
|
+
*/
|
|
101
|
+
export async function deleteSession(ctx: SessionContext, sessionFile: string, options: DeleteOptions = {}): Promise<DeleteMethod> {
|
|
102
|
+
if (isCurrentSession(ctx, sessionFile)) throw new Error(CURRENT_SESSION_DELETE_ERROR);
|
|
103
|
+
if (!existsSync(sessionFile)) throw new Error(`session file not found: ${sessionFile}`);
|
|
104
|
+
// 文件名以 - 开头时要用 -- 隔开,否则会被 trash 当成选项。
|
|
105
|
+
const trashArgs = sessionFile.startsWith("-") ? ["--", sessionFile] : [sessionFile];
|
|
106
|
+
const trashSpec = options.trash ?? { command: "trash", args: [] };
|
|
107
|
+
const trash = spawnSync(trashSpec.command, [...trashSpec.args, ...trashArgs], { encoding: "utf-8" });
|
|
108
|
+
// trash 报告成功,或者文件已经不在了,都算进了回收站。
|
|
109
|
+
if (trash.status === 0 || !existsSync(sessionFile)) return "trash";
|
|
110
|
+
try {
|
|
111
|
+
await unlink(sessionFile);
|
|
112
|
+
return "unlink";
|
|
113
|
+
} catch (err) {
|
|
114
|
+
const hint = trashErrorHint(trash);
|
|
115
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
116
|
+
throw new Error(hint ? `${message} (${hint})` : message, { cause: err });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** First line of what `trash` complained about, for the error when `unlink` fails too. */
|
|
121
|
+
function trashErrorHint(result: ReturnType<typeof spawnSync>): string | undefined {
|
|
122
|
+
const parts: string[] = [];
|
|
123
|
+
if (result.error) parts.push(result.error.message);
|
|
124
|
+
const stderr = String(result.stderr ?? "").trim();
|
|
125
|
+
if (stderr) parts.push(stderr.split("\n")[0] ?? stderr);
|
|
126
|
+
if (parts.length === 0) return undefined;
|
|
127
|
+
return `trash: ${parts.join(" · ").slice(0, 200)}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Set (or clear with "") the display name of a session (what `/name` does).
|
|
132
|
+
*
|
|
133
|
+
* 分流和打标签一致:目标是 pi 当前打开的会话就走 `pi.setSessionName`(pi 内存里的
|
|
134
|
+
* SessionManager 同步更新、还会广播 session_info_changed);其他历史会话单独打开文件
|
|
135
|
+
* 追加一条 session_info 条目(pi 内置 /resume 的 rename 就是这么做的)。空名字清除名称:
|
|
136
|
+
* pi 的 `getSessionName` 对空的 session_info 返回 undefined。
|
|
137
|
+
*
|
|
138
|
+
* Throws when the file does not exist.
|
|
139
|
+
*/
|
|
140
|
+
export async function renameSession(
|
|
141
|
+
pi: Pick<ExtensionAPI, "setSessionName">,
|
|
142
|
+
ctx: SessionContext,
|
|
143
|
+
sessionFile: string,
|
|
144
|
+
name: string,
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
const value = name.trim();
|
|
147
|
+
if (isCurrentSession(ctx, sessionFile)) {
|
|
148
|
+
pi.setSessionName(value);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
openSessionFile(sessionFile).appendSessionInfo(value);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Copy arbitrary text to the system clipboard (the Session Info dialog's `y`). */
|
|
155
|
+
export async function copyText(text: string): Promise<void> {
|
|
156
|
+
await copyToClipboard(text);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The slice `newSession` needs. */
|
|
160
|
+
export type NewSessionContext = Pick<ExtensionCommandContext, "newSession">;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Start a fresh session (what `/new` does), naming it when `name` is non-empty.
|
|
164
|
+
*
|
|
165
|
+
* 名字非空时通过 `setup` 往新会话追加一条 session_info(对应 /name 的 `[name]` 参数),
|
|
166
|
+
* 空名字就不设置。pi 建新会话后会切过去、收掉旧扩展,所以调用方走 `enter()` 关面板。
|
|
167
|
+
* `setup` 里抛异常会被 pi 的包装当成致命错误直接退出进程(同 fork),所以它只做一次追加。
|
|
168
|
+
*/
|
|
169
|
+
export async function newSession(ctx: NewSessionContext, name: string): Promise<EnterOutcome> {
|
|
170
|
+
const value = name.trim();
|
|
171
|
+
const options = value ? { setup: async (sm: SessionManager) => void sm.appendSessionInfo(value) } : {};
|
|
172
|
+
const result = await ctx.newSession(options);
|
|
173
|
+
if (result.cancelled) throw new Error("new session cancelled by pi or an extension");
|
|
174
|
+
return "switched";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The slice fork / clone need: current session (for isCurrentSession), fork, and switchSession for other files. */
|
|
178
|
+
export type ForkContext = Pick<ExtensionCommandContext, "sessionManager" | "fork" | "switchSession">;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Fork the session before the user message `entryId`, opening the fork (what `/fork` does).
|
|
182
|
+
*
|
|
183
|
+
* pi 自己给扩展的 fork 包装会把那条 user 消息填回新会话的编辑器(`selectedText`),这里不用管。
|
|
184
|
+
* `ctx.fork` 只能 fork 当前打开的会话,所以光标会话不是当前会话时先 `switchSession`,再在新 ctx
|
|
185
|
+
* 上 fork(和 `restoreNode` 同一个做法)。调用方必须先经确认框确认,这里不弹窗。
|
|
186
|
+
*
|
|
187
|
+
* Throws (before pi is touched) when pi's fork would refuse; see `checkForkable`.
|
|
188
|
+
*/
|
|
189
|
+
export async function forkSession(ctx: ForkContext, sessionFile: string, entryId: string): Promise<EnterOutcome> {
|
|
190
|
+
return forkAt(ctx, sessionFile, entryId, "before");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Clone the active branch to a new file (what `/clone` does): fork the leaf with position "at". */
|
|
194
|
+
export async function cloneSession(ctx: ForkContext, sessionFile: string): Promise<EnterOutcome> {
|
|
195
|
+
const manager = isCurrentSession(ctx, sessionFile) ? ctx.sessionManager : openSessionFile(sessionFile);
|
|
196
|
+
const leafId = manager.getLeafId();
|
|
197
|
+
if (!leafId) throw new Error("nothing to clone yet");
|
|
198
|
+
return forkAt(ctx, sessionFile, leafId, "at");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Shared body of fork / clone: refuse what pi would throw on, then fork here or after switching. */
|
|
202
|
+
async function forkAt(ctx: ForkContext, sessionFile: string, entryId: string, position: "before" | "at"): Promise<EnterOutcome> {
|
|
203
|
+
const current = isCurrentSession(ctx, sessionFile);
|
|
204
|
+
checkForkable(current ? ctx.sessionManager : openSessionFile(sessionFile), sessionFile, entryId, position);
|
|
205
|
+
const label = position === "at" ? "clone" : "fork";
|
|
206
|
+
const fork = async (c: Pick<ExtensionCommandContext, "fork">): Promise<void> => {
|
|
207
|
+
const result = await c.fork(entryId, { position });
|
|
208
|
+
if (result.cancelled) throw new Error(`${label} cancelled by pi or an extension`);
|
|
209
|
+
};
|
|
210
|
+
if (current) {
|
|
211
|
+
await fork(ctx);
|
|
212
|
+
return "switched";
|
|
213
|
+
}
|
|
214
|
+
// 其他会话:先切过去,切换后旧 ctx 失效,只能在新 ctx 上 fork;这时面板已被 pi 收掉,失败只能 notify。
|
|
215
|
+
await resumeSession(ctx, sessionFile, {
|
|
216
|
+
withSession: async (next) => {
|
|
217
|
+
try {
|
|
218
|
+
await fork(next);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
next.ui.notify(`${label} failed: ${(err as Error).message}`, "error");
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
return "switched";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Refuse, with a readable error, every case pi's fork throws on.
|
|
229
|
+
*
|
|
230
|
+
* 必须在调用 `ctx.fork` 之前拦下:pi 给扩展的 fork 包装(interactive-mode 的 commandContextActions)
|
|
231
|
+
* 把 fork 里的任何异常都交给 `handleFatalRuntimeError`,它会直接 `process.exit(1)` 把整个 pi 退掉。
|
|
232
|
+
* pi 会抛的情况:条目不存在、position "before" 但不是 user 消息、会话文件还没落盘;另外 fork
|
|
233
|
+
* 出来的会话按文件里记的 cwd 重建,那个目录已经被删了就不让它去试。
|
|
234
|
+
*/
|
|
235
|
+
function checkForkable(manager: Pick<SessionManager, "getEntry">, sessionFile: string, entryId: string, position: "before" | "at"): void {
|
|
236
|
+
if (!existsSync(sessionFile)) throw new Error(`session file not found: ${sessionFile}`);
|
|
237
|
+
const entry = manager.getEntry(entryId);
|
|
238
|
+
if (!entry) throw new Error(`entry ${entryId} not found in session`);
|
|
239
|
+
if (position === "before" && (entry.type !== "message" || entry.message.role !== "user")) {
|
|
240
|
+
throw new Error("can only fork before a user message");
|
|
241
|
+
}
|
|
242
|
+
// 读文件里记的 cwd(当前会话在内存里可能带着 resume 时的 cwd 覆盖,pi fork 时不会用它)。
|
|
243
|
+
const cwd = SessionManager.open(sessionFile).getCwd();
|
|
244
|
+
if (cwd && !existsSync(cwd)) throw new Error(`the session's folder no longer exists: ${cwd}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Copy the last assistant reply of `sessionFile` to the clipboard (what `/copy` does).
|
|
249
|
+
* Returns `false` when the branch has no assistant reply yet.
|
|
250
|
+
*/
|
|
251
|
+
export async function copyLastReply(sessionFile: string): Promise<boolean> {
|
|
252
|
+
const text = await loadLastReply(sessionFile);
|
|
253
|
+
if (!text) return false;
|
|
254
|
+
await copyToClipboard(text);
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// compact
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
/** The slice compact needs: current session (for isCurrentSession), compact, switchSession, and the footer. */
|
|
263
|
+
export type CompactContext = Pick<ExtensionCommandContext, "sessionManager" | "switchSession" | "compact" | "ui">;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Compact the active branch of `sessionFile` (what `/compact [instructions]` does), then be in it.
|
|
267
|
+
*
|
|
268
|
+
* pi 的 `ctx.compact` 只作用于当前会话,而且是 fire-and-forget(结果走 onComplete / onError)。
|
|
269
|
+
* 所以光标会话就是当前会话时直接压缩(返回 `compacted`);是其他会话时先 `switchSession` 切过去
|
|
270
|
+
* (这就是"压缩完进对话"里的"进对话"),再在 pi 给 `withSession` 的新 ctx 上压缩——切换后旧 ctx
|
|
271
|
+
* 已失效,面板也被 pi 收掉,所以那时的失败只能 `notify`(和 `restoreNode` 处理其他会话一致)。
|
|
272
|
+
* 压缩要跑一次模型、可能十几秒,期间面板是隐藏的,进度写到 pi 自己的 footer(带旋转 spinner)。
|
|
273
|
+
*
|
|
274
|
+
* Throws when the file cannot be opened, the switch is cancelled, or (for the
|
|
275
|
+
* current session) compaction fails — e.g. no model, session too small, already
|
|
276
|
+
* compacted. `customInstructions` focuses the summary; blank uses pi's default.
|
|
277
|
+
*/
|
|
278
|
+
export async function compactSession(ctx: CompactContext, sessionFile: string, customInstructions?: string): Promise<EnterOutcome> {
|
|
279
|
+
if (isCurrentSession(ctx, sessionFile)) {
|
|
280
|
+
const stop = startFooterSpinner(ctx.ui, t("status.compacting"));
|
|
281
|
+
try {
|
|
282
|
+
await runCompaction(ctx, customInstructions);
|
|
283
|
+
} finally {
|
|
284
|
+
stop();
|
|
285
|
+
}
|
|
286
|
+
return "compacted";
|
|
287
|
+
}
|
|
288
|
+
await resumeSession(ctx, sessionFile, {
|
|
289
|
+
withSession: async (next) => {
|
|
290
|
+
const stop = startFooterSpinner(next.ui, t("status.compacting"));
|
|
291
|
+
try {
|
|
292
|
+
await runCompaction(next, customInstructions);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
next.ui.notify(`compact failed: ${(err as Error).message}`, "error");
|
|
295
|
+
} finally {
|
|
296
|
+
stop();
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
return "switched";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Wrap pi's fire-and-forget `ctx.compact` in a promise (it reports success / failure through callbacks). */
|
|
304
|
+
function runCompaction(ctx: Pick<CompactContext, "compact">, customInstructions?: string): Promise<void> {
|
|
305
|
+
return new Promise((resolve, reject) => {
|
|
306
|
+
ctx.compact({
|
|
307
|
+
...(customInstructions ? { customInstructions } : {}),
|
|
308
|
+
onComplete: () => resolve(),
|
|
309
|
+
onError: (err) => reject(err),
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Rotate a spinner + `text` in pi's own footer until the returned stop() clears it. */
|
|
315
|
+
function startFooterSpinner(ui: Pick<CompactContext["ui"], "setStatus">, text: string): () => void {
|
|
316
|
+
let frame = 0;
|
|
317
|
+
const show = (): void => {
|
|
318
|
+
const glyph = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] ?? SPINNER_FRAMES[0];
|
|
319
|
+
ui.setStatus(EXTENSION_ID, `${glyph} ${text}`);
|
|
320
|
+
};
|
|
321
|
+
show();
|
|
322
|
+
const timer = setInterval(() => {
|
|
323
|
+
frame++;
|
|
324
|
+
show();
|
|
325
|
+
}, SPINNER_INTERVAL_MS);
|
|
326
|
+
// pi 的 setInterval 句柄不需要 unref:stop() 一定会在压缩结束(成功或失败)时清掉它。
|
|
327
|
+
return () => {
|
|
328
|
+
clearInterval(timer);
|
|
329
|
+
ui.setStatus(EXTENSION_ID, undefined);
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// export / import / share
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
/** How to run an external program: the executable plus the arguments that go before ours. */
|
|
338
|
+
export interface CommandSpec {
|
|
339
|
+
command: string;
|
|
340
|
+
args: string[];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Overrides for the programs export / share spawn (tests pass `node fake.js`). */
|
|
344
|
+
export interface ExternalCommands {
|
|
345
|
+
/** pi itself, for `pi --export` (default: the running pi, see `runningPi`). */
|
|
346
|
+
pi?: CommandSpec;
|
|
347
|
+
/** The GitHub CLI (default `gh`). */
|
|
348
|
+
gh?: CommandSpec;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** `pi --export` renders a whole session to HTML; it normally takes about a second. */
|
|
352
|
+
const EXPORT_TIMEOUT_MS = 60_000;
|
|
353
|
+
/** `gh auth status` / `gh gist create` talk to GitHub. */
|
|
354
|
+
const GH_TIMEOUT_MS = 60_000;
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The pi that is running this extension, as a command.
|
|
358
|
+
*
|
|
359
|
+
* 扩展 API 只有 AgentSession 上的 exportToHtml(只能导出当前会话),包的 exports 也只开放了
|
|
360
|
+
* 入口,所以 HTML 走 pi 自己公开的 CLI:`pi --export <file> <out>`(和 pi 对任意会话文件导出
|
|
361
|
+
* 用的是同一个 exportFromFile)。不用 PATH 上的 `pi`:Windows 上那是 pi.cmd,不开 shell 起不来,
|
|
362
|
+
* 版本也可能和正在运行的不同。npm 安装时 argv[1] 是 cli.js,用同一个 node 跑它;Bun 编译的单文件里
|
|
363
|
+
* 可执行文件本身就是 pi,argv[1] 是磁盘上不存在的虚拟路径。
|
|
364
|
+
*/
|
|
365
|
+
export function runningPi(): CommandSpec {
|
|
366
|
+
const script = process.argv[1];
|
|
367
|
+
if (script && existsSync(script)) {
|
|
368
|
+
// tsx 这类加载器参数要带上;--inspect 会和父进程抢调试端口,去掉。
|
|
369
|
+
const execArgs = process.execArgv.filter((arg) => !arg.startsWith("--inspect"));
|
|
370
|
+
return { command: process.execPath, args: [...execArgs, script] };
|
|
371
|
+
}
|
|
372
|
+
return { command: process.execPath, args: [] };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
interface RunResult {
|
|
376
|
+
code: number | null;
|
|
377
|
+
stdout: string;
|
|
378
|
+
stderr: string;
|
|
379
|
+
/** Could not be started (e.g. ENOENT), or was killed after the timeout. */
|
|
380
|
+
error?: Error;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Run a program to completion without a shell or a terminal (stdin closed, output captured). */
|
|
384
|
+
function runCommand(spec: CommandSpec, args: string[], timeoutMs: number): Promise<RunResult> {
|
|
385
|
+
return new Promise((done) => {
|
|
386
|
+
let stdout = "";
|
|
387
|
+
let stderr = "";
|
|
388
|
+
let settled = false;
|
|
389
|
+
const finish = (result: RunResult): void => {
|
|
390
|
+
if (settled) return;
|
|
391
|
+
settled = true;
|
|
392
|
+
clearTimeout(timer);
|
|
393
|
+
done(result);
|
|
394
|
+
};
|
|
395
|
+
// stdin 必须关掉:子进程继承 pi 的终端会抢走按键。
|
|
396
|
+
const child = spawn(spec.command, [...spec.args, ...args], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
397
|
+
// 超时自己计时、结束时清掉:spawn 的 timeout 选项只在 exit 时清定时器,命令不存在(ENOENT)时
|
|
398
|
+
// 没有 exit,定时器会白白挂满整个超时(gh 没装时进程多挂 60 秒,测试也因此多等一分钟)。
|
|
399
|
+
const timer = setTimeout(() => child.kill(), timeoutMs);
|
|
400
|
+
child.stdout.on("data", (chunk) => (stdout += String(chunk)));
|
|
401
|
+
child.stderr.on("data", (chunk) => (stderr += String(chunk)));
|
|
402
|
+
child.on("error", (error) => finish({ code: null, stdout, stderr, error }));
|
|
403
|
+
child.on("close", (code, signal) => {
|
|
404
|
+
const error = code === null ? new Error(`timed out or killed (${signal ?? "no exit code"})`) : undefined;
|
|
405
|
+
finish(error ? { code, stdout, stderr, error } : { code, stdout, stderr });
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** First non-empty line of a program's complaint, without colors. */
|
|
411
|
+
function firstLine(text: string): string {
|
|
412
|
+
// 去掉 chalk 的颜色码(子进程不是终端时一般不会有,保险起见)。
|
|
413
|
+
const plain = text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
414
|
+
return plain.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** pi's default export file name: `pi-session-<file>.html`, or a timestamped `session-….jsonl` (both in the cwd). */
|
|
418
|
+
export function defaultExportName(sessionFile: string, format: ExportFormat, now = new Date()): string {
|
|
419
|
+
if (format === "html") return `pi-session-${basename(sessionFile, ".jsonl")}.html`;
|
|
420
|
+
return `session-${now.toISOString().replace(/[:.]/g, "-")}.jsonl`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Where `e` writes, from what the user typed: blank = pi's default name in the
|
|
425
|
+
* cwd; a directory (existing, or typed with a trailing separator) gets the
|
|
426
|
+
* default name inside it; anything else is the file itself. `~` and relative
|
|
427
|
+
* paths work on every platform (see ../utils/paths.ts).
|
|
428
|
+
*/
|
|
429
|
+
export function exportTarget(cwd: string, sessionFile: string, format: ExportFormat, input: string): ExportTarget {
|
|
430
|
+
const typed = stripQuotes(input.trim()).trim();
|
|
431
|
+
let path = resolveUserPath(cwd, input);
|
|
432
|
+
if (!path) path = join(cwd, defaultExportName(sessionFile, format));
|
|
433
|
+
else if (/[\\/]$/.test(typed) || isDirectory(path)) path = join(path, defaultExportName(sessionFile, format));
|
|
434
|
+
return { path, exists: existsSync(path) };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function isDirectory(path: string): boolean {
|
|
438
|
+
try {
|
|
439
|
+
return statSync(path).isDirectory();
|
|
440
|
+
} catch {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** The slice export needs: the current session's in-memory manager (its leaf may not be on disk yet). */
|
|
446
|
+
export type ExportContext = Pick<ExtensionCommandContext, "sessionManager">;
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Export `sessionFile` to `outputPath` (what `/export` does), for any session, not only the open one.
|
|
450
|
+
*
|
|
451
|
+
* - `html`:交给 `pi --export`(整棵树都在里面,默认显示活动分支;和 CLI 一样不带系统提示词 / 工具
|
|
452
|
+
* 定义,用 pi 的默认主题)。
|
|
453
|
+
* - `jsonl`:照搬 pi 的 exportSessionToJsonl:新的 header + 活动分支上的条目,parentId 重新串成一条链。
|
|
454
|
+
* 当前会话用 pi 内存里的 manager(跳转后还没落盘的叶子也算),其他会话读文件。
|
|
455
|
+
*
|
|
456
|
+
* 目标文件已存在时直接覆盖,调用方负责先问;拒绝把会话文件自己当成输出(JSONL 只留一条分支,会丢数据)。
|
|
457
|
+
* Returns the path written.
|
|
458
|
+
*/
|
|
459
|
+
export async function exportSession(
|
|
460
|
+
ctx: ExportContext,
|
|
461
|
+
sessionFile: string,
|
|
462
|
+
format: ExportFormat,
|
|
463
|
+
outputPath: string,
|
|
464
|
+
commands: ExternalCommands = {},
|
|
465
|
+
): Promise<string> {
|
|
466
|
+
if (!existsSync(sessionFile)) throw new Error(`session file not found: ${sessionFile}`);
|
|
467
|
+
if (resolve(outputPath) === resolve(sessionFile)) throw new Error("refusing to overwrite the session file itself");
|
|
468
|
+
if (isDirectory(outputPath)) throw new Error(`is a directory: ${outputPath}`);
|
|
469
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
470
|
+
if (format === "jsonl") {
|
|
471
|
+
writeJsonlExport(isCurrentSession(ctx, sessionFile) ? ctx.sessionManager : openSessionFile(sessionFile), outputPath);
|
|
472
|
+
} else {
|
|
473
|
+
await exportHtmlFile(sessionFile, outputPath, commands.pi ?? runningPi());
|
|
474
|
+
}
|
|
475
|
+
return outputPath;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** pi's exportSessionToJsonl, line for line: a fresh header, then the active branch re-chained. */
|
|
479
|
+
function writeJsonlExport(manager: Pick<SessionManager, "getSessionId" | "getCwd" | "getBranch">, outputPath: string): void {
|
|
480
|
+
const header = {
|
|
481
|
+
type: "session",
|
|
482
|
+
version: CURRENT_SESSION_VERSION,
|
|
483
|
+
id: manager.getSessionId(),
|
|
484
|
+
timestamp: new Date().toISOString(),
|
|
485
|
+
cwd: manager.getCwd(),
|
|
486
|
+
};
|
|
487
|
+
const lines = [JSON.stringify(header)];
|
|
488
|
+
let parentId: string | null = null;
|
|
489
|
+
for (const entry of manager.getBranch()) {
|
|
490
|
+
lines.push(JSON.stringify({ ...entry, parentId }));
|
|
491
|
+
parentId = entry.id;
|
|
492
|
+
}
|
|
493
|
+
writeFileSync(outputPath, `${lines.join("\n")}\n`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** `pi --export <file> <out>`; throws with what pi printed when it fails. */
|
|
497
|
+
async function exportHtmlFile(sessionFile: string, outputPath: string, pi: CommandSpec): Promise<void> {
|
|
498
|
+
const result = await runCommand(pi, ["--export", sessionFile, outputPath], EXPORT_TIMEOUT_MS);
|
|
499
|
+
if (result.error) throw new Error(`cannot run pi --export: ${result.error.message}`);
|
|
500
|
+
if (result.code !== 0) {
|
|
501
|
+
const reason = firstLine(result.stderr).replace(/^Error:\s*/, "");
|
|
502
|
+
throw new Error(reason || `pi --export exited with code ${result.code}`);
|
|
503
|
+
}
|
|
504
|
+
if (!existsSync(outputPath)) throw new Error("pi --export did not write the file");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** The slice import needs: where sessions live, the cwd relative paths are resolved against, and switchSession. */
|
|
508
|
+
export type ImportContext = Pick<ExtensionCommandContext, "sessionManager" | "switchSession" | "cwd">;
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Import a session JSONL and switch to it (what `/import` does).
|
|
512
|
+
*
|
|
513
|
+
* 扩展 ctx 上没有 pi 的 `importFromJsonl`,但它做的事很简单,这里照搬:把文件复制进当前会话目录
|
|
514
|
+
* (重名就加 -1、-2 后缀,文件本来就在会话目录里则不复制),再切过去。切换交给 `resumeSession`:
|
|
515
|
+
* 先 `SessionManager.open` 校验(不是 pi 会话文件会抛错,必须在 `ctx.switchSession` 之前拦下,
|
|
516
|
+
* 那里的异常会让 pi 直接退出),会话记的目录不存在时 pi 自己会问要不要在当前目录继续。
|
|
517
|
+
* 校验失败或切换被取消时删掉刚复制的副本。
|
|
518
|
+
*/
|
|
519
|
+
export async function importSession(ctx: ImportContext, input: string): Promise<EnterOutcome> {
|
|
520
|
+
const source = resolveUserPath(ctx.cwd, input);
|
|
521
|
+
if (!source) throw new Error("no file given");
|
|
522
|
+
if (!existsSync(source)) throw new Error(`file not found: ${source}`);
|
|
523
|
+
const stat = statSync(source);
|
|
524
|
+
if (!stat.isFile()) throw new Error(`not a file: ${source}`);
|
|
525
|
+
// 空文件会被 SessionManager.open 当成新会话初始化,导入它没有意义。
|
|
526
|
+
if (stat.size === 0) throw new Error(`not a pi session file (empty): ${source}`);
|
|
527
|
+
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
528
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
529
|
+
let destination = join(sessionDir, basename(source));
|
|
530
|
+
const alreadyStored = resolve(destination) === source;
|
|
531
|
+
if (!alreadyStored) {
|
|
532
|
+
const { name, ext } = parse(destination);
|
|
533
|
+
for (let suffix = 1; existsSync(destination); suffix++) destination = join(sessionDir, `${name}-${suffix}${ext}`);
|
|
534
|
+
copyFileSync(source, destination, constants.COPYFILE_EXCL);
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
return await resumeSession(ctx, destination);
|
|
538
|
+
} catch (err) {
|
|
539
|
+
if (!alreadyStored) rmSync(destination, { force: true });
|
|
540
|
+
throw err;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** pi's wording when `gh` is missing / not logged in. */
|
|
545
|
+
export const GH_NOT_INSTALLED = "GitHub CLI (gh) is not installed. Install it from https://cli.github.com/";
|
|
546
|
+
export const GH_NOT_LOGGED_IN = "GitHub CLI is not logged in. Run 'gh auth login' first.";
|
|
547
|
+
|
|
548
|
+
const DEFAULT_SHARE_VIEWER_URL = "https://pi.dev/session/";
|
|
549
|
+
|
|
550
|
+
/** pi's getShareViewerUrl: the pi.dev viewer (or `PI_SHARE_VIEWER_URL`) pointed at a gist. */
|
|
551
|
+
export function shareViewerUrl(gistId: string): string {
|
|
552
|
+
return `${process.env.PI_SHARE_VIEWER_URL || DEFAULT_SHARE_VIEWER_URL}#${gistId}`;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Upload `sessionFile` as a secret GitHub gist and return the viewer link (what `/share` does).
|
|
557
|
+
*
|
|
558
|
+
* 照搬 pi 的 gist 路径:`gh auth status` 检查登录 → 导出 HTML 到临时目录 → `gh gist create
|
|
559
|
+
* --public=false` → 从输出的 gist 地址取 id 拼 pi.dev 的查看链接;临时目录最后删掉。pi 会先试
|
|
560
|
+
* Radius(需要 pi 的 modelRuntime,扩展拿不到),这里只走 gist。外发操作,调用方必须先确认。
|
|
561
|
+
*/
|
|
562
|
+
export async function shareSession(sessionFile: string, commands: ExternalCommands = {}): Promise<ShareResult> {
|
|
563
|
+
if (!existsSync(sessionFile)) throw new Error(`session file not found: ${sessionFile}`);
|
|
564
|
+
const gh = commands.gh ?? { command: "gh", args: [] };
|
|
565
|
+
const auth = await runCommand(gh, ["auth", "status"], GH_TIMEOUT_MS);
|
|
566
|
+
if (auth.error) {
|
|
567
|
+
throw new Error((auth.error as NodeJS.ErrnoException).code === "ENOENT" ? GH_NOT_INSTALLED : `gh auth status: ${auth.error.message}`);
|
|
568
|
+
}
|
|
569
|
+
if (auth.code !== 0) throw new Error(GH_NOT_LOGGED_IN);
|
|
570
|
+
const tempDir = mkdtempSync(join(tmpdir(), "pi-share-"));
|
|
571
|
+
try {
|
|
572
|
+
const htmlFile = join(tempDir, "session.html");
|
|
573
|
+
await exportHtmlFile(sessionFile, htmlFile, commands.pi ?? runningPi());
|
|
574
|
+
const result = await runCommand(gh, ["gist", "create", "--public=false", htmlFile], GH_TIMEOUT_MS);
|
|
575
|
+
if (result.error) throw new Error(`Failed to create gist: ${result.error.message}`);
|
|
576
|
+
if (result.code !== 0) throw new Error(`Failed to create gist: ${result.stderr.trim() || "Unknown error"}`);
|
|
577
|
+
// gh 把进度写到 stderr,stdout 最后一行是 gist 地址。
|
|
578
|
+
const gistUrl = result.stdout.trim().split(/\r?\n/).pop()?.trim() ?? "";
|
|
579
|
+
const gistId = gistUrl.split("/").pop();
|
|
580
|
+
if (!gistId) throw new Error("Failed to parse gist ID from gh output");
|
|
581
|
+
return { url: shareViewerUrl(gistId), gistUrl };
|
|
582
|
+
} finally {
|
|
583
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
584
|
+
}
|
|
585
|
+
}
|