@unifan/pi-unifan-zh 1.0.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 (42) hide show
  1. package/README.md +111 -0
  2. package/extensions/review/CHANGELOG.md +540 -0
  3. package/extensions/review/LICENSE +15 -0
  4. package/extensions/review/README.md +55 -0
  5. package/extensions/review/agents/bugbot.md +46 -0
  6. package/extensions/review/agents/claude-md-compliance.md +46 -0
  7. package/extensions/review/agents/code-comments.md +43 -0
  8. package/extensions/review/agents/conventions.md +41 -0
  9. package/extensions/review/agents/gate.md +70 -0
  10. package/extensions/review/agents/history-context.md +45 -0
  11. package/extensions/review/agents/lite-review.md +51 -0
  12. package/extensions/review/agents/security-review.md +45 -0
  13. package/extensions/review/index.ts +205 -0
  14. package/extensions/review/package.json +38 -0
  15. package/extensions/review/reference/README.md +20 -0
  16. package/extensions/review/reference/claude-code-review.md +133 -0
  17. package/extensions/review/reference/cursor-review-skills.md +72 -0
  18. package/extensions/review/reference/pi-review-roadmap.md +183 -0
  19. package/extensions/review/reference/structured-output.md +26 -0
  20. package/extensions/review/reference/v0.2-plan.md +268 -0
  21. package/extensions/review/src/cli-args.ts +105 -0
  22. package/extensions/review/src/config.ts +340 -0
  23. package/extensions/review/src/directive.ts +481 -0
  24. package/extensions/review/src/gate-enforce.ts +151 -0
  25. package/extensions/review/src/lean-agents.ts +105 -0
  26. package/extensions/review/src/pr-ref.ts +39 -0
  27. package/extensions/review/src/report-tool.ts +394 -0
  28. package/extensions/review/src/report.ts +399 -0
  29. package/extensions/review/src/review-report.ts +279 -0
  30. package/extensions/review/src/review-run.ts +568 -0
  31. package/extensions/review/src/target-workspace.ts +239 -0
  32. package/extensions/review/src/tool-wrapper.ts +75 -0
  33. package/extensions/review/src/tui-renderer.ts +92 -0
  34. package/extensions/review/src/types.ts +207 -0
  35. package/extensions/review/src/workflow-schemas.ts +172 -0
  36. package/extensions/sessions/README.md +41 -0
  37. package/extensions/sessions/index.ts +862 -0
  38. package/extensions/sessions/package.json +21 -0
  39. package/extensions/sessions/sessions.ts +264 -0
  40. package/package.json +36 -0
  41. package/skills/README.md +29 -0
  42. package/tsconfig.json +14 -0
@@ -0,0 +1,862 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { DynamicBorder, SessionManager, keyHint } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ CancellableLoader,
5
+ Container,
6
+ Key,
7
+ SelectList,
8
+ type SelectItem,
9
+ Spacer,
10
+ Text,
11
+ matchesKey,
12
+ truncateToWidth,
13
+ visibleWidth,
14
+ wrapTextWithAnsi,
15
+ } from "@earendil-works/pi-tui";
16
+ import {
17
+ buildPreviewError,
18
+ buildSessionDescription,
19
+ buildSessionLabel,
20
+ buildSessionPreview,
21
+ buildSessionSearchEntries,
22
+ filterSessionEntries,
23
+ getSessionPaneLayout,
24
+ parseLimit,
25
+ type PreviewBlock,
26
+ type SessionInfoLike,
27
+ type SessionPreview,
28
+ formatRelativeTime,
29
+ } from "./sessions.js";
30
+ import * as child_process from "child_process";
31
+
32
+ const DEFAULT_VISIBLE = 12;
33
+ const SNIPPET_MAX = 60;
34
+ const PREVIEW_LOAD_DEBOUNCE_MS = 50;
35
+
36
+ const isPrintable = (data: string): boolean => {
37
+ if (data.length !== 1) return false;
38
+ const code = data.charCodeAt(0);
39
+ return code >= 32 && code !== 127;
40
+ };
41
+
42
+ const sortSessions = (sessions: SessionInfoLike[]): SessionInfoLike[] =>
43
+ [...sessions].sort((a, b) => b.modified.getTime() - a.modified.getTime());
44
+
45
+ const formatPlainLine = (session: SessionInfoLike): string => {
46
+ const label = buildSessionLabel(session);
47
+ const description = buildSessionDescription(session, SNIPPET_MAX);
48
+ return `${label}\t${description}`;
49
+ };
50
+
51
+ const padAnsiRight = (text: string, width: number): string => {
52
+ const truncated = truncateToWidth(text, width, "");
53
+ return `${truncated}${" ".repeat(Math.max(0, width - visibleWidth(truncated)))}`;
54
+ };
55
+
56
+ const themeText = (
57
+ theme: { fg: (color: any, text: string) => string; bold: (text: string) => string; italic?: (text: string) => string },
58
+ kind: "title" | "subtitle" | "rule" | "user" | "assistant" | "tool" | "error" | "muted" | "text" | "thinking",
59
+ text: string,
60
+ ): string => {
61
+ if (kind === "title") return theme.fg("accent", theme.bold(text));
62
+ if (kind === "subtitle") return theme.fg("dim", text);
63
+ if (kind === "rule") return theme.fg("border", text);
64
+ if (kind === "user") return theme.fg("accent", theme.bold(text));
65
+ if (kind === "assistant") return theme.fg("warning", theme.bold(text));
66
+ if (kind === "tool") return theme.fg("muted", theme.bold(text));
67
+ if (kind === "error") return theme.fg("error", text);
68
+ if (kind === "muted") return theme.fg("muted", text);
69
+ if (kind === "thinking") return theme.italic ? theme.italic(theme.fg("dim", text)) : theme.fg("dim", text);
70
+ return theme.fg("text", text);
71
+ };
72
+
73
+ interface PreviewRenderOptions {
74
+ toolsExpanded: boolean;
75
+ thinkingVisible: boolean;
76
+ }
77
+
78
+ const splitLines = (text: string): string[] => text.replace(/\r\n/g, "\n").split("\n");
79
+
80
+ const compactText = (text: string, maxLines: number): { lines: string[]; hidden: number } => {
81
+ const lines = splitLines(text).map((line) => line.replace(/\s+$/g, "")).filter((line) => line.trim().length > 0);
82
+ if (lines.length <= maxLines) return { lines, hidden: 0 };
83
+ return { lines: lines.slice(0, maxLines), hidden: lines.length - maxLines };
84
+ };
85
+
86
+ const wrapStyled = (
87
+ prefix: string,
88
+ text: string,
89
+ width: number,
90
+ color: (line: string) => string,
91
+ ): string[] => {
92
+ const contentWidth = Math.max(1, width - visibleWidth(prefix));
93
+ const wrapped = wrapTextWithAnsi(color(text), contentWidth);
94
+ return wrapped.map((line) => `${prefix}${line}`);
95
+ };
96
+
97
+ const renderTextBlock = (
98
+ label: string,
99
+ text: string,
100
+ width: number,
101
+ theme: { fg: (color: any, text: string) => string; bold: (text: string) => string },
102
+ labelKind: "user" | "assistant" | "tool" | "muted" | "error" | "thinking",
103
+ ): string[] => {
104
+ const lines = [themeText(theme, labelKind, label)];
105
+ for (const rawLine of splitLines(text)) {
106
+ if (!rawLine.trim()) {
107
+ lines.push("");
108
+ continue;
109
+ }
110
+ lines.push(...wrapStyled(" ", rawLine.trimEnd(), width, (line) => theme.fg("text", line)));
111
+ }
112
+ return lines;
113
+ };
114
+
115
+ const isToolBlock = (block: PreviewBlock): boolean =>
116
+ block.kind === "toolCall" || block.kind === "toolResult" || block.kind === "bash";
117
+
118
+ const summarizeToolRun = (blocks: PreviewBlock[]): string => {
119
+ const names = new Set<string>();
120
+ let outputLines = 0;
121
+ let hasError = false;
122
+
123
+ for (const block of blocks) {
124
+ if (block.kind === "toolCall") names.add(block.name);
125
+ if (block.kind === "toolResult") {
126
+ if (block.name) names.add(block.name);
127
+ outputLines += compactText(block.text, Number.MAX_SAFE_INTEGER).lines.length;
128
+ hasError ||= !!block.isError;
129
+ }
130
+ if (block.kind === "bash") {
131
+ names.add("bash");
132
+ outputLines += compactText(block.output ?? "", Number.MAX_SAFE_INTEGER).lines.length;
133
+ hasError ||= !!block.isError;
134
+ }
135
+ }
136
+
137
+ const nameList = Array.from(names).slice(0, 4).join(", ");
138
+ const moreNames = names.size > 4 ? ` +${names.size - 4}` : "";
139
+ const output = outputLines > 0 ? ` · ${outputLines} 行输出` : "";
140
+ return `${hasError ? "✖" : "▸"} 工具执行 · ${blocks.length} 个事件${nameList ? ` · ${nameList}${moreNames}` : ""}${output} (按 t 展开)`;
141
+ };
142
+
143
+ const renderPreviewBlock = (
144
+ block: PreviewBlock,
145
+ width: number,
146
+ theme: { fg: (color: any, text: string) => string; bold: (text: string) => string; italic?: (text: string) => string },
147
+ options: PreviewRenderOptions,
148
+ ): string[] => {
149
+ if (block.kind === "notice") {
150
+ return wrapTextWithAnsi(themeText(theme, "muted", block.text), width);
151
+ }
152
+
153
+ if (block.kind === "user") {
154
+ return renderTextBlock("◆ 用户", block.text, width, theme, "user");
155
+ }
156
+
157
+ if (block.kind === "assistant") {
158
+ return renderTextBlock("● 助手", block.text, width, theme, "assistant");
159
+ }
160
+
161
+ if (block.kind === "thinking") {
162
+ if (!options.thinkingVisible) return [themeText(theme, "thinking", "◌ 思考过程已隐藏 (按 h 显示)")];
163
+ return renderTextBlock("◌ 思考过程", block.text, width, theme, "thinking");
164
+ }
165
+
166
+ if (block.kind === "toolCall") {
167
+ const header = `▸ 工具调用 · ${block.name}`;
168
+ if (!options.toolsExpanded || !block.args) return [themeText(theme, "tool", header)];
169
+ return [themeText(theme, "tool", header), ...wrapStyled(" ", block.args, width, (line) => theme.fg("muted", line))];
170
+ }
171
+
172
+ if (block.kind === "toolResult") {
173
+ const label = `${block.isError ? "✖" : "▸"} 工具返回${block.name ? ` · ${block.name}` : ""}`;
174
+ const compact = compactText(block.text, options.toolsExpanded ? 200 : 4);
175
+ const lines = [themeText(theme, block.isError ? "error" : "tool", label)];
176
+ for (const line of compact.lines) {
177
+ lines.push(...wrapStyled(" ", line, width, (value) => theme.fg(block.isError ? "error" : "muted", value)));
178
+ }
179
+ if (compact.hidden > 0) lines.push(theme.fg("dim", ` … 已折叠 ${compact.hidden} 行输出 (按 t 键展开全部)`));
180
+ return lines;
181
+ }
182
+
183
+ if (block.kind === "bash") {
184
+ const label = `${block.isError ? "✖" : "▸"} Bash 命令`;
185
+ const lines = [themeText(theme, block.isError ? "error" : "tool", label)];
186
+ if (block.command) lines.push(...wrapStyled(" ", `$ ${block.command}`, width, (line) => theme.fg("accent", line)));
187
+ const compact = compactText(block.output ?? "", options.toolsExpanded ? 200 : 4);
188
+ for (const line of compact.lines) {
189
+ lines.push(...wrapStyled(" ", line, width, (value) => theme.fg(block.isError ? "error" : "muted", value)));
190
+ }
191
+ if (compact.hidden > 0) lines.push(theme.fg("dim", ` … 已折叠 ${compact.hidden} 行输出 (按 t 键展开全部)`));
192
+ return lines;
193
+ }
194
+
195
+ if (block.kind === "summary") {
196
+ return renderTextBlock(`◇ ${block.label}`, block.text, width, theme, "muted");
197
+ }
198
+
199
+ return renderTextBlock(`◇ ${block.label}`, block.text, width, theme, "muted");
200
+ };
201
+
202
+ interface SessionStats {
203
+ add: number;
204
+ mod: number;
205
+ del: number;
206
+ }
207
+
208
+ const statsCache = new Map<string, SessionStats>();
209
+ const statsLoading = new Set<string>();
210
+
211
+ const execCmd = (cmd: string, cwd: string): Promise<string> => {
212
+ return new Promise((resolve) => {
213
+ child_process.exec(cmd, { cwd, encoding: "utf8" }, (err, stdout) => {
214
+ resolve(err ? "" : stdout);
215
+ });
216
+ });
217
+ };
218
+
219
+ const loadSessionStats = (session: SessionInfoLike, isLatestForCwd: boolean, tui: any) => {
220
+ const key = session.path;
221
+ if (statsCache.has(key) || statsLoading.has(key)) return;
222
+
223
+ statsLoading.add(key);
224
+
225
+ const after = session.created ? session.created.toISOString() : new Date(session.modified.getTime() - 24 * 3600 * 1000).toISOString();
226
+ const before = session.modified.toISOString();
227
+ const logCmd = `git log --after="${after}" --before="${before}" --numstat --pretty=format:`;
228
+ const diffCmd = isLatestForCwd ? `git diff --numstat` : ``;
229
+
230
+ Promise.all([
231
+ execCmd(logCmd, session.cwd),
232
+ diffCmd ? execCmd(diffCmd, session.cwd) : Promise.resolve("")
233
+ ]).then(([logOut, diffOut]) => {
234
+ let added = 0;
235
+ let deleted = 0;
236
+ const parseOut = (out: string) => {
237
+ const lines = out.split("\n");
238
+ for (const line of lines) {
239
+ const parts = line.trim().split(/\s+/);
240
+ if (parts.length >= 2) {
241
+ const a = parseInt(parts[0], 10);
242
+ const d = parseInt(parts[1], 10);
243
+ if (!isNaN(a)) added += a;
244
+ if (!isNaN(d)) deleted += d;
245
+ }
246
+ }
247
+ };
248
+ parseOut(logOut);
249
+ parseOut(diffOut);
250
+
251
+ const mod = Math.min(added, deleted);
252
+ const add = added - mod;
253
+ const del = deleted - mod;
254
+
255
+ statsCache.set(key, { add, mod, del });
256
+ statsLoading.delete(key);
257
+ tui.requestRender();
258
+ });
259
+ };
260
+
261
+ const formatItemLabel = (
262
+ session: SessionInfoLike,
263
+ width: number,
264
+ theme: any,
265
+ statsCache: Map<string, SessionStats>
266
+ ): string => {
267
+ const itemWidth = width - 4;
268
+ const timeStr = formatRelativeTime(session.modified);
269
+
270
+ const stats = statsCache.get(session.path);
271
+ let statsStr = "";
272
+ let statsLen = 0;
273
+ if (stats && (stats.add > 0 || stats.mod > 0 || stats.del > 0)) {
274
+ const parts = [];
275
+ if (stats.add > 0) parts.push(theme.fg("success", `+${stats.add}`));
276
+ if (stats.mod > 0) parts.push(theme.fg("warning", `~${stats.mod}`));
277
+ if (stats.del > 0) parts.push(theme.fg("error", `-${stats.del}`));
278
+ statsStr = parts.join(" ");
279
+ statsLen = (stats.add > 0 ? `${stats.add}`.length + 1 : 0) +
280
+ (stats.mod > 0 ? `${stats.mod}`.length + 1 : 0) +
281
+ (stats.del > 0 ? `${stats.del}`.length + 1 : 0) +
282
+ parts.length - 1;
283
+ }
284
+
285
+ const title = buildSessionLabel(session);
286
+ const reserved = 1 + timeStr.length + (statsLen > 0 ? statsLen + 2 : 0);
287
+ const titleWidth = Math.max(5, itemWidth - reserved);
288
+ const truncatedTitle = truncateToWidth(title, titleWidth, "…");
289
+
290
+ const visibleTitleLen = visibleWidth(truncatedTitle);
291
+ const paddingSize = itemWidth - visibleTitleLen - (statsLen > 0 ? statsLen : 0) - timeStr.length;
292
+
293
+ if (statsStr) {
294
+ const pad1 = " ".repeat(Math.max(1, paddingSize - 2));
295
+ const pad2 = " ";
296
+ return `${truncatedTitle}${pad1}${statsStr}${pad2}${theme.fg("dim", timeStr)}`;
297
+ } else {
298
+ const padding = " ".repeat(Math.max(1, paddingSize));
299
+ return `${truncatedTitle}${padding}${theme.fg("dim", timeStr)}`;
300
+ }
301
+ };
302
+
303
+ const renderPreview = (
304
+ preview: SessionPreview | undefined,
305
+ width: number,
306
+ height: number,
307
+ scrollOffset: number,
308
+ theme: { fg: (color: any, text: string) => string; bold: (text: string) => string; italic?: (text: string) => string },
309
+ options: PreviewRenderOptions,
310
+ focus: "list" | "preview"
311
+ ): { lines: string[]; totalLines: number; maxScroll: number } => {
312
+ const raw: string[] = [];
313
+
314
+ if (!preview) {
315
+ raw.push(" ".repeat(Math.max(0, Math.floor((width - 8) / 2))) + themeText(theme, "title", "会话预览"));
316
+ raw.push(" ".repeat(Math.max(0, Math.floor((width - 20) / 2))) + themeText(theme, "subtitle", "正在加载所选会话…"));
317
+ } else {
318
+ const titleStr = "会话内容实时预览";
319
+ const titleColor = focus === "preview" ? themeText(theme, "title", titleStr) : themeText(theme, "subtitle", titleStr);
320
+ const titlePadding = " ".repeat(Math.max(0, Math.floor((width - visibleWidth(titleStr)) / 2)));
321
+ raw.push(`${titlePadding}${titleColor}`);
322
+
323
+ const subStr = preview.subtitle;
324
+ const subPadding = " ".repeat(Math.max(0, Math.floor((width - visibleWidth(subStr)) / 2)));
325
+ raw.push(`${subPadding}${themeText(theme, "subtitle", subStr)}`);
326
+
327
+ raw.push(themeText(theme, "rule", "─".repeat(Math.max(0, width))));
328
+
329
+ for (let i = 0; i < preview.blocks.length; i++) {
330
+ const block = preview.blocks[i]!;
331
+ if (!options.toolsExpanded && isToolBlock(block)) {
332
+ const run: PreviewBlock[] = [];
333
+ while (i < preview.blocks.length && isToolBlock(preview.blocks[i]!)) {
334
+ run.push(preview.blocks[i]!);
335
+ i++;
336
+ }
337
+ i--;
338
+ if (raw.length > 3) raw.push("");
339
+ raw.push(...wrapTextWithAnsi(themeText(theme, "tool", summarizeToolRun(run)), width));
340
+ continue;
341
+ }
342
+
343
+ if (raw.length > 3) raw.push("");
344
+ raw.push(...renderPreviewBlock(block, width, theme, options));
345
+ }
346
+ }
347
+
348
+ const maxScroll = Math.max(0, raw.length - height);
349
+ const boundedOffset = Math.max(0, Math.min(scrollOffset, maxScroll));
350
+
351
+ const visible: string[] = [];
352
+ const thumbSize = Math.max(1, Math.min(height, Math.round((height / raw.length) * height)));
353
+ const thumbStart = maxScroll > 0
354
+ ? Math.max(0, Math.min(height - thumbSize, Math.round((boundedOffset / maxScroll) * (height - thumbSize))))
355
+ : 0;
356
+
357
+ for (let i = 0; i < height; i++) {
358
+ const lineIndex = boundedOffset + i;
359
+ const line = lineIndex < raw.length ? raw[lineIndex]! : "";
360
+
361
+ let scrollChar = "";
362
+ if (maxScroll > 0) {
363
+ if (i >= thumbStart && i < thumbStart + thumbSize) {
364
+ scrollChar = theme.fg("text", "█");
365
+ } else {
366
+ scrollChar = theme.fg("border", "│");
367
+ }
368
+ } else {
369
+ scrollChar = theme.fg("border", "│");
370
+ }
371
+
372
+ visible.push(padAnsiRight(line, width - 1) + scrollChar);
373
+ }
374
+
375
+ return { lines: visible, totalLines: raw.length, maxScroll };
376
+ };
377
+
378
+ const loadSessionPreview = async (session: SessionInfoLike): Promise<SessionPreview> => {
379
+ try {
380
+ const manager = SessionManager.open(session.path);
381
+ const context = manager.buildSessionContext();
382
+ return buildSessionPreview(session, context.messages as any[]);
383
+ } catch (error) {
384
+ return buildPreviewError(session, error);
385
+ }
386
+ };
387
+
388
+ async function listSessions(ctx: ExtensionCommandContext): Promise<SessionInfoLike[] | null> {
389
+ if (!ctx.hasUI) {
390
+ const sessions = await SessionManager.list(ctx.cwd);
391
+ return sortSessions(sessions);
392
+ }
393
+
394
+ let loadError: string | undefined;
395
+ const sessions = await ctx.ui.custom<SessionInfoLike[] | null>((tui, theme, _kb, done) => {
396
+ const container = new Container();
397
+ const borderColor = (text: string) => theme.fg("border", text);
398
+
399
+ const loader = new CancellableLoader(
400
+ tui,
401
+ (text) => theme.fg("accent", text),
402
+ (text) => theme.fg("muted", text),
403
+ "正在加载会话列表...",
404
+ );
405
+
406
+ let settled = false;
407
+ const finish = (result: SessionInfoLike[] | null) => {
408
+ if (settled) return;
409
+ settled = true;
410
+ done(result);
411
+ };
412
+
413
+ loader.onAbort = () => finish(null);
414
+
415
+ container.addChild(new DynamicBorder(borderColor));
416
+ container.addChild(loader);
417
+ container.addChild(new Spacer(1));
418
+ container.addChild(new Text(keyHint("tui.select.cancel", "取消"), 1, 0));
419
+ container.addChild(new Spacer(1));
420
+ container.addChild(new DynamicBorder(borderColor));
421
+
422
+ const load = async () => {
423
+ try {
424
+ const results = await SessionManager.list(ctx.cwd, undefined, (loaded, total) => {
425
+ if (total > 0) {
426
+ loader.setMessage(`正在加载历史会话 (${loaded}/${total})...`);
427
+ }
428
+ });
429
+ finish(sortSessions(results));
430
+ } catch (error) {
431
+ loadError = error instanceof Error ? error.message : "未知错误";
432
+ finish(null);
433
+ }
434
+ };
435
+
436
+ void load();
437
+
438
+ return {
439
+ render: (width) => container.render(width),
440
+ invalidate: () => container.invalidate(),
441
+ handleInput: (data) => {
442
+ loader.handleInput(data);
443
+ tui.requestRender();
444
+ },
445
+ };
446
+ });
447
+
448
+ if (loadError) {
449
+ ctx.ui.notify(`加载会话列表失败: ${loadError}`, "error");
450
+ }
451
+
452
+ return sessions;
453
+ }
454
+
455
+ async function showSessionPicker(
456
+ ctx: ExtensionCommandContext,
457
+ sessions: SessionInfoLike[],
458
+ maxVisible: number,
459
+ ): Promise<SessionInfoLike | null> {
460
+ let sorted = sessions;
461
+ let entries = buildSessionSearchEntries(sorted);
462
+ const sessionByPath = new Map(sorted.map((session) => [session.path, session]));
463
+
464
+ return ctx.ui.custom<SessionInfoLike | null>((tui, theme, kb, done) => {
465
+ let filter = "";
466
+ let selectList: SelectList;
467
+ let filteredEntries = entries;
468
+ let selectedPath = sorted[0]?.path ?? "";
469
+ let previewScrollOffset = 0;
470
+ let toolsExpanded = false;
471
+ let thinkingVisible = false;
472
+ const previewCache = new Map<string, SessionPreview>();
473
+ let activePreview: SessionPreview | undefined;
474
+ let previewTimer: ReturnType<typeof setTimeout> | undefined;
475
+ let previewSeq = 0;
476
+
477
+ let focus: "list" | "preview" = "list";
478
+ let showAllWorkspaces = false;
479
+ let isLoading = false;
480
+
481
+ const previewKey = (session: SessionInfoLike): string => `${session.path}:${session.modified.getTime()}`;
482
+
483
+ const getLatestSessionMap = (list: SessionInfoLike[]) => {
484
+ const latestMap = new Map<string, string>();
485
+ for (const s of list) {
486
+ if (!latestMap.has(s.cwd)) {
487
+ latestMap.set(s.cwd, s.path);
488
+ }
489
+ }
490
+ return latestMap;
491
+ };
492
+
493
+ const triggerStatsLoad = (list: SessionInfoLike[]) => {
494
+ const latestMap = getLatestSessionMap(list);
495
+ for (const s of list) {
496
+ const isLatest = latestMap.get(s.cwd) === s.path;
497
+ loadSessionStats(s, isLatest, tui);
498
+ }
499
+ };
500
+
501
+ triggerStatsLoad(sorted);
502
+
503
+ const loadWorkspaceSessions = async (allWorkspaces: boolean) => {
504
+ isLoading = true;
505
+ tui.requestRender();
506
+ try {
507
+ let results: SessionInfoLike[];
508
+ if (allWorkspaces) {
509
+ results = await SessionManager.listAll();
510
+ } else {
511
+ results = await SessionManager.list(ctx.cwd);
512
+ }
513
+ sorted = sortSessions(results);
514
+ for (const s of sorted) {
515
+ sessionByPath.set(s.path, s);
516
+ }
517
+ entries = buildSessionSearchEntries(sorted);
518
+ triggerStatsLoad(sorted);
519
+
520
+ filteredEntries = filterSessionEntries(entries, filter);
521
+ const matchedIndex = filteredEntries.findIndex((entry) => entry.session.path === selectedPath);
522
+ if (matchedIndex < 0) {
523
+ selectedPath = filteredEntries[0]?.session.path ?? "";
524
+ }
525
+ rebuild();
526
+ schedulePreviewLoad();
527
+ } catch (error) {
528
+ ctx.ui.notify(`加载会话失败: ${error instanceof Error ? error.message : error}`, "error");
529
+ } finally {
530
+ isLoading = false;
531
+ tui.requestRender();
532
+ }
533
+ };
534
+
535
+ const schedulePreviewLoad = () => {
536
+ previewScrollOffset = 0;
537
+ const session = sessionByPath.get(selectedPath);
538
+ if (!session) {
539
+ activePreview = undefined;
540
+ return;
541
+ }
542
+
543
+ const key = previewKey(session);
544
+ const cached = previewCache.get(key);
545
+ if (cached) {
546
+ activePreview = cached;
547
+ return;
548
+ }
549
+
550
+ activePreview = {
551
+ title: buildSessionLabel(session),
552
+ subtitle: `${session.modified.toLocaleString()} · 正在加载预览`,
553
+ blocks: [{ kind: "notice", text: "正在加载所选会话内容…" }],
554
+ };
555
+
556
+ if (previewTimer) clearTimeout(previewTimer);
557
+ const seq = ++previewSeq;
558
+ previewTimer = setTimeout(() => {
559
+ void loadSessionPreview(session).then((preview) => {
560
+ if (seq !== previewSeq || selectedPath !== session.path) return;
561
+ previewCache.set(key, preview);
562
+ activePreview = preview;
563
+ tui.requestRender();
564
+ });
565
+ }, PREVIEW_LOAD_DEBOUNCE_MS);
566
+ };
567
+
568
+ const setSelectedPath = (path: string | undefined) => {
569
+ if (!path || path === selectedPath) return;
570
+ selectedPath = path;
571
+ schedulePreviewLoad();
572
+ };
573
+
574
+ const buildItems = (current: typeof entries, listWidth: number): SelectItem[] =>
575
+ current.map((entry) => ({
576
+ value: entry.session.path,
577
+ label: formatItemLabel(entry.session, listWidth, theme, statsCache),
578
+ }));
579
+
580
+ const rebuild = () => {
581
+ filteredEntries = filterSessionEntries(entries, filter);
582
+ const matchedIndex = filteredEntries.findIndex((entry) => entry.session.path === selectedPath);
583
+ if (matchedIndex < 0) {
584
+ selectedPath = filteredEntries[0]?.session.path ?? "";
585
+ schedulePreviewLoad();
586
+ }
587
+ };
588
+
589
+ const renderSinglePane = (width: number): string[] => {
590
+ const container = new Container();
591
+ const filterLine = filter.length
592
+ ? `${theme.fg("muted", "过滤: ")}${theme.fg("text", filter)}`
593
+ : `${theme.fg("muted", "过滤: ")}${theme.fg("dim", "输入关键词过滤...")}`;
594
+
595
+ container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
596
+ container.addChild(new Text(theme.fg("accent", theme.bold("历史会话列表")), 1, 0));
597
+ container.addChild(new Text(filterLine, 1, 0));
598
+ if (isLoading) {
599
+ container.addChild(new Spacer(1));
600
+ container.addChild(new Text(theme.fg("muted", " 正在加载会话列表..."), 1, 0));
601
+ container.addChild(new Spacer(1));
602
+ } else {
603
+ const items = buildItems(filteredEntries, width);
604
+ selectList = new SelectList(items, Math.max(1, Math.min(maxVisible, Math.max(items.length, 1))), {
605
+ selectedPrefix: (text) => theme.fg(focus === "list" ? "accent" : "muted", text),
606
+ selectedText: (text) => theme.fg(focus === "list" ? "accent" : "muted", text),
607
+ description: (text) => theme.fg("muted", text),
608
+ scrollInfo: (text) => theme.fg("dim", text),
609
+ noMatch: () => theme.fg("warning", " 未找到匹配的历史会话"),
610
+ });
611
+ const selectedIndex = filteredEntries.findIndex((entry) => entry.session.path === selectedPath);
612
+ selectList.setSelectedIndex(selectedIndex >= 0 ? selectedIndex : 0);
613
+ selectList.onSelect = (item) => done(sessionByPath.get(item.value) ?? null);
614
+ selectList.onCancel = () => done(null);
615
+ selectList.onSelectionChange = (item) => setSelectedPath(item.value);
616
+ container.addChild(selectList);
617
+ }
618
+ container.addChild(new Text(theme.fg("dim", "↑↓ 导航 • Enter 恢复会话 • Esc 退出"), 1, 0));
619
+ container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
620
+ return container.render(width);
621
+ };
622
+
623
+ const buildTopBorder = (listWidth: number, previewWidth: number): string => {
624
+ const leftTitle = " 切换历史会话 ";
625
+ const left = `┌─${leftTitle}${"─".repeat(Math.max(0, listWidth - leftTitle.length - 2))}`;
626
+ const right = "─".repeat(Math.max(0, previewWidth - 1)) + "┐";
627
+ return `${theme.fg("border", left)}${theme.fg("border", "─┬─")}${theme.fg("border", right)}`;
628
+ };
629
+
630
+ const buildBottomBorder = (listWidth: number, previewWidth: number, previewStats: string): string => {
631
+ const help = showAllWorkspaces ? " Ctrl+T/Alt+W:仅当前项目 · Esc:退出 " : " Ctrl+T/Alt+W:全部工作区 · Esc:退出 ";
632
+ const left = `└${"─".repeat(Math.max(0, listWidth - 1))}`;
633
+ const right = `${"─".repeat(Math.max(0, previewWidth - help.length - 1))}${help}┘`;
634
+ return `${theme.fg("border", left)}${theme.fg("border", "─┴─")}${theme.fg("border", right)}`;
635
+ };
636
+
637
+ const renderSplitPane = (width: number): string[] => {
638
+ const layout = getSessionPaneLayout(width);
639
+ const termRows = Math.max(12, tui.terminal?.rows ?? 24);
640
+ const contentHeight = Math.max(8, termRows - 2);
641
+ const filterLine = filter.length
642
+ ? `${theme.fg("muted", "过滤: ")}${theme.fg("text", filter)}`
643
+ : `${theme.fg("muted", "过滤: ")}${theme.fg("dim", "输入关键词过滤...")}`;
644
+ const listHeight = Math.max(1, contentHeight - 1);
645
+
646
+ let leftLines: string[] = [];
647
+ if (isLoading) {
648
+ leftLines.push(filterLine);
649
+ leftLines.push("");
650
+ leftLines.push(theme.fg("muted", " 正在加载会话列表..."));
651
+ } else {
652
+ const items = buildItems(filteredEntries, layout.listWidth);
653
+ selectList = new SelectList(items, Math.max(listHeight, Math.min(maxVisible, Math.max(items.length, 1))), {
654
+ selectedPrefix: (text) => theme.fg(focus === "list" ? "accent" : "muted", text),
655
+ selectedText: (text) => theme.fg(focus === "list" ? "accent" : "muted", text),
656
+ description: (text) => theme.fg("muted", text),
657
+ scrollInfo: (text) => theme.fg("dim", text),
658
+ noMatch: () => theme.fg("warning", " 未找到匹配的历史会话"),
659
+ });
660
+ const selectedIndex = filteredEntries.findIndex((entry) => entry.session.path === selectedPath);
661
+ selectList.setSelectedIndex(selectedIndex >= 0 ? selectedIndex : 0);
662
+ selectList.onSelect = (item) => done(sessionByPath.get(item.value) ?? null);
663
+ selectList.onCancel = () => done(null);
664
+ selectList.onSelectionChange = (item) => setSelectedPath(item.value);
665
+
666
+ leftLines = [filterLine, ...selectList.render(layout.listWidth)].slice(0, contentHeight);
667
+ }
668
+
669
+ while (leftLines.length < contentHeight) leftLines.push("");
670
+
671
+ const renderedPreview = renderPreview(activePreview, layout.previewWidth, contentHeight, previewScrollOffset, theme, {
672
+ toolsExpanded,
673
+ thinkingVisible,
674
+ }, focus);
675
+ previewScrollOffset = Math.min(previewScrollOffset, renderedPreview.maxScroll);
676
+ const modeHints = `t: ${toolsExpanded ? "折叠工具" : "展开工具"} • h: ${thinkingVisible ? "隐藏思考" : "显示思考"}`;
677
+ const previewStats = renderedPreview.maxScroll > 0
678
+ ? ` ${previewScrollOffset + 1}-${Math.min(previewScrollOffset + contentHeight, renderedPreview.totalLines)}/${renderedPreview.totalLines} • PgUp/PgDn 翻页 • ${modeHints} `
679
+ : ` Esc/Enter • ${modeHints} `;
680
+
681
+ const lines = [buildTopBorder(layout.listWidth, layout.previewWidth)];
682
+ for (let i = 0; i < contentHeight; i++) {
683
+ const left = padAnsiRight(leftLines[i] ?? "", layout.listWidth);
684
+ const right = renderedPreview.lines[i] ?? " ".repeat(layout.previewWidth);
685
+ lines.push(`${left}${theme.fg("border", " │ ")}${right}`);
686
+ }
687
+ lines.push(buildBottomBorder(layout.listWidth, layout.previewWidth, previewStats));
688
+ return lines.map((line) => truncateToWidth(line, width, "", true));
689
+ };
690
+
691
+ rebuild();
692
+ schedulePreviewLoad();
693
+
694
+ return {
695
+ render: (width) => {
696
+ const layout = getSessionPaneLayout(width);
697
+ if (layout.mode === "single") return renderSinglePane(width);
698
+ return renderSplitPane(width);
699
+ },
700
+ invalidate: () => {
701
+ previewCache.clear();
702
+ rebuild();
703
+ },
704
+ handleInput: (data) => {
705
+ if (data === "\u0014" || data === "\u001bw") {
706
+ showAllWorkspaces = !showAllWorkspaces;
707
+ void loadWorkspaceSessions(showAllWorkspaces);
708
+ return;
709
+ }
710
+
711
+ if (data === "\t") {
712
+ focus = focus === "list" ? "preview" : "list";
713
+ tui.requestRender();
714
+ return;
715
+ }
716
+ if (matchesKey(data, Key.left)) {
717
+ if (focus === "preview") {
718
+ focus = "list";
719
+ tui.requestRender();
720
+ }
721
+ return;
722
+ }
723
+ if (matchesKey(data, Key.right)) {
724
+ if (focus === "list") {
725
+ focus = "preview";
726
+ tui.requestRender();
727
+ }
728
+ return;
729
+ }
730
+
731
+ if (isLoading) return;
732
+
733
+ if (matchesKey(data, Key.backspace) || matchesKey(data, Key.delete)) {
734
+ if (filter.length > 0) {
735
+ filter = filter.slice(0, -1);
736
+ rebuild();
737
+ tui.requestRender();
738
+ }
739
+ return;
740
+ }
741
+
742
+ const currentLayout = getSessionPaneLayout(tui.terminal?.columns ?? 80);
743
+ if (currentLayout.mode === "split") {
744
+ if (data === "t") {
745
+ toolsExpanded = !toolsExpanded;
746
+ previewScrollOffset = 0;
747
+ tui.requestRender();
748
+ return;
749
+ }
750
+ if (data === "h") {
751
+ thinkingVisible = !thinkingVisible;
752
+ previewScrollOffset = 0;
753
+ tui.requestRender();
754
+ return;
755
+ }
756
+
757
+ if (focus === "preview") {
758
+ const pageSize = Math.max(4, (tui.terminal?.rows ?? 24) - 4);
759
+ if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("d"))) {
760
+ previewScrollOffset += pageSize;
761
+ tui.requestRender();
762
+ return;
763
+ }
764
+ if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("u"))) {
765
+ previewScrollOffset = Math.max(0, previewScrollOffset - pageSize);
766
+ tui.requestRender();
767
+ return;
768
+ }
769
+ if (matchesKey(data, Key.down) || matchesKey(data, Key.ctrl("j"))) {
770
+ previewScrollOffset++;
771
+ tui.requestRender();
772
+ return;
773
+ }
774
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.ctrl("k"))) {
775
+ previewScrollOffset = Math.max(0, previewScrollOffset - 1);
776
+ tui.requestRender();
777
+ return;
778
+ }
779
+ }
780
+ }
781
+
782
+ if (focus === "list") {
783
+ if (isPrintable(data)) {
784
+ filter += data;
785
+ rebuild();
786
+ tui.requestRender();
787
+ return;
788
+ }
789
+
790
+ if (kb.matches(data, "tui.select.pageUp")) {
791
+ const currentIndex = Math.max(0, filteredEntries.findIndex((entry) => entry.session.path === selectedPath));
792
+ const nextIndex = Math.max(0, currentIndex - maxVisible);
793
+ selectList.setSelectedIndex(nextIndex);
794
+ setSelectedPath(filteredEntries[nextIndex]?.session.path);
795
+ tui.requestRender();
796
+ return;
797
+ }
798
+
799
+ if (kb.matches(data, "tui.select.pageDown")) {
800
+ const currentIndex = Math.max(0, filteredEntries.findIndex((entry) => entry.session.path === selectedPath));
801
+ const nextIndex = Math.min(filteredEntries.length - 1, currentIndex + maxVisible);
802
+ selectList.setSelectedIndex(nextIndex);
803
+ setSelectedPath(filteredEntries[nextIndex]?.session.path);
804
+ tui.requestRender();
805
+ return;
806
+ }
807
+
808
+ selectList.handleInput(data);
809
+ tui.requestRender();
810
+ }
811
+ },
812
+ };
813
+ }, {
814
+ overlay: true,
815
+ overlayOptions: {
816
+ anchor: "top-left",
817
+ row: 0,
818
+ col: 0,
819
+ width: "100%",
820
+ maxHeight: "100%",
821
+ margin: 0,
822
+ },
823
+ });
824
+ }
825
+
826
+ async function runSessionsCommand(args: string | undefined, ctx: ExtensionCommandContext): Promise<void> {
827
+ const limit = parseLimit(args, DEFAULT_VISIBLE);
828
+ const sessions = await listSessions(ctx);
829
+
830
+ if (!sessions || sessions.length === 0) {
831
+ if (ctx.hasUI) {
832
+ ctx.ui.notify("当前项目未找到任何历史会话。", "info");
833
+ } else {
834
+ console.log("当前项目未找到任何历史会话。");
835
+ }
836
+ return;
837
+ }
838
+
839
+ if (!ctx.hasUI) {
840
+ for (const session of sessions) {
841
+ console.log(formatPlainLine(session));
842
+ }
843
+ return;
844
+ }
845
+
846
+ const selection = await showSessionPicker(ctx, sessions, limit);
847
+ if (!selection) return;
848
+
849
+ const result = await ctx.switchSession(selection.path);
850
+ if (result.cancelled) {
851
+ ctx.ui.notify("已取消切换会话。", "info");
852
+ }
853
+ }
854
+
855
+ export default function sessionsExtension(pi: ExtensionAPI) {
856
+ pi.registerCommand("sessions", {
857
+ description: "浏览并切换历史会话(双栏实时内容预览与恢复,中文增强版)",
858
+ handler: async (args, ctx) => {
859
+ await runSessionsCommand(args, ctx);
860
+ },
861
+ });
862
+ }