pi-cc-extensions 0.1.1
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/README.md +141 -0
- package/assets/readme/context-preview.webp +0 -0
- package/assets/readme/context-usage.webp +0 -0
- package/assets/readme/hero.svg +101 -0
- package/assets/readme/session-reference.webp +0 -0
- package/assets/readme/showcase.webp +0 -0
- package/extensions/README.md +3 -0
- package/extensions/ccstyle-powerline/bash-mode/completion.ts +556 -0
- package/extensions/ccstyle-powerline/bash-mode/editor.ts +442 -0
- package/extensions/ccstyle-powerline/bash-mode/history.ts +151 -0
- package/extensions/ccstyle-powerline/bash-mode/shell-session.ts +286 -0
- package/extensions/ccstyle-powerline/bash-mode/transcript.ts +108 -0
- package/extensions/ccstyle-powerline/bash-mode/types.ts +59 -0
- package/extensions/ccstyle-powerline/colors.ts +69 -0
- package/extensions/ccstyle-powerline/context-usage.ts +46 -0
- package/extensions/ccstyle-powerline/fixed-editor/cluster.ts +107 -0
- package/extensions/ccstyle-powerline/fixed-editor/terminal-split.ts +1317 -0
- package/extensions/ccstyle-powerline/git-status.ts +218 -0
- package/extensions/ccstyle-powerline/icons.ts +181 -0
- package/extensions/ccstyle-powerline/powerline-config.ts +234 -0
- package/extensions/ccstyle-powerline/powerline.ts +3150 -0
- package/extensions/ccstyle-powerline/presets.ts +109 -0
- package/extensions/ccstyle-powerline/render-scheduler.ts +24 -0
- package/extensions/ccstyle-powerline/segments.ts +476 -0
- package/extensions/ccstyle-powerline/separators.ts +41 -0
- package/extensions/ccstyle-powerline/shortcuts.ts +57 -0
- package/extensions/ccstyle-powerline/theme.json +26 -0
- package/extensions/ccstyle-powerline/theme.ts +241 -0
- package/extensions/ccstyle-powerline/types.ts +197 -0
- package/extensions/ccstyle-powerline/welcome-dismiss.ts +34 -0
- package/extensions/ccstyle-powerline/welcome.ts +611 -0
- package/extensions/ccstyle-powerline/working-vibes.ts +695 -0
- package/extensions/claude-code-style.ts +987 -0
- package/extensions/context.ts +362 -0
- package/extensions/session-reference/README.md +28 -0
- package/extensions/session-reference/core.test.ts +74 -0
- package/extensions/session-reference/core.ts +258 -0
- package/extensions/session-reference/index.ts +379 -0
- package/package.json +33 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { type ExtensionAPI, type ExtensionCommandContext, estimateTokens } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export type ContextPart = {
|
|
5
|
+
label: string;
|
|
6
|
+
tokens: number;
|
|
7
|
+
color: "accent" | "success" | "warning" | "muted" | "dim";
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type PreviewKey = "systemPrompt" | "tools" | "contextFiles" | "skills";
|
|
11
|
+
|
|
12
|
+
type ContextPreview = {
|
|
13
|
+
key: PreviewKey;
|
|
14
|
+
label: string;
|
|
15
|
+
title: string;
|
|
16
|
+
content: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function normalizePreviewText(text: string): string {
|
|
20
|
+
return text
|
|
21
|
+
.replace(/\r\n?/g, "\n")
|
|
22
|
+
.replace(/\t/g, " ")
|
|
23
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function showTextPreview(ctx: Pick<ExtensionCommandContext, "ui">, title: string, rawContent: string): Promise<void> {
|
|
27
|
+
const content = normalizePreviewText(rawContent);
|
|
28
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
29
|
+
let scrollOffset = 0;
|
|
30
|
+
let pageSize = 1;
|
|
31
|
+
let totalLines = 1;
|
|
32
|
+
|
|
33
|
+
const scrollTo = (nextOffset: number): void => {
|
|
34
|
+
scrollOffset = Math.max(0, Math.min(nextOffset, Math.max(0, totalLines - pageSize)));
|
|
35
|
+
tui.requestRender();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
invalidate() {},
|
|
40
|
+
handleInput(data: string) {
|
|
41
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
42
|
+
done();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (matchesKey(data, Key.up)) scrollTo(scrollOffset - 1);
|
|
46
|
+
else if (matchesKey(data, Key.down)) scrollTo(scrollOffset + 1);
|
|
47
|
+
else if (matchesKey(data, "pageUp")) scrollTo(scrollOffset - pageSize);
|
|
48
|
+
else if (matchesKey(data, "pageDown")) scrollTo(scrollOffset + pageSize);
|
|
49
|
+
else if (matchesKey(data, Key.home)) scrollTo(0);
|
|
50
|
+
else if (matchesKey(data, Key.end)) scrollTo(totalLines - pageSize);
|
|
51
|
+
else {
|
|
52
|
+
const mouse = parseSgrMousePacket(data);
|
|
53
|
+
if (mouse?.final !== "M") return;
|
|
54
|
+
const button = mouseBaseButton(mouse.code);
|
|
55
|
+
if (button === 64) scrollTo(scrollOffset - 3);
|
|
56
|
+
else if (button === 65) scrollTo(scrollOffset + 3);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
render(width: number) {
|
|
60
|
+
const inner = Math.max(1, width - 2);
|
|
61
|
+
const bodyInner = Math.max(1, inner - 1);
|
|
62
|
+
const bodyWidth = Math.max(1, bodyInner - 1);
|
|
63
|
+
const terminalHeight = Math.max(1, tui.terminal.rows);
|
|
64
|
+
const availableHeight = Math.max(1, terminalHeight - 4);
|
|
65
|
+
const viewportHeight = Math.min(30, Math.max(1, Math.floor(terminalHeight * 0.8)), availableHeight);
|
|
66
|
+
pageSize = Math.max(1, viewportHeight - 6);
|
|
67
|
+
const wrapped = wrapTextWithAnsi(content, bodyWidth);
|
|
68
|
+
totalLines = wrapped.length;
|
|
69
|
+
scrollOffset = Math.min(scrollOffset, Math.max(0, totalLines - pageSize));
|
|
70
|
+
const visible = wrapped.slice(scrollOffset, scrollOffset + pageSize);
|
|
71
|
+
const border = (text: string) => theme.fg("border", text);
|
|
72
|
+
const padLine = (text: string, lineWidth = inner): string => {
|
|
73
|
+
const truncated = truncateToWidth(text, lineWidth, "…");
|
|
74
|
+
return truncated + " ".repeat(Math.max(0, lineWidth - visibleWidth(truncated)));
|
|
75
|
+
};
|
|
76
|
+
const scrollable = totalLines > pageSize;
|
|
77
|
+
const thumbSize = scrollable ? Math.max(1, Math.floor((pageSize * pageSize) / totalLines)) : 0;
|
|
78
|
+
const maxScrollOffset = Math.max(0, totalLines - pageSize);
|
|
79
|
+
const thumbStart = scrollable && maxScrollOffset > 0
|
|
80
|
+
? Math.round((scrollOffset / maxScrollOffset) * (pageSize - thumbSize))
|
|
81
|
+
: 0;
|
|
82
|
+
const scrollbar = (row: number): string => {
|
|
83
|
+
if (!scrollable) return " ";
|
|
84
|
+
const inThumb = row >= thumbStart && row < thumbStart + thumbSize;
|
|
85
|
+
return theme.fg(inThumb ? "accent" : "borderMuted", inThumb ? "█" : "│");
|
|
86
|
+
};
|
|
87
|
+
const bodyRows = Array.from({ length: pageSize }, (_, row) => {
|
|
88
|
+
const line = visible[row] ?? "";
|
|
89
|
+
return `${border("│")}${padLine(` ${line}`, bodyInner)}${scrollbar(row)}${border("│")}`;
|
|
90
|
+
});
|
|
91
|
+
const start = totalLines === 0 ? 0 : scrollOffset + 1;
|
|
92
|
+
const end = Math.min(totalLines, scrollOffset + pageSize);
|
|
93
|
+
const status = `${start}-${end} / ${totalLines} lines · ↑↓ PgUp/PgDn Home/End · Esc close`;
|
|
94
|
+
|
|
95
|
+
return [
|
|
96
|
+
border(`╭${"─".repeat(inner)}╮`),
|
|
97
|
+
`${border("│")}${padLine(` ${theme.bold(theme.fg("accent", title))}`)}${border("│")}`,
|
|
98
|
+
`${border("├")}${border("─".repeat(inner))}${border("┤")}`,
|
|
99
|
+
...bodyRows,
|
|
100
|
+
`${border("├")}${border("─".repeat(inner))}${border("┤")}`,
|
|
101
|
+
`${border("│")}${padLine(theme.fg("dim", ` ${status}`))}${border("│")}`,
|
|
102
|
+
border(`╰${"─".repeat(inner)}╯`),
|
|
103
|
+
];
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}, {
|
|
107
|
+
overlay: true,
|
|
108
|
+
overlayOptions: { anchor: "center", width: "85%", minWidth: 50, maxHeight: "80%", margin: 2 },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type SgrMousePacket = {
|
|
113
|
+
code: number;
|
|
114
|
+
col: number;
|
|
115
|
+
row: number;
|
|
116
|
+
final: "M" | "m";
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function parseSgrMousePacket(data: string): SgrMousePacket | null {
|
|
120
|
+
const match = data.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/);
|
|
121
|
+
if (!match) return null;
|
|
122
|
+
return {
|
|
123
|
+
code: Number(match[1]),
|
|
124
|
+
col: Number(match[2]),
|
|
125
|
+
row: Number(match[3]),
|
|
126
|
+
final: match[4] as "M" | "m",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function mouseBaseButton(code: number): number {
|
|
131
|
+
return code & ~(4 | 8 | 16 | 32);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const tokenEstimate = (value: unknown): number => {
|
|
135
|
+
if (!value) return 0;
|
|
136
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
137
|
+
return Math.max(0, Math.ceil(text.length / 4));
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export function scaleParts(parts: ContextPart[], target: number): ContextPart[] {
|
|
141
|
+
const estimated = parts.reduce((sum, part) => sum + part.tokens, 0);
|
|
142
|
+
if (estimated === 0 || target <= 0) return parts;
|
|
143
|
+
const scaled = parts.map((part) => ({ ...part, tokens: Math.round((part.tokens / estimated) * target) }));
|
|
144
|
+
const delta = target - scaled.reduce((sum, part) => sum + part.tokens, 0);
|
|
145
|
+
const largest = scaled.reduce((best, part, index) => part.tokens > scaled[best]!.tokens ? index : best, 0);
|
|
146
|
+
scaled[largest]!.tokens += delta;
|
|
147
|
+
return scaled;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function formatTokens(tokens: number): string {
|
|
151
|
+
if (tokens < 1_000) return String(tokens);
|
|
152
|
+
if (tokens < 100_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
|
153
|
+
return `${Math.round(tokens / 1_000)}k`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function collectParts(ctx: ExtensionCommandContext): ContextPart[] {
|
|
157
|
+
const options = ctx.getSystemPromptOptions();
|
|
158
|
+
const contextFileTokens = (options.contextFiles ?? []).reduce((sum, file) => sum + tokenEstimate(file.content), 0);
|
|
159
|
+
const skillTokens = (options.skills ?? []).reduce((sum, skill) => sum + tokenEstimate(skill), 0);
|
|
160
|
+
const tools = options.selectedTools ?? [];
|
|
161
|
+
const toolTokens = tools.reduce((sum, name) => sum + tokenEstimate(name) + tokenEstimate(options.toolSnippets?.[name]), 0)
|
|
162
|
+
+ tokenEstimate(options.promptGuidelines);
|
|
163
|
+
|
|
164
|
+
let user = 0;
|
|
165
|
+
let assistant = 0;
|
|
166
|
+
let toolResults = 0;
|
|
167
|
+
let summaries = 0;
|
|
168
|
+
for (const entry of ctx.sessionManager.buildContextEntries()) {
|
|
169
|
+
if (entry.type === "message") {
|
|
170
|
+
const tokens = estimateTokens(entry.message);
|
|
171
|
+
if (entry.message.role === "user") user += tokens;
|
|
172
|
+
else if (entry.message.role === "assistant") assistant += tokens;
|
|
173
|
+
else toolResults += tokens;
|
|
174
|
+
} else if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
175
|
+
summaries += tokenEstimate(entry);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const systemTotal = tokenEstimate(ctx.getSystemPrompt());
|
|
180
|
+
const baseSystem = Math.max(0, systemTotal - contextFileTokens - skillTokens - toolTokens);
|
|
181
|
+
const parts: ContextPart[] = [
|
|
182
|
+
{ label: "System prompt", tokens: baseSystem, color: "accent" },
|
|
183
|
+
{ label: "Tools", tokens: toolTokens, color: "success" },
|
|
184
|
+
{ label: "Context files", tokens: contextFileTokens, color: "warning" },
|
|
185
|
+
{ label: "Skills", tokens: skillTokens, color: "warning" },
|
|
186
|
+
{ label: "User messages", tokens: user, color: "muted" },
|
|
187
|
+
{ label: "Assistant messages", tokens: assistant, color: "accent" },
|
|
188
|
+
{ label: "Tool results", tokens: toolResults, color: "dim" },
|
|
189
|
+
{ label: "Compaction summaries", tokens: summaries, color: "success" },
|
|
190
|
+
];
|
|
191
|
+
return parts.filter((part) => part.tokens > 0);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export default function contextUsageExtension(pi: ExtensionAPI) {
|
|
195
|
+
pi.registerCommand("context", {
|
|
196
|
+
description: "Show the current context-window distribution",
|
|
197
|
+
handler: async (_args, ctx) => {
|
|
198
|
+
const usage = ctx.getContextUsage();
|
|
199
|
+
const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
200
|
+
const used = usage?.tokens ?? collectParts(ctx).reduce((sum, part) => sum + part.tokens, 0);
|
|
201
|
+
const parts = scaleParts(collectParts(ctx), used);
|
|
202
|
+
const free = Math.max(0, contextWindow - used);
|
|
203
|
+
const allParts = [...parts, { label: "Free space", tokens: free, color: "dim" as const }];
|
|
204
|
+
|
|
205
|
+
if (ctx.mode !== "tui") {
|
|
206
|
+
const lines = allParts.map((part) => `${part.label}: ${formatTokens(part.tokens)} tokens`);
|
|
207
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const options = ctx.getSystemPromptOptions();
|
|
212
|
+
const toolByName = new Map(pi.getAllTools().map((tool) => [tool.name, tool]));
|
|
213
|
+
const toolContent = (options.selectedTools ?? []).map((name) => {
|
|
214
|
+
const tool = toolByName.get(name);
|
|
215
|
+
const lines = [`## ${name}`];
|
|
216
|
+
if (tool?.description) lines.push(tool.description);
|
|
217
|
+
if (options.toolSnippets?.[name]) lines.push(`Prompt: ${options.toolSnippets[name]}`);
|
|
218
|
+
if (tool?.promptGuidelines?.length) {
|
|
219
|
+
lines.push("Guidelines:", ...tool.promptGuidelines.map((guideline) => `- ${guideline}`));
|
|
220
|
+
}
|
|
221
|
+
return lines.join("\n");
|
|
222
|
+
});
|
|
223
|
+
if (options.promptGuidelines?.length) {
|
|
224
|
+
toolContent.push(`## Shared prompt guidelines\n${options.promptGuidelines.map((guideline) => `- ${guideline}`).join("\n")}`);
|
|
225
|
+
}
|
|
226
|
+
const contextFilesContent = (options.contextFiles ?? [])
|
|
227
|
+
.map((file) => `===== ${file.path} =====\n${file.content}`)
|
|
228
|
+
.join("\n\n");
|
|
229
|
+
const skillsContent = (options.skills ?? [])
|
|
230
|
+
.map((skill) => [
|
|
231
|
+
`## ${skill.name}`,
|
|
232
|
+
skill.description,
|
|
233
|
+
`Path: ${skill.filePath}`,
|
|
234
|
+
`Model invocation: ${skill.disableModelInvocation ? "disabled" : "enabled"}`,
|
|
235
|
+
].filter(Boolean).join("\n"))
|
|
236
|
+
.join("\n\n");
|
|
237
|
+
const rawPreviews: ContextPreview[] = [
|
|
238
|
+
{ key: "systemPrompt", label: "System prompt", title: "System Prompt", content: ctx.getSystemPrompt() },
|
|
239
|
+
{ key: "tools", label: "Tools", title: "Tools", content: toolContent.join("\n\n") || "No active tools." },
|
|
240
|
+
{ key: "contextFiles", label: "Context files", title: "Context Files", content: contextFilesContent || "No context files loaded." },
|
|
241
|
+
{ key: "skills", label: "Skills", title: "Skills", content: skillsContent || "No skills loaded." },
|
|
242
|
+
];
|
|
243
|
+
const previews = rawPreviews.map((preview) => ({
|
|
244
|
+
...preview,
|
|
245
|
+
content: normalizePreviewText(preview.content),
|
|
246
|
+
}));
|
|
247
|
+
const previewByKey = new Map(previews.map((preview) => [preview.key, preview]));
|
|
248
|
+
const visiblePreviews = previews.filter((preview) => allParts.some((part) => part.label === preview.label));
|
|
249
|
+
let selectedPreviewIndex = 0;
|
|
250
|
+
|
|
251
|
+
while (true) {
|
|
252
|
+
const action = await ctx.ui.custom<PreviewKey | undefined>((tui, theme, _keybindings, done) => {
|
|
253
|
+
let previewHitboxes: Array<{ key: PreviewKey; row: number; startCol: number; endCol: number }> = [];
|
|
254
|
+
|
|
255
|
+
const padLine = (text: string, width: number): string => {
|
|
256
|
+
const truncated = truncateToWidth(text, width, "…");
|
|
257
|
+
return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated)));
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
invalidate() {},
|
|
262
|
+
handleInput(data: string) {
|
|
263
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
264
|
+
done(undefined);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (matchesKey(data, Key.up) && visiblePreviews.length > 0) {
|
|
268
|
+
selectedPreviewIndex = (selectedPreviewIndex - 1 + visiblePreviews.length) % visiblePreviews.length;
|
|
269
|
+
tui.requestRender();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (matchesKey(data, Key.down) && visiblePreviews.length > 0) {
|
|
273
|
+
selectedPreviewIndex = (selectedPreviewIndex + 1) % visiblePreviews.length;
|
|
274
|
+
tui.requestRender();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (matchesKey(data, Key.enter)) {
|
|
278
|
+
done(visiblePreviews[selectedPreviewIndex]?.key);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const mouse = parseSgrMousePacket(data);
|
|
283
|
+
if (mouse?.final !== "M" || mouseBaseButton(mouse.code) !== 0 || (mouse.code & 32) !== 0) return;
|
|
284
|
+
const hitbox = previewHitboxes.find((candidate) =>
|
|
285
|
+
mouse.row === candidate.row
|
|
286
|
+
&& mouse.col >= candidate.startCol
|
|
287
|
+
&& mouse.col <= candidate.endCol
|
|
288
|
+
);
|
|
289
|
+
if (hitbox) {
|
|
290
|
+
selectedPreviewIndex = Math.max(0, visiblePreviews.findIndex((preview) => preview.key === hitbox.key));
|
|
291
|
+
done(hitbox.key);
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
render(width: number) {
|
|
295
|
+
const inner = Math.max(1, width - 2);
|
|
296
|
+
const percent = contextWindow > 0 ? (used / contextWindow) * 100 : 0;
|
|
297
|
+
const title = theme.bold(theme.fg("accent", "Context Usage"));
|
|
298
|
+
const subtitle = `${formatTokens(used)} / ${formatTokens(contextWindow)} tokens (${percent.toFixed(1)}%)`;
|
|
299
|
+
const barWidth = Math.max(1, Math.min(60, inner - 2));
|
|
300
|
+
let remaining = barWidth;
|
|
301
|
+
const segments = allParts.map((part, index) => {
|
|
302
|
+
const cells = index === allParts.length - 1
|
|
303
|
+
? remaining
|
|
304
|
+
: Math.min(remaining, Math.round((part.tokens / Math.max(1, contextWindow)) * barWidth));
|
|
305
|
+
remaining -= cells;
|
|
306
|
+
return theme.fg(part.color, "█".repeat(Math.max(0, cells)));
|
|
307
|
+
}).join("");
|
|
308
|
+
const labelWidth = Math.min(24, Math.max(...allParts.map((part) => part.label.length)));
|
|
309
|
+
const selectedLabel = visiblePreviews[selectedPreviewIndex]?.label;
|
|
310
|
+
const partRows = allParts.map((part) => {
|
|
311
|
+
const pct = contextWindow > 0 ? (part.tokens / contextWindow) * 100 : 0;
|
|
312
|
+
const swatch = theme.fg(part.color, "■");
|
|
313
|
+
const label = part.label.padEnd(labelWidth);
|
|
314
|
+
const amount = `${formatTokens(part.tokens).padStart(7)} ${pct.toFixed(1).padStart(5)}%`;
|
|
315
|
+
const selected = part.label === selectedLabel;
|
|
316
|
+
const prefix = selected ? "› " : " ";
|
|
317
|
+
const row = padLine(`${prefix}${swatch} ${label} ${amount}`, inner);
|
|
318
|
+
return selected ? theme.bg("selectedBg", row) : row;
|
|
319
|
+
});
|
|
320
|
+
const border = (text: string) => theme.fg("border", text);
|
|
321
|
+
const lines = [
|
|
322
|
+
border(`╭${"─".repeat(inner)}╮`),
|
|
323
|
+
`${border("│")}${padLine(` ${title} ${theme.fg("muted", subtitle)}`, inner)}${border("│")}`,
|
|
324
|
+
`${border("├")}${border("─".repeat(inner))}${border("┤")}`,
|
|
325
|
+
`${border("│")}${padLine(` ${segments}`, inner)}${border("│")}`,
|
|
326
|
+
`${border("│")}${" ".repeat(inner)}${border("│")}`,
|
|
327
|
+
...partRows.map((row) => `${border("│")}${row}${border("│")}`),
|
|
328
|
+
`${border("├")}${border("─".repeat(inner))}${border("┤")}`,
|
|
329
|
+
`${border("│")}${padLine(theme.fg("dim", " ↑↓ select · Click / Enter to preview · Esc to close"), inner)}${border("│")}`,
|
|
330
|
+
border(`╰${"─".repeat(inner)}╯`),
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
const terminalHeight = Math.max(1, tui.terminal.rows);
|
|
334
|
+
const maxHeight = Math.min(Math.max(1, Math.floor(terminalHeight * 0.9)), Math.max(1, terminalHeight - 2));
|
|
335
|
+
const visibleHeight = Math.min(lines.length, maxHeight);
|
|
336
|
+
const overlayTop = 1 + Math.floor((Math.max(1, terminalHeight - 2) - visibleHeight) / 2);
|
|
337
|
+
const overlayLeft = Math.floor((Math.max(1, tui.terminal.columns) - width) / 2);
|
|
338
|
+
previewHitboxes = visiblePreviews.flatMap((preview) => {
|
|
339
|
+
const partIndex = allParts.findIndex((part) => part.label === preview.label);
|
|
340
|
+
const line = 5 + partIndex;
|
|
341
|
+
return partIndex >= 0 && line < visibleHeight
|
|
342
|
+
? [{ key: preview.key, row: overlayTop + line + 1, startCol: overlayLeft + 1, endCol: overlayLeft + width }]
|
|
343
|
+
: [];
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
return lines;
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}, {
|
|
350
|
+
overlay: true,
|
|
351
|
+
overlayOptions: { anchor: "center", width: 64, minWidth: 44, maxHeight: "90%", margin: 1 },
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
if (!action) break;
|
|
355
|
+
const preview = previewByKey.get(action);
|
|
356
|
+
if (!preview) continue;
|
|
357
|
+
|
|
358
|
+
await showTextPreview(ctx, preview.title, preview.content);
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Session Reference
|
|
2
|
+
|
|
3
|
+
在 pi 编辑器中输入 `@`,从统一补全列表选择 `[Session] ...`,即可把历史 Session 绑定为:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
@session:<session-id>
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
提交 prompt 时,扩展读取该 Session 当前有效分支(遵循分支与 compaction),以自定义上下文消息注入当前 Session。历史内容被标记为不可信背景上下文,不会切换或合并 Session。
|
|
10
|
+
|
|
11
|
+
## 行为
|
|
12
|
+
|
|
13
|
+
- 搜索全部 pi 历史 Session;当前 Workspace 的 Session 优先。
|
|
14
|
+
- Session 模糊查询默认最多显示 3 个候选。
|
|
15
|
+
- 与 pi 原有 `@` 文件补全并存。
|
|
16
|
+
- 若已加载 `pi-subagents`,`@` 补全会同时显示现有 SubAgent,并引用其当前有效上下文。
|
|
17
|
+
- 支持一个 prompt 引用多个 Session,并自动去重;单次最多注入前 5 个。
|
|
18
|
+
- 跳过历史 Session 中嵌套的 Session 引用,避免递归膨胀。
|
|
19
|
+
- 上下文限制:单条消息 8 KB、单 Session 24 KB、总计 48 KB;超限保留开头和结尾。
|
|
20
|
+
- 选择后仅插入引用标记;真正读取和注入发生在提交 prompt 时。
|
|
21
|
+
|
|
22
|
+
项目扩展可通过 `/reload` 热重载。
|
|
23
|
+
|
|
24
|
+
## 验证
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
node --test .pi/extensions/session-reference/core.test.ts
|
|
28
|
+
```
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
import {
|
|
5
|
+
SESSION_REFERENCE_CUSTOM_TYPE,
|
|
6
|
+
buildReferenceContent,
|
|
7
|
+
extractSessionReferenceIds,
|
|
8
|
+
sessionTitle,
|
|
9
|
+
truncateUtf8,
|
|
10
|
+
} from "./core.ts";
|
|
11
|
+
|
|
12
|
+
const info = {
|
|
13
|
+
id: "019f78f7-526e-78ac-afa5-ff6d5e06beb8",
|
|
14
|
+
cwd: "/repo",
|
|
15
|
+
firstMessage: " Refactor\n\tthe auth module ",
|
|
16
|
+
messageCount: 2,
|
|
17
|
+
modified: new Date("2025-01-02T03:04:05.000Z"),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
test("extractSessionReferenceIds finds boundary-delimited references and deduplicates them", () => {
|
|
21
|
+
assert.deepEqual(
|
|
22
|
+
extractSessionReferenceIds(
|
|
23
|
+
"Use @session:abc-123 and\n@session:def_456.v2, then @session:abc-123. Ignore x@session:nope.",
|
|
24
|
+
),
|
|
25
|
+
["abc-123", "def_456.v2"],
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("sessionTitle normalizes and truncates display text", () => {
|
|
30
|
+
assert.equal(sessionTitle(info), "Refactor the auth module");
|
|
31
|
+
assert.equal(sessionTitle({ ...info, name: "Named session" }), "Named session");
|
|
32
|
+
assert.equal(sessionTitle({ ...info, name: "123456789" }, 6), "12345…");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("truncateUtf8 enforces byte limits for multibyte content", () => {
|
|
36
|
+
const result = truncateUtf8("你".repeat(1_000), 100);
|
|
37
|
+
assert.ok(Buffer.byteLength(result, "utf8") <= 100);
|
|
38
|
+
assert.match(result, /truncated/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("buildReferenceContent formats active context and drops nested references", () => {
|
|
42
|
+
const content = buildReferenceContent([
|
|
43
|
+
{
|
|
44
|
+
info,
|
|
45
|
+
messages: [
|
|
46
|
+
{ role: "user", content: "Implement it" },
|
|
47
|
+
{ role: "assistant", content: [{ type: "text", text: "Done" }] },
|
|
48
|
+
{
|
|
49
|
+
role: "custom",
|
|
50
|
+
customType: SESSION_REFERENCE_CUSTOM_TYPE,
|
|
51
|
+
content: "nested prior reference",
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
assert.match(content, /Referenced Pi sessions/);
|
|
58
|
+
assert.match(content, /User: Implement it/);
|
|
59
|
+
assert.match(content, /Assistant: Done/);
|
|
60
|
+
assert.doesNotMatch(content, /nested prior reference/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("buildReferenceContent enforces incremental session and total byte limits", () => {
|
|
64
|
+
const messages = Array.from({ length: 1_000 }, (_, index) => ({
|
|
65
|
+
role: "user",
|
|
66
|
+
content: `${index}: ${"x".repeat(1_000)}`,
|
|
67
|
+
}));
|
|
68
|
+
const content = buildReferenceContent(
|
|
69
|
+
[{ info, messages }],
|
|
70
|
+
{ maxMessageBytes: 400, maxSessionBytes: 2_000, maxTotalBytes: 1_000 },
|
|
71
|
+
);
|
|
72
|
+
assert.ok(Buffer.byteLength(content, "utf8") <= 1_000);
|
|
73
|
+
assert.match(content, /truncated/);
|
|
74
|
+
});
|