pi-one-ui 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/LICENSE +21 -0
- package/README.md +106 -0
- package/extensions/app/config/renderer.ts +452 -0
- package/extensions/app/config/shell.ts +2484 -0
- package/extensions/app/ownership.ts +18 -0
- package/extensions/app/panel.ts +533 -0
- package/extensions/app/presets.ts +36 -0
- package/extensions/features/agent-summary/core.ts +194 -0
- package/extensions/features/agent-summary/index.ts +37 -0
- package/extensions/features/compact-thinking.ts +1262 -0
- package/extensions/features/context.ts +857 -0
- package/extensions/features/reference/index.ts +643 -0
- package/extensions/features/reference/session.ts +278 -0
- package/extensions/features/reference/subagent.ts +218 -0
- package/extensions/features/shell/aliases.ts +28 -0
- package/extensions/features/shell/flush-docked-bash.ts +39 -0
- package/extensions/features/shell/startup-header.ts +357 -0
- package/extensions/features/shell/working-message.ts +226 -0
- package/extensions/index.ts +52 -0
- package/extensions/shell/accent-rail-editor.ts +112 -0
- package/extensions/shell/accent-rail-layout-patch.ts +530 -0
- package/extensions/shell/completion-menu.ts +186 -0
- package/extensions/shell/editor-metadata-format.ts +287 -0
- package/extensions/shell/editor-transfer.ts +73 -0
- package/extensions/shell/extension-status.ts +102 -0
- package/extensions/shell/footer-format.ts +388 -0
- package/extensions/shell/footer-layout.ts +144 -0
- package/extensions/shell/footer.ts +1035 -0
- package/extensions/shell/format.ts +577 -0
- package/extensions/shell/git.ts +434 -0
- package/extensions/shell/icons.ts +261 -0
- package/extensions/shell/index.ts +1659 -0
- package/extensions/shell/interaction-summary.ts +1194 -0
- package/extensions/shell/live-context.ts +82 -0
- package/extensions/shell/minimalist-editor.ts +548 -0
- package/extensions/shell/package-version.ts +675 -0
- package/extensions/shell/project-refresh.ts +130 -0
- package/extensions/shell/project-state.ts +60 -0
- package/extensions/shell/prototype-patch-registry.ts +183 -0
- package/extensions/shell/runtime.ts +915 -0
- package/extensions/shell/selector-border.ts +109 -0
- package/extensions/shell/session-lifecycle.ts +60 -0
- package/extensions/shell/settings-command.ts +2144 -0
- package/extensions/shell/settings-previews.ts +137 -0
- package/extensions/shell/state.ts +83 -0
- package/extensions/shell/style.ts +480 -0
- package/extensions/shell/telemetry.ts +90 -0
- package/extensions/shell/ui.ts +1326 -0
- package/extensions/shell/user-message-osc.ts +279 -0
- package/extensions/shell/user-message-styles.ts +266 -0
- package/extensions/shell/user-message.ts +226 -0
- package/extensions/shell/working-line-messages.ts +24 -0
- package/extensions/shell/working-line-spinners.ts +54 -0
- package/extensions/shell/working-line.ts +1603 -0
- package/extensions/tools/ansi-color.ts +44 -0
- package/extensions/tools/ansi-text.ts +52 -0
- package/extensions/tools/component-tree.ts +37 -0
- package/extensions/tools/format.ts +28 -0
- package/extensions/tools/fullscreen-detect.ts +11 -0
- package/extensions/tools/patch-keys.ts +158 -0
- package/extensions/tools/sgr-mouse.ts +59 -0
- package/extensions/tools/tool-loading-icon.ts +20 -0
- package/extensions/tools/tool-result-sanitize.ts +27 -0
- package/extensions/transcript/index.ts +65 -0
- package/extensions/transcript/renderer/compact-mode.ts +1443 -0
- package/extensions/transcript/renderer/default-mode.ts +735 -0
- package/extensions/transcript/renderer/index.ts +286 -0
- package/extensions/transcript/renderer/markdown-enhance.ts +325 -0
- package/extensions/transcript/renderer/mouse/hover.ts +175 -0
- package/extensions/transcript/renderer/mouse/interaction.ts +1016 -0
- package/extensions/transcript/renderer/mouse/layout.ts +207 -0
- package/extensions/transcript/renderer/mouse/packets.ts +96 -0
- package/extensions/transcript/renderer/mouse/scroll.ts +211 -0
- package/extensions/transcript/renderer/tool/diff/ATTRIBUTION.md +28 -0
- package/extensions/transcript/renderer/tool/diff/ansi-utils.ts +128 -0
- package/extensions/transcript/renderer/tool/diff/diff-component.ts +112 -0
- package/extensions/transcript/renderer/tool/diff/diff-edit-render.ts +255 -0
- package/extensions/transcript/renderer/tool/diff/diff-header.ts +184 -0
- package/extensions/transcript/renderer/tool/diff/diff-highlight.ts +131 -0
- package/extensions/transcript/renderer/tool/diff/diff-inline.ts +350 -0
- package/extensions/transcript/renderer/tool/diff/diff-layout.ts +870 -0
- package/extensions/transcript/renderer/tool/diff/diff-limits.ts +189 -0
- package/extensions/transcript/renderer/tool/diff/diff-palette.ts +436 -0
- package/extensions/transcript/renderer/tool/diff/diff-parse.ts +536 -0
- package/extensions/transcript/renderer/tool/diff/diff-presentation.ts +79 -0
- package/extensions/transcript/renderer/tool/diff/diff-renderer.ts +29 -0
- package/extensions/transcript/renderer/tool/diff/diff-text.ts +57 -0
- package/extensions/transcript/renderer/tool/diff/diff-write-render.ts +629 -0
- package/extensions/transcript/renderer/tool/diff/index.ts +147 -0
- package/extensions/transcript/renderer/tool/diff/line-width-safety.ts +100 -0
- package/extensions/transcript/renderer/tool/diff/shiki-highlight.ts +104 -0
- package/extensions/transcript/renderer/tool/diff/write-execution.ts +124 -0
- package/extensions/transcript/renderer/tool/grouping.ts +619 -0
- package/extensions/transcript/renderer/tool/message-display.ts +183 -0
- package/extensions/transcript/renderer/tool/names.ts +195 -0
- package/extensions/transcript/renderer/tool/result.ts +842 -0
- package/extensions/transcript/renderer/tool/show-more-hint.ts +42 -0
- package/extensions/transcript/renderer/transcript-refresh.ts +79 -0
- package/package.json +58 -0
- package/themes/cc-dark.json +91 -0
- package/themes/cc-light.json +91 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
SessionManager,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type SessionInfo,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
Text,
|
|
9
|
+
type AutocompleteItem,
|
|
10
|
+
type AutocompleteProvider,
|
|
11
|
+
type AutocompleteSuggestions,
|
|
12
|
+
fuzzyFilter,
|
|
13
|
+
} from "@earendil-works/pi-tui";
|
|
14
|
+
import {
|
|
15
|
+
SESSION_REFERENCE_CUSTOM_TYPE,
|
|
16
|
+
SESSION_REFERENCE_PREFIX,
|
|
17
|
+
buildReferenceContentFromSections,
|
|
18
|
+
extractSessionReferenceIds,
|
|
19
|
+
formatReferenceSession,
|
|
20
|
+
sessionTitle,
|
|
21
|
+
type ReferenceSessionInfo,
|
|
22
|
+
type ReferenceSource,
|
|
23
|
+
} from "./session.ts";
|
|
24
|
+
|
|
25
|
+
const MAX_SESSION_SUGGESTIONS = 3;
|
|
26
|
+
const MAX_FILE_SUGGESTIONS = 7;
|
|
27
|
+
const MAX_REFERENCED_SESSIONS = 5;
|
|
28
|
+
const MENTION_PATTERN = /(?:^|[\t ])@([^\s@]*)$/;
|
|
29
|
+
const SUBAGENT_MANAGER_KEY = Symbol.for("pi-subagents:manager");
|
|
30
|
+
|
|
31
|
+
// ── In-process subagent record tracker ──────────────────────────────
|
|
32
|
+
// pi-subagents does not expose a global manager; we track records
|
|
33
|
+
// ourselves by listening to the events it emits.
|
|
34
|
+
|
|
35
|
+
type SubagentLiveRecord = {
|
|
36
|
+
runId: string;
|
|
37
|
+
sessionId: string;
|
|
38
|
+
agent: string;
|
|
39
|
+
cwd: string;
|
|
40
|
+
startedAt: number;
|
|
41
|
+
completedAt?: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type ReferenceDetails = {
|
|
45
|
+
sessions: Array<{ id: string; title: string; cwd: string }>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type SessionReference = {
|
|
49
|
+
kind: "session" | "subagent";
|
|
50
|
+
referenceIds: string[];
|
|
51
|
+
info: ReferenceSessionInfo;
|
|
52
|
+
path?: string;
|
|
53
|
+
messages?: unknown[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type SubagentRecord = {
|
|
57
|
+
id: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
startedAt?: number;
|
|
60
|
+
completedAt?: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type SubagentManager = {
|
|
64
|
+
getRecord(id: string): SubagentRecord | undefined;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Local subagent record tracking (pi-subagents does not expose a global manager).
|
|
68
|
+
const liveSubagentRecords = new Map<string, SubagentLiveRecord>();
|
|
69
|
+
/** Cap retained completed/live records so long-lived pi processes cannot grow unbounded. */
|
|
70
|
+
const MAX_LIVE_SUBAGENT_RECORDS = 200;
|
|
71
|
+
|
|
72
|
+
function pruneLiveSubagentRecords(): void {
|
|
73
|
+
while (liveSubagentRecords.size > MAX_LIVE_SUBAGENT_RECORDS) {
|
|
74
|
+
const oldest = liveSubagentRecords.keys().next().value;
|
|
75
|
+
if (oldest === undefined) break;
|
|
76
|
+
liveSubagentRecords.delete(oldest);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function clearLiveSubagentRecords(): void {
|
|
81
|
+
liveSubagentRecords.clear();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function trackSubagentFromEvent(data: unknown): void {
|
|
85
|
+
if (!data || typeof data !== "object") return;
|
|
86
|
+
const event = data as Record<string, unknown>;
|
|
87
|
+
// subagent:async-started payload has "id" as the run ID
|
|
88
|
+
// subagent:async-complete payload has "runId" as the run ID
|
|
89
|
+
const runId =
|
|
90
|
+
(typeof event.id === "string" ? event.id : undefined) ??
|
|
91
|
+
(typeof event.runId === "string" ? event.runId : undefined);
|
|
92
|
+
if (!runId) return;
|
|
93
|
+
const sessionId =
|
|
94
|
+
typeof event.sessionId === "string" ? event.sessionId : undefined;
|
|
95
|
+
if (!sessionId) return;
|
|
96
|
+
|
|
97
|
+
const existing = liveSubagentRecords.get(runId);
|
|
98
|
+
if (existing) {
|
|
99
|
+
// Completion event — mark completedAt
|
|
100
|
+
if (
|
|
101
|
+
typeof event.endedAt === "number" ||
|
|
102
|
+
typeof event.lastUpdate === "number"
|
|
103
|
+
) {
|
|
104
|
+
existing.completedAt = (
|
|
105
|
+
typeof event.endedAt === "number" ? event.endedAt : event.lastUpdate
|
|
106
|
+
) as number;
|
|
107
|
+
}
|
|
108
|
+
// Refresh insertion order so completed-but-still-referenced runs stay recent.
|
|
109
|
+
liveSubagentRecords.delete(runId);
|
|
110
|
+
liveSubagentRecords.set(runId, existing);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// Started event — create new record
|
|
114
|
+
const agent = typeof event.agent === "string" ? event.agent : "";
|
|
115
|
+
const cwd = typeof event.cwd === "string" ? event.cwd : "";
|
|
116
|
+
const startedAt =
|
|
117
|
+
typeof event.startedAt === "number" ? event.startedAt : Date.now();
|
|
118
|
+
liveSubagentRecords.set(runId, {
|
|
119
|
+
runId,
|
|
120
|
+
sessionId,
|
|
121
|
+
agent,
|
|
122
|
+
cwd,
|
|
123
|
+
startedAt,
|
|
124
|
+
});
|
|
125
|
+
pruneLiveSubagentRecords();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getSubagentManager(): SubagentManager | undefined {
|
|
129
|
+
// Try the global manager first (future-proof), fall back to local records.
|
|
130
|
+
const manager = (globalThis as Record<PropertyKey, unknown>)[
|
|
131
|
+
SUBAGENT_MANAGER_KEY
|
|
132
|
+
] as SubagentManager | undefined;
|
|
133
|
+
if (manager && typeof manager.getRecord === "function") return manager;
|
|
134
|
+
// If no global manager, use our local tracking.
|
|
135
|
+
if (liveSubagentRecords.size === 0) return undefined;
|
|
136
|
+
return {
|
|
137
|
+
getRecord(id: string): SubagentRecord | undefined {
|
|
138
|
+
const record = liveSubagentRecords.get(id);
|
|
139
|
+
if (!record) return undefined;
|
|
140
|
+
return {
|
|
141
|
+
id: record.runId,
|
|
142
|
+
description: record.agent || undefined,
|
|
143
|
+
startedAt: record.startedAt,
|
|
144
|
+
completedAt: record.completedAt,
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function extractMentionQuery(textBeforeCursor: string): string | undefined {
|
|
151
|
+
return textBeforeCursor.match(MENTION_PATTERN)?.[1];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function formatDate(date: Date): string {
|
|
155
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
156
|
+
year: "numeric",
|
|
157
|
+
month: "2-digit",
|
|
158
|
+
day: "2-digit",
|
|
159
|
+
}).format(date);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Stable across keystrokes while getReferences returns the same reference objects. */
|
|
163
|
+
const sessionSearchTextCache = new WeakMap<SessionReference, string>();
|
|
164
|
+
const sessionItemCache = new WeakMap<
|
|
165
|
+
SessionReference,
|
|
166
|
+
{ cwd: string; named?: AutocompleteItem; stable?: AutocompleteItem }
|
|
167
|
+
>();
|
|
168
|
+
|
|
169
|
+
function sessionSearchText(reference: SessionReference): string {
|
|
170
|
+
const cached = sessionSearchTextCache.get(reference);
|
|
171
|
+
if (cached !== undefined) return cached;
|
|
172
|
+
// 会话只按显式 session name 匹配,避免 firstMessage、路径或 ID 产生噪声。
|
|
173
|
+
const text = reference.info.name?.trim() ?? "";
|
|
174
|
+
sessionSearchTextCache.set(reference, text);
|
|
175
|
+
return text;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function sessionItem(
|
|
179
|
+
reference: SessionReference,
|
|
180
|
+
currentCwd: string,
|
|
181
|
+
useStableId: boolean,
|
|
182
|
+
): AutocompleteItem {
|
|
183
|
+
let cached = sessionItemCache.get(reference);
|
|
184
|
+
const variant = useStableId ? "stable" : "named";
|
|
185
|
+
if (cached?.cwd === currentCwd && cached[variant]) return cached[variant];
|
|
186
|
+
|
|
187
|
+
const session = reference.info;
|
|
188
|
+
const workspace = samePath(session.cwd, currentCwd)
|
|
189
|
+
? "current workspace"
|
|
190
|
+
: session.cwd || "unknown workspace";
|
|
191
|
+
const label = reference.kind === "subagent" ? "[SubAgent]" : "[Session]";
|
|
192
|
+
// 唯一名称保持短格式;同名或无名称时使用稳定 ID,避免引用歧义。
|
|
193
|
+
const sessionName = session.name?.trim();
|
|
194
|
+
const referenceId = useStableId
|
|
195
|
+
? reference.referenceIds[0]
|
|
196
|
+
: (sessionName ?? reference.referenceIds[0]);
|
|
197
|
+
const item: AutocompleteItem = {
|
|
198
|
+
value: `${SESSION_REFERENCE_PREFIX}[${referenceId}]`,
|
|
199
|
+
label: `${label} ${sessionTitle(session)}`,
|
|
200
|
+
description: `${workspace} · ${session.messageCount} messages · ${formatDate(session.modified)}`,
|
|
201
|
+
};
|
|
202
|
+
if (cached?.cwd !== currentCwd) cached = { cwd: currentCwd };
|
|
203
|
+
cached[variant] = item;
|
|
204
|
+
sessionItemCache.set(reference, cached);
|
|
205
|
+
return item;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Sort cache: same array + cwd reuses order (getReferences returns a stable ordered array). */
|
|
209
|
+
const orderedReferencesCache = new WeakMap<
|
|
210
|
+
SessionReference[],
|
|
211
|
+
{ cwd: string; ordered: SessionReference[] }
|
|
212
|
+
>();
|
|
213
|
+
|
|
214
|
+
function orderSessionReferences(
|
|
215
|
+
references: SessionReference[],
|
|
216
|
+
currentCwd: string,
|
|
217
|
+
): SessionReference[] {
|
|
218
|
+
const cached = orderedReferencesCache.get(references);
|
|
219
|
+
if (cached && cached.cwd === currentCwd) return cached.ordered;
|
|
220
|
+
|
|
221
|
+
const ordered = [...references].sort((left, right) => {
|
|
222
|
+
const leftLocal = samePath(left.info.cwd, currentCwd) ? 1 : 0;
|
|
223
|
+
const rightLocal = samePath(right.info.cwd, currentCwd) ? 1 : 0;
|
|
224
|
+
return (
|
|
225
|
+
rightLocal - leftLocal ||
|
|
226
|
+
right.info.modified.getTime() - left.info.modified.getTime()
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
orderedReferencesCache.set(references, { cwd: currentCwd, ordered });
|
|
230
|
+
// Point the ordered array at itself so subsequent keystrokes skip re-sorting.
|
|
231
|
+
orderedReferencesCache.set(ordered, { cwd: currentCwd, ordered });
|
|
232
|
+
return ordered;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function filterSessions(
|
|
236
|
+
references: SessionReference[],
|
|
237
|
+
query: string,
|
|
238
|
+
currentCwd: string,
|
|
239
|
+
): AutocompleteItem[] {
|
|
240
|
+
if (query.startsWith("session:")) return [];
|
|
241
|
+
|
|
242
|
+
const ordered = orderSessionReferences(references, currentCwd);
|
|
243
|
+
const nameCounts = new Map<string, number>();
|
|
244
|
+
for (const reference of ordered) {
|
|
245
|
+
const name = reference.info.name?.trim();
|
|
246
|
+
if (name) nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
|
|
247
|
+
}
|
|
248
|
+
const trimmed = query.trim();
|
|
249
|
+
// 模糊匹配只接受有 session name 的会话;subagent 按 agent name 匹配。
|
|
250
|
+
const searchable = ordered.filter(
|
|
251
|
+
(reference) =>
|
|
252
|
+
reference.kind === "subagent" || Boolean(reference.info.name?.trim()),
|
|
253
|
+
);
|
|
254
|
+
// Empty query: top-N already sorted — no fuzzy scan over the full list。
|
|
255
|
+
const matches = trimmed
|
|
256
|
+
? fuzzyFilter(searchable, trimmed, sessionSearchText)
|
|
257
|
+
: ordered.slice(0, MAX_SESSION_SUGGESTIONS);
|
|
258
|
+
return matches.slice(0, MAX_SESSION_SUGGESTIONS).map((reference) => {
|
|
259
|
+
const name = reference.info.name?.trim();
|
|
260
|
+
return sessionItem(
|
|
261
|
+
reference,
|
|
262
|
+
currentCwd,
|
|
263
|
+
Boolean(name && (nameCounts.get(name) ?? 0) > 1),
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function isPathLikeQuery(query: string): boolean {
|
|
269
|
+
return /[\\/*?]/.test(query) || /\.[A-Za-z0-9_-]{1,12}$/.test(query);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function mergeSessionAndFileItems(
|
|
273
|
+
sessionItems: AutocompleteItem[],
|
|
274
|
+
fileItems: AutocompleteItem[],
|
|
275
|
+
query: string,
|
|
276
|
+
): AutocompleteItem[] {
|
|
277
|
+
const sessions = sessionItems.slice(0, MAX_SESSION_SUGGESTIONS);
|
|
278
|
+
const files = fileItems.slice(0, MAX_FILE_SUGGESTIONS);
|
|
279
|
+
if (isPathLikeQuery(query)) return [...files, ...sessions];
|
|
280
|
+
|
|
281
|
+
const merged: AutocompleteItem[] = [];
|
|
282
|
+
let sessionIndex = 0;
|
|
283
|
+
let fileIndex = 0;
|
|
284
|
+
while (sessionIndex < sessions.length || fileIndex < files.length) {
|
|
285
|
+
for (let count = 0; count < 2 && fileIndex < files.length; count++) {
|
|
286
|
+
merged.push(files[fileIndex++]!);
|
|
287
|
+
}
|
|
288
|
+
if (sessionIndex < sessions.length) merged.push(sessions[sessionIndex++]!);
|
|
289
|
+
}
|
|
290
|
+
return merged;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function liveSubagentReferences(
|
|
294
|
+
agentIds: Set<string>,
|
|
295
|
+
currentSessionId: string,
|
|
296
|
+
): SessionReference[] {
|
|
297
|
+
const manager = getSubagentManager();
|
|
298
|
+
if (!manager) return [];
|
|
299
|
+
|
|
300
|
+
const references: SessionReference[] = [];
|
|
301
|
+
for (const agentId of agentIds) {
|
|
302
|
+
const record = manager.getRecord(agentId);
|
|
303
|
+
if (!record) continue;
|
|
304
|
+
|
|
305
|
+
// Try the global manager's live session first (future-proof).
|
|
306
|
+
const liveRecord = liveSubagentRecords.get(agentId);
|
|
307
|
+
const sessionId = liveRecord?.sessionId ?? record.id;
|
|
308
|
+
if (!sessionId || sessionId === currentSessionId) continue;
|
|
309
|
+
|
|
310
|
+
const name = record.description?.trim() || liveRecord?.agent || undefined;
|
|
311
|
+
const modifiedAt = record.completedAt ?? record.startedAt ?? Date.now();
|
|
312
|
+
references.push({
|
|
313
|
+
kind: "subagent",
|
|
314
|
+
referenceIds: [sessionId, agentId],
|
|
315
|
+
info: {
|
|
316
|
+
id: sessionId,
|
|
317
|
+
name,
|
|
318
|
+
cwd: liveRecord?.cwd ?? "",
|
|
319
|
+
firstMessage: "",
|
|
320
|
+
messageCount: 0,
|
|
321
|
+
modified: new Date(modifiedAt),
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return references;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function mergeReferences(
|
|
329
|
+
sessions: SessionInfo[],
|
|
330
|
+
subagents: SessionReference[],
|
|
331
|
+
): SessionReference[] {
|
|
332
|
+
const bySessionId = new Map<string, SessionReference>();
|
|
333
|
+
for (const session of sessions) {
|
|
334
|
+
bySessionId.set(session.id, {
|
|
335
|
+
kind: "session",
|
|
336
|
+
referenceIds: [session.id],
|
|
337
|
+
info: session,
|
|
338
|
+
path: session.path,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
for (const subagent of subagents) {
|
|
342
|
+
// Prefer the live subagent context when a persisted subagent has the same ID.
|
|
343
|
+
bySessionId.set(subagent.info.id, subagent);
|
|
344
|
+
}
|
|
345
|
+
return [...bySessionId.values()];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function createAutocompleteProvider(
|
|
349
|
+
current: AutocompleteProvider,
|
|
350
|
+
getReferences: () => Promise<SessionReference[]>,
|
|
351
|
+
currentCwd: string,
|
|
352
|
+
): AutocompleteProvider {
|
|
353
|
+
return {
|
|
354
|
+
triggerCharacters: ["@"],
|
|
355
|
+
async getSuggestions(
|
|
356
|
+
lines,
|
|
357
|
+
cursorLine,
|
|
358
|
+
cursorCol,
|
|
359
|
+
options,
|
|
360
|
+
): Promise<AutocompleteSuggestions | null> {
|
|
361
|
+
const currentLine = lines[cursorLine] ?? "";
|
|
362
|
+
const query = extractMentionQuery(currentLine.slice(0, cursorCol));
|
|
363
|
+
if (query === undefined) {
|
|
364
|
+
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const [baseSuggestions, references] = await Promise.all([
|
|
368
|
+
current.getSuggestions(lines, cursorLine, cursorCol, options),
|
|
369
|
+
getReferences(),
|
|
370
|
+
]);
|
|
371
|
+
if (options.signal.aborted) return null;
|
|
372
|
+
|
|
373
|
+
const sessionItems = filterSessions(references, query, currentCwd);
|
|
374
|
+
const fileItems =
|
|
375
|
+
baseSuggestions?.prefix === `@${query}` ? baseSuggestions.items : [];
|
|
376
|
+
const items = mergeSessionAndFileItems(sessionItems, fileItems, query);
|
|
377
|
+
if (items.length === 0) return baseSuggestions;
|
|
378
|
+
return { prefix: `@${query}`, items };
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
382
|
+
return current.applyCompletion(
|
|
383
|
+
lines,
|
|
384
|
+
cursorLine,
|
|
385
|
+
cursorCol,
|
|
386
|
+
item,
|
|
387
|
+
prefix,
|
|
388
|
+
);
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
392
|
+
return (
|
|
393
|
+
current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
|
|
394
|
+
true
|
|
395
|
+
);
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function samePath(left: string | undefined, right: string): boolean {
|
|
401
|
+
if (!left) return false;
|
|
402
|
+
const normalizedLeft = resolve(left);
|
|
403
|
+
const normalizedRight = resolve(right);
|
|
404
|
+
return process.platform === "win32"
|
|
405
|
+
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
|
406
|
+
: normalizedLeft === normalizedRight;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export default function sessionReferenceExtension(pi: ExtensionAPI): void {
|
|
410
|
+
let getAvailableReferences: (() => Promise<SessionReference[]>) | undefined;
|
|
411
|
+
let sessionGeneration = 0;
|
|
412
|
+
const subagentIds = new Set<string>();
|
|
413
|
+
// pi-subagents emits "subagent:async-started" and "subagent:async-complete" events.
|
|
414
|
+
// We track records locally so @[SubAgent] suggestions work even without a global manager.
|
|
415
|
+
const subagentEventNames = [
|
|
416
|
+
"subagent:async-started",
|
|
417
|
+
"subagent:async-complete",
|
|
418
|
+
];
|
|
419
|
+
const unsubscribeSubagentEvents = subagentEventNames.map((eventName) =>
|
|
420
|
+
pi.events.on(eventName, (data) => {
|
|
421
|
+
trackSubagentFromEvent(data);
|
|
422
|
+
if (!data || typeof data !== "object") return;
|
|
423
|
+
const payload = data as Record<string, unknown>;
|
|
424
|
+
// subagent:async-started uses "id", subagent:async-complete uses "runId"
|
|
425
|
+
const id =
|
|
426
|
+
(typeof payload.id === "string" ? payload.id : undefined) ??
|
|
427
|
+
(typeof payload.runId === "string" ? payload.runId : undefined);
|
|
428
|
+
if (typeof id === "string" && id) subagentIds.add(id);
|
|
429
|
+
}),
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
pi.registerMessageRenderer(
|
|
433
|
+
SESSION_REFERENCE_CUSTOM_TYPE,
|
|
434
|
+
(message, _options, theme) => {
|
|
435
|
+
const details = message.details as ReferenceDetails | undefined;
|
|
436
|
+
const sessions = details?.sessions ?? [];
|
|
437
|
+
const labels = sessions.map((session) => session.title).join(", ");
|
|
438
|
+
const summary =
|
|
439
|
+
sessions.length === 1
|
|
440
|
+
? "Referenced 1 session"
|
|
441
|
+
: `Referenced ${sessions.length} sessions`;
|
|
442
|
+
const text = labels
|
|
443
|
+
? `${theme.fg("accent", summary)}\n${theme.fg("dim", labels)}`
|
|
444
|
+
: summary;
|
|
445
|
+
return new Text(text, 1, 0);
|
|
446
|
+
},
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
pi.on("session_start", (_event, ctx) => {
|
|
450
|
+
const generation = ++sessionGeneration;
|
|
451
|
+
// 会话替换/reload 会让 ctx 的 getter 抛 stale 错误,await 之后不能再读;
|
|
452
|
+
// 这里在同步阶段一次性取出纯值。
|
|
453
|
+
const currentCwd = ctx.cwd;
|
|
454
|
+
const ui = ctx.ui;
|
|
455
|
+
subagentIds.clear();
|
|
456
|
+
clearLiveSubagentRecords();
|
|
457
|
+
let loadErrorShown = false;
|
|
458
|
+
const currentSessionId = ctx.sessionManager.getSessionId();
|
|
459
|
+
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
460
|
+
let sessionsPromise: Promise<SessionInfo[]> | undefined;
|
|
461
|
+
|
|
462
|
+
const getSessions = (): Promise<SessionInfo[]> => {
|
|
463
|
+
sessionsPromise ||= SessionManager.listAll()
|
|
464
|
+
.then((sessions) =>
|
|
465
|
+
sessions.filter(
|
|
466
|
+
(session) =>
|
|
467
|
+
session.id !== currentSessionId &&
|
|
468
|
+
!samePath(currentSessionFile, session.path),
|
|
469
|
+
),
|
|
470
|
+
)
|
|
471
|
+
.catch((error: unknown) => {
|
|
472
|
+
if (!loadErrorShown) {
|
|
473
|
+
loadErrorShown = true;
|
|
474
|
+
const reason =
|
|
475
|
+
error instanceof Error ? error.message : String(error);
|
|
476
|
+
ui.notify(
|
|
477
|
+
`session-reference: failed to load sessions: ${reason}`,
|
|
478
|
+
"error",
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
return [];
|
|
482
|
+
});
|
|
483
|
+
return sessionsPromise;
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
// Pre-sort once per (sessions list, subagent id set) so @ keystrokes only fuzzy-filter.
|
|
487
|
+
let referencesCache:
|
|
488
|
+
| {
|
|
489
|
+
sessions: SessionInfo[];
|
|
490
|
+
subagentKey: string;
|
|
491
|
+
ordered: SessionReference[];
|
|
492
|
+
}
|
|
493
|
+
| undefined;
|
|
494
|
+
const getReferences = async (): Promise<SessionReference[]> => {
|
|
495
|
+
const sessions = await getSessions();
|
|
496
|
+
// 会话替换/reload 后丢弃旧 generation 的结果,避免对失效状态继续工作。
|
|
497
|
+
if (generation !== sessionGeneration) return [];
|
|
498
|
+
const subagentKey = [...subagentIds].join("\0");
|
|
499
|
+
if (
|
|
500
|
+
referencesCache &&
|
|
501
|
+
referencesCache.sessions === sessions &&
|
|
502
|
+
referencesCache.subagentKey === subagentKey
|
|
503
|
+
) {
|
|
504
|
+
return referencesCache.ordered;
|
|
505
|
+
}
|
|
506
|
+
const ordered = orderSessionReferences(
|
|
507
|
+
mergeReferences(
|
|
508
|
+
sessions,
|
|
509
|
+
liveSubagentReferences(subagentIds, currentSessionId),
|
|
510
|
+
),
|
|
511
|
+
currentCwd,
|
|
512
|
+
);
|
|
513
|
+
referencesCache = { sessions, subagentKey, ordered };
|
|
514
|
+
return ordered;
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
getAvailableReferences = getReferences;
|
|
518
|
+
if (ctx.mode === "tui") {
|
|
519
|
+
// 预取是 detached 的,必须兜住 rejection,否则 stale 错误会变成
|
|
520
|
+
// unhandled rejection 直接终止 Pi。
|
|
521
|
+
void getReferences().catch(() => {});
|
|
522
|
+
// Register after other session_start handlers. pi-fff claims every @
|
|
523
|
+
// prefix, so a provider installed before it would never see session mentions.
|
|
524
|
+
setTimeout(() => {
|
|
525
|
+
if (generation !== sessionGeneration) return;
|
|
526
|
+
ui.addAutocompleteProvider((current) =>
|
|
527
|
+
createAutocompleteProvider(current, getReferences, currentCwd),
|
|
528
|
+
);
|
|
529
|
+
}, 0);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
534
|
+
const referenceIds = extractSessionReferenceIds(event.prompt);
|
|
535
|
+
if (referenceIds.length === 0) return;
|
|
536
|
+
|
|
537
|
+
const currentSessionId = ctx.sessionManager.getSessionId();
|
|
538
|
+
const references = await (getAvailableReferences?.() ??
|
|
539
|
+
SessionManager.listAll().then((sessions) =>
|
|
540
|
+
mergeReferences(
|
|
541
|
+
sessions.filter((session) => session.id !== currentSessionId),
|
|
542
|
+
liveSubagentReferences(subagentIds, currentSessionId),
|
|
543
|
+
),
|
|
544
|
+
));
|
|
545
|
+
const referencesById = new Map<string, SessionReference>();
|
|
546
|
+
const referencesByName = new Map<string, SessionReference>();
|
|
547
|
+
const ambiguousNames = new Set<string>();
|
|
548
|
+
for (const reference of references) {
|
|
549
|
+
for (const id of reference.referenceIds)
|
|
550
|
+
referencesById.set(id, reference);
|
|
551
|
+
const name = reference.info.name?.trim();
|
|
552
|
+
if (!name || ambiguousNames.has(name)) continue;
|
|
553
|
+
if (referencesByName.has(name)) {
|
|
554
|
+
referencesByName.delete(name);
|
|
555
|
+
ambiguousNames.add(name);
|
|
556
|
+
} else {
|
|
557
|
+
referencesByName.set(name, reference);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const seenReferences = new Set<SessionReference>();
|
|
561
|
+
const matchingReferences = referenceIds
|
|
562
|
+
.map((id) => referencesById.get(id) ?? referencesByName.get(id))
|
|
563
|
+
.filter((reference): reference is SessionReference => {
|
|
564
|
+
if (
|
|
565
|
+
!reference ||
|
|
566
|
+
reference.info.id === currentSessionId ||
|
|
567
|
+
seenReferences.has(reference)
|
|
568
|
+
)
|
|
569
|
+
return false;
|
|
570
|
+
seenReferences.add(reference);
|
|
571
|
+
return true;
|
|
572
|
+
});
|
|
573
|
+
const selected = matchingReferences.slice(0, MAX_REFERENCED_SESSIONS);
|
|
574
|
+
|
|
575
|
+
if (selected.length === 0) {
|
|
576
|
+
ctx.ui.notify(
|
|
577
|
+
"session-reference: referenced sessions were not found",
|
|
578
|
+
"warning",
|
|
579
|
+
);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (matchingReferences.length > MAX_REFERENCED_SESSIONS) {
|
|
584
|
+
ctx.ui.notify(
|
|
585
|
+
`session-reference: only the first ${MAX_REFERENCED_SESSIONS} sessions were included`,
|
|
586
|
+
"warning",
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const sections: string[] = [];
|
|
591
|
+
const referencedSessions: ReferenceDetails["sessions"] = [];
|
|
592
|
+
for (const reference of selected) {
|
|
593
|
+
try {
|
|
594
|
+
const info = reference.info;
|
|
595
|
+
const messages =
|
|
596
|
+
reference.messages ??
|
|
597
|
+
(reference.path
|
|
598
|
+
? SessionManager.open(reference.path).buildSessionContext().messages
|
|
599
|
+
: (() => {
|
|
600
|
+
throw new Error("reference session is no longer available");
|
|
601
|
+
})());
|
|
602
|
+
const source: ReferenceSource = { info, messages };
|
|
603
|
+
sections.push(formatReferenceSession(source));
|
|
604
|
+
referencedSessions.push({
|
|
605
|
+
id: info.id,
|
|
606
|
+
title: sessionTitle(info),
|
|
607
|
+
cwd: info.cwd,
|
|
608
|
+
});
|
|
609
|
+
} catch (error) {
|
|
610
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
611
|
+
ctx.ui.notify(
|
|
612
|
+
`session-reference: failed to read ${reference.info.id}: ${reason}`,
|
|
613
|
+
"warning",
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (sections.length === 0) return;
|
|
618
|
+
|
|
619
|
+
const details: ReferenceDetails = { sessions: referencedSessions };
|
|
620
|
+
return {
|
|
621
|
+
message: {
|
|
622
|
+
customType: SESSION_REFERENCE_CUSTOM_TYPE,
|
|
623
|
+
content: buildReferenceContentFromSections(sections),
|
|
624
|
+
display: true,
|
|
625
|
+
details,
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
pi.on("session_before_switch", () => {
|
|
631
|
+
subagentIds.clear();
|
|
632
|
+
clearLiveSubagentRecords();
|
|
633
|
+
getAvailableReferences = undefined;
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
pi.on("session_shutdown", () => {
|
|
637
|
+
sessionGeneration++;
|
|
638
|
+
for (const unsubscribe of unsubscribeSubagentEvents) unsubscribe();
|
|
639
|
+
subagentIds.clear();
|
|
640
|
+
clearLiveSubagentRecords();
|
|
641
|
+
getAvailableReferences = undefined;
|
|
642
|
+
});
|
|
643
|
+
}
|