pi-ui-extension 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +43 -0
  2. package/extensions/ctx/README.md +35 -0
  3. package/extensions/ctx/activity.ts +226 -0
  4. package/extensions/ctx/data.test.ts +65 -0
  5. package/extensions/ctx/data.ts +230 -0
  6. package/extensions/ctx/panel.test.ts +103 -0
  7. package/extensions/ctx/panel.ts +185 -0
  8. package/extensions/ctx/tools.test.ts +64 -0
  9. package/extensions/fabric-detection.test.ts +15 -0
  10. package/extensions/fabric-detection.ts +7 -0
  11. package/extensions/footer/index.ts +62 -0
  12. package/extensions/index.ts +577 -0
  13. package/extensions/mcp/integration.test.ts +64 -0
  14. package/extensions/mcp/resources.test.ts +27 -0
  15. package/extensions/mcp/resources.ts +31 -0
  16. package/extensions/resource-dialog.test.ts +45 -0
  17. package/extensions/session/mutations.test.ts +30 -0
  18. package/extensions/session/mutations.ts +17 -0
  19. package/extensions/session/panel.test.ts +238 -0
  20. package/extensions/session/panel.ts +461 -0
  21. package/extensions/sidebar/default-panels.test.ts +97 -0
  22. package/extensions/sidebar/default-panels.ts +155 -0
  23. package/extensions/sidebar/index.ts +212 -0
  24. package/extensions/sidebar/layout.test.ts +68 -0
  25. package/extensions/sidebar/layout.ts +75 -0
  26. package/extensions/sidebar/panel.ts +49 -0
  27. package/extensions/sidebar/registry.ts +35 -0
  28. package/extensions/sidebar/resize.test.ts +71 -0
  29. package/extensions/sidebar/resize.ts +154 -0
  30. package/extensions/tool-view/controller.test.ts +57 -0
  31. package/extensions/tool-view/controller.ts +519 -0
  32. package/extensions/tool-view/panel.test.ts +73 -0
  33. package/extensions/tool-view/panel.ts +221 -0
  34. package/extensions/tool-view/trust.test.ts +41 -0
  35. package/extensions/tool-view/types.ts +6 -0
  36. package/extensions/tool-view/usage.test.ts +231 -0
  37. package/extensions/tool-view/usage.ts +303 -0
  38. package/extensions/tool-view/wheel.test.ts +32 -0
  39. package/package.json +53 -0
@@ -0,0 +1,577 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import {
4
+ DefaultPackageManager,
5
+ estimateTokens,
6
+ getAgentDir,
7
+ loadProjectContextFiles,
8
+ SettingsManager,
9
+ SessionManager,
10
+ type ExtensionAPI,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import { installContextEstimates } from "./ctx/data.ts";
13
+ import { createActivityTracker, type ActivityTracker } from "./ctx/activity.ts";
14
+ import { installFooter, type FooterController } from "./footer/index.ts";
15
+ import { isFabricFullCodeMode } from "./fabric-detection.ts";
16
+ import { mcpResourceEntriesFromStatus } from "./mcp/resources.ts";
17
+ import {
18
+ createDefaultSidebarPanels,
19
+ SESSION_PANEL_ID,
20
+ } from "./sidebar/default-panels.ts";
21
+ import type { ResourceButtonId } from "./session/panel.ts";
22
+ import { installSidebar, type SidebarController } from "./sidebar/index.ts";
23
+ import { deleteSessionFile } from "./session/mutations.ts";
24
+ import type { SessionListItem } from "./session/panel.ts";
25
+ import { installToolViewController } from "./tool-view/controller.ts";
26
+ import type { ToolViewMode } from "./tool-view/types.ts";
27
+ import {
28
+ createToolUsageTracker,
29
+ type ToolUsageTracker,
30
+ } from "./tool-view/usage.ts";
31
+
32
+ const SESSION_SWITCH_COMMAND = "sidebar-session-switch";
33
+ const SESSION_RENAME_COMMAND = "sidebar-session-rename";
34
+ const SESSION_DELETE_COMMAND = "sidebar-session-delete";
35
+ const SESSION_CLEAR_COMMAND = "sidebar-session-clear-others";
36
+
37
+ export type ResourceEntry = { name: string; description: string; content?: string };
38
+
39
+ /** Format every resource row for the scrollable resource picker. */
40
+ export function resourceDialogOptions(rows: readonly ResourceEntry[]): string[] {
41
+ return rows.map((row) => {
42
+ const desc = row.description.length > 110 ? `${row.description.slice(0, 109)}…` : row.description;
43
+ return `${row.name} — ${desc}`;
44
+ });
45
+ }
46
+
47
+ /** Description from the nearest ancestor package.json (bounded at agentDir). */
48
+ function packageSummary(extensionPath: string, agentDir: string): string | undefined {
49
+ try {
50
+ const stop = resolve(agentDir);
51
+ let dir = dirname(resolve(extensionPath));
52
+ for (let guard = 0; guard < 8; guard++) {
53
+ if (dir !== stop && !dir.startsWith(stop + "/")) break;
54
+ const pkgPath = join(dir, "package.json");
55
+ if (existsSync(pkgPath)) {
56
+ const parsed = JSON.parse(readFileSync(pkgPath, "utf8")) as { description?: string };
57
+ if (typeof parsed.description === "string" && parsed.description.trim()) {
58
+ return parsed.description.trim();
59
+ }
60
+ }
61
+ const parent = dirname(dir);
62
+ if (parent === dir || dir === stop) break;
63
+ dir = parent;
64
+ }
65
+ } catch { /* unreadable — fall through */ }
66
+ return undefined;
67
+ }
68
+
69
+ /**
70
+ * Show a modal picker dialog listing resource rows. Long descriptions show a
71
+ * wide preview; selecting a row opens a second dialog with the full content.
72
+ * When `content` is given (single AGENTS.md), the full file content is shown
73
+ * directly in a scrollable editor dialog (Esc closes, nothing is written back).
74
+ */
75
+ function showResourceDialog(
76
+ ctx: { ui: { select(title: string, options: string[]): Promise<string | undefined>; editor(title: string, prefill?: string): Promise<string | undefined> } },
77
+ button: ResourceButtonId,
78
+ rows: readonly ResourceEntry[],
79
+ content?: string,
80
+ ): void {
81
+ if (content !== undefined) {
82
+ void ctx.ui.editor("AGENTS.md (read-only · Esc to close)", content).then(() => {});
83
+ return;
84
+ }
85
+ const title = button === "skills"
86
+ ? "Skills"
87
+ : button === "extensions"
88
+ ? "Extensions"
89
+ : button === "mcp"
90
+ ? "MCP"
91
+ : "Context";
92
+ const visible = resourceDialogOptions(rows);
93
+ void ctx.ui.select(
94
+ `${title} (${rows.length}) · ↵ full description`,
95
+ visible,
96
+ ).then((choice) => {
97
+ if (!choice) return;
98
+ const index = visible.indexOf(choice);
99
+ const row = index >= 0 ? rows[index] : undefined;
100
+ if (row) {
101
+ void ctx.ui.editor(`${row.name} (full description · Esc to close)`, row.description).then(() => {});
102
+ }
103
+ });
104
+ }
105
+
106
+ /** Load Agents.md contexts, Skills, and Extensions as display rows. */
107
+ export async function loadResourceEntries(
108
+ pi: ExtensionAPI,
109
+ cwd: string,
110
+ mcp: readonly ResourceEntry[],
111
+ projectTrusted: boolean,
112
+ button: ResourceButtonId,
113
+ ): Promise<readonly ResourceEntry[]> {
114
+ if (button === "mcp") return mcp;
115
+ if (button === "skills") return pi.getCommands()
116
+ .filter((command) => command.source === "skill")
117
+ .map((command) => ({
118
+ name: command.name.replace(/^skill:/, ""),
119
+ description: command.description?.replace(/\s+/g, " ").trim() || "Skill command",
120
+ }));
121
+ const agentDir = getAgentDir();
122
+ if (button === "agents") return loadProjectContextFiles({ cwd, agentDir }).map((file) => ({
123
+ name: basename(file.path),
124
+ description: file.content.split(/\r?\n/).map(line => line.trim()).find(line => line && !line.startsWith("#")) ?? "Context file",
125
+ content: file.content,
126
+ }));
127
+
128
+ const settingsManager = SettingsManager.create(cwd, agentDir, {
129
+ projectTrusted,
130
+ });
131
+ const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
132
+ const resolved = await packageManager.resolve(async () => "skip");
133
+ const byPath = new Map<string, { path: string; source: string; scope: string }>();
134
+ for (const extension of resolved.extensions.filter((item) => item.enabled)) {
135
+ byPath.set(extension.path, {
136
+ path: extension.path,
137
+ source: extension.metadata.source,
138
+ scope: extension.metadata.scope,
139
+ });
140
+ }
141
+ for (const tool of pi.getAllTools()) {
142
+ const info = tool.sourceInfo;
143
+ if (existsSync(info.path) && /\.[cm]?[jt]s$/.test(info.path)) {
144
+ byPath.set(info.path, { path: info.path, source: info.source, scope: info.scope });
145
+ }
146
+ }
147
+
148
+ const extensions: ResourceEntry[] = [...byPath.values()].map((info) => ({
149
+ name: info.source && info.source !== "local" && info.source !== "cli"
150
+ ? info.source.replace(/^(npm:|git:)/, "").split("/").pop()!
151
+ : basename(dirname(info.path)) === "extensions" || basename(dirname(info.path)) === "dist"
152
+ ? basename(dirname(dirname(info.path)))
153
+ : basename(info.path).replace(/\.(?:[cm]?[jt]s)$/, ""),
154
+ description: packageSummary(info.path, agentDir)
155
+ ?? (info.source && info.source !== "local" && info.source !== "cli"
156
+ ? `package ${info.source.replace(/^(npm:|git:)/, "")}`
157
+ : info.scope),
158
+ }));
159
+
160
+ return extensions;
161
+ }
162
+
163
+ /**
164
+ * Move every saved session except `keepId` to trash. Recoverable deletion only,
165
+ * and one failure never stops the remaining sessions from being cleared.
166
+ */
167
+ async function clearOtherSessions(
168
+ pi: ExtensionAPI,
169
+ cwd: string,
170
+ sessionDir: string,
171
+ keepId: string,
172
+ ): Promise<{ readonly deleted: number; readonly failed: number; readonly firstError?: string }> {
173
+ const sessions = await SessionManager.list(cwd, sessionDir);
174
+ const targets = sessions.filter((session) => session.id !== keepId);
175
+ let deleted = 0;
176
+ let firstError: string | undefined;
177
+ for (const target of targets) {
178
+ const result = await deleteSessionFile(pi, target.path);
179
+ if (result.ok) deleted++;
180
+ else firstError ??= result.error;
181
+ }
182
+ return { deleted, failed: targets.length - deleted, ...(firstError === undefined ? {} : { firstError }) };
183
+ }
184
+
185
+ export default async function piUiExtension(pi: ExtensionAPI): Promise<void> {
186
+ const toolView = await installToolViewController(pi);
187
+ let mcpResources: readonly ResourceEntry[] = [];
188
+ let toolViewMode: ToolViewMode | undefined;
189
+ let footer: FooterController | undefined;
190
+ let sidebar: SidebarController | undefined;
191
+ let activity: ActivityTracker | undefined;
192
+ let toolUsage: ToolUsageTracker | undefined;
193
+ let getCurrentBranch: (() => readonly unknown[]) | undefined;
194
+ let cacheInputTokens = 0;
195
+ let cacheReadTokens = 0;
196
+ let cacheWriteTokens = 0;
197
+ const cacheHitRate = (): number => {
198
+ const promptTokens = cacheInputTokens + cacheReadTokens + cacheWriteTokens;
199
+ return promptTokens === 0 ? 0 : cacheReadTokens / promptTokens * 100;
200
+ };
201
+ const addCacheUsage = (usage: unknown): void => {
202
+ if (!usage || typeof usage !== "object") return;
203
+ const values = usage as { input?: unknown; cacheRead?: unknown; cacheWrite?: unknown };
204
+ const number = (value: unknown): number => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
205
+ cacheInputTokens += number(values.input);
206
+ cacheReadTokens += number(values.cacheRead);
207
+ cacheWriteTokens += number(values.cacheWrite);
208
+ };
209
+ const resetCacheUsage = (branch: readonly unknown[]): void => {
210
+ cacheInputTokens = 0;
211
+ cacheReadTokens = 0;
212
+ cacheWriteTokens = 0;
213
+ for (const entry of branch) {
214
+ if (!entry || typeof entry !== "object") continue;
215
+ const typed = entry as { type?: unknown; message?: { usage?: unknown } };
216
+ if (typed.type === "message") addCacheUsage(typed.message?.usage);
217
+ }
218
+ };
219
+ let switchRequested = false;
220
+ const getMcpServerNames = () => mcpResources.map((server) => server.name);
221
+ const unsubscribeMcpStatus = pi.events.on("pi-mcp-adapter/status/v1", (value) => {
222
+ mcpResources = mcpResourceEntriesFromStatus(value);
223
+ const branch = getCurrentBranch?.();
224
+ if (branch) toolUsage?.resetSession(branch, getMcpServerNames(), { preserveExchange: true });
225
+ sidebar?.refresh();
226
+ });
227
+
228
+ pi.registerCommand(SESSION_RENAME_COMMAND, {
229
+ description: "Rename a saved session selected from the sidebar",
230
+ handler: async (args, ctx) => {
231
+ const sessionId = args.trim();
232
+ if (!sessionId) {
233
+ ctx.ui.notify("Missing session id", "error");
234
+ return;
235
+ }
236
+
237
+ try {
238
+ const sessions = await SessionManager.list(ctx.cwd, ctx.sessionManager.getSessionDir());
239
+ const target = sessions.find((session) => session.id === sessionId);
240
+ if (!target) {
241
+ ctx.ui.notify("Session no longer exists", "error");
242
+ refreshSessions();
243
+ return;
244
+ }
245
+ const title = target.name ?? target.firstMessage;
246
+ const nextName = await ctx.ui.input("Rename session", title);
247
+ const next = nextName?.trim();
248
+ if (!next) return;
249
+
250
+ if (target.id === ctx.sessionManager.getSessionId()) {
251
+ pi.setSessionName(next);
252
+ } else {
253
+ SessionManager.open(target.path).appendSessionInfo(next);
254
+ refreshSessions();
255
+ }
256
+ ctx.ui.notify("Session renamed", "info");
257
+ } catch (error) {
258
+ const message = error instanceof Error ? error.message : String(error);
259
+ ctx.ui.notify(`Failed to rename session: ${message}`, "error");
260
+ }
261
+ },
262
+ });
263
+
264
+ pi.registerCommand(SESSION_DELETE_COMMAND, {
265
+ description: "Delete a saved session selected from the sidebar",
266
+ handler: async (args, ctx) => {
267
+ const sessionId = args.trim();
268
+ if (!sessionId) {
269
+ ctx.ui.notify("Missing session id", "error");
270
+ return;
271
+ }
272
+ if (sessionId === ctx.sessionManager.getSessionId()) {
273
+ ctx.ui.notify("Cannot delete the currently active session", "warning");
274
+ return;
275
+ }
276
+
277
+ try {
278
+ const sessions = await SessionManager.list(ctx.cwd, ctx.sessionManager.getSessionDir());
279
+ const target = sessions.find((session) => session.id === sessionId);
280
+ if (!target) {
281
+ ctx.ui.notify("Session no longer exists", "error");
282
+ refreshSessions();
283
+ return;
284
+ }
285
+ const result = await deleteSessionFile(pi, target.path);
286
+ if (!result.ok) {
287
+ ctx.ui.notify(`Failed to delete session: ${result.error}`, "error");
288
+ return;
289
+ }
290
+ refreshSessions();
291
+ ctx.ui.notify("Session moved to trash", "info");
292
+ } catch (error) {
293
+ const message = error instanceof Error ? error.message : String(error);
294
+ ctx.ui.notify(`Failed to delete session: ${message}`, "error");
295
+ }
296
+ },
297
+ });
298
+
299
+ pi.registerCommand(SESSION_CLEAR_COMMAND, {
300
+ description: "Move every saved session except the active one to trash",
301
+ handler: async (_args, ctx) => {
302
+ try {
303
+ const currentSessionId = ctx.sessionManager.getSessionId();
304
+ const sessions = await SessionManager.list(ctx.cwd, ctx.sessionManager.getSessionDir());
305
+ const others = sessions.filter((session) => session.id !== currentSessionId);
306
+ if (others.length === 0) {
307
+ ctx.ui.notify("No other sessions to clear", "info");
308
+ return;
309
+ }
310
+ const confirmed = await ctx.ui.confirm(
311
+ "Clear other sessions",
312
+ `Move ${others.length} other session(s) to trash? The active session is kept.`,
313
+ );
314
+ if (!confirmed) return;
315
+
316
+ const result = await clearOtherSessions(pi, ctx.cwd, ctx.sessionManager.getSessionDir(), currentSessionId);
317
+ refreshSessions();
318
+ if (result.failed > 0) {
319
+ ctx.ui.notify(
320
+ `Cleared ${result.deleted} session(s); ${result.failed} failed: ${result.firstError ?? "unknown error"}`,
321
+ "error",
322
+ );
323
+ return;
324
+ }
325
+ ctx.ui.notify(`Cleared ${result.deleted} session(s) to trash`, "info");
326
+ } catch (error) {
327
+ const message = error instanceof Error ? error.message : String(error);
328
+ ctx.ui.notify(`Failed to clear sessions: ${message}`, "error");
329
+ }
330
+ },
331
+ });
332
+
333
+ pi.registerCommand(SESSION_SWITCH_COMMAND, {
334
+ description: "Switch to a saved session selected from the sidebar",
335
+ handler: async (args, ctx) => {
336
+ const sessionId = args.trim();
337
+ if (!sessionId) {
338
+ switchRequested = false;
339
+ ctx.ui.notify("Missing session id", "error");
340
+ return;
341
+ }
342
+
343
+ try {
344
+ const currentSessionId = ctx.sessionManager.getSessionId();
345
+ if (sessionId === currentSessionId) {
346
+ switchRequested = false;
347
+ return;
348
+ }
349
+
350
+ const sessions = await SessionManager.list(ctx.cwd, ctx.sessionManager.getSessionDir());
351
+ const target = sessions.find((session) => session.id === sessionId);
352
+ if (!target) {
353
+ switchRequested = false;
354
+ ctx.ui.notify("Session no longer exists", "error");
355
+ refreshSessions();
356
+ return;
357
+ }
358
+
359
+ const result = await ctx.switchSession(target.path);
360
+ // Do not use the old command ctx after a successful replacement.
361
+ if (!result.cancelled) return;
362
+ switchRequested = false;
363
+ ctx.ui.notify("Session switch cancelled", "info");
364
+ } catch (error) {
365
+ switchRequested = false;
366
+ const message = error instanceof Error ? error.message : String(error);
367
+ ctx.ui.notify(`Failed to switch session: ${message}`, "error");
368
+ }
369
+ },
370
+ });
371
+
372
+ const unsubscribeToolViewMode = toolView.onModeChange((mode) => {
373
+ toolViewMode = mode;
374
+ sidebar?.refresh();
375
+ });
376
+
377
+ const refreshContextUi = () => {
378
+ footer?.refresh();
379
+ // CTX reads the activity snapshot during render, so refresh rather than
380
+ // updatePanel — the snapshot is not a panel state payload anymore.
381
+ sidebar?.refresh();
382
+ };
383
+ const contextEstimates = installContextEstimates(pi, refreshContextUi);
384
+ const refreshSessions = () => {
385
+ sidebar?.updatePanel(SESSION_PANEL_ID, undefined);
386
+ };
387
+ pi.on("before_agent_start", () => {
388
+ contextEstimates.resetDelta();
389
+ toolUsage?.startExchange();
390
+ sidebar?.refresh();
391
+ });
392
+ pi.on("agent_start", () => {
393
+ activity?.startRun();
394
+ refreshContextUi();
395
+ });
396
+ pi.on("turn_start", (event) => {
397
+ activity?.startTurn(event.turnIndex);
398
+ sidebar?.refresh();
399
+ });
400
+ pi.on("before_provider_request", () => activity?.startResponse());
401
+ pi.on("message_update", (event) => {
402
+ const estimatedOutputTokens = estimateTokens(event.message);
403
+ if (estimatedOutputTokens > 0) activity?.updateResponseEstimate(estimatedOutputTokens);
404
+ });
405
+ pi.on("message_end", (event) => {
406
+ if (event.message.role === "assistant") {
407
+ addCacheUsage(event.message.usage);
408
+ activity?.finishResponse(event.message.usage.output);
409
+ }
410
+ refreshContextUi();
411
+ });
412
+ pi.on("tool_execution_start", (event) => {
413
+ activity?.startTool(event);
414
+ sidebar?.refresh();
415
+ });
416
+ pi.on("tool_execution_end", (event) => {
417
+ activity?.finishTool(event);
418
+ toolUsage?.record(event);
419
+ sidebar?.refresh();
420
+ });
421
+ pi.on("agent_settled", () => {
422
+ activity?.settle();
423
+ refreshSessions();
424
+ });
425
+ pi.on("agent_end", refreshContextUi);
426
+ pi.on("turn_end", (event) => {
427
+ activity?.finishTurn(event.turnIndex);
428
+ refreshContextUi();
429
+ });
430
+ pi.on("session_tree", () => {
431
+ const branch = getCurrentBranch?.();
432
+ if (branch) {
433
+ resetCacheUsage(branch);
434
+ toolUsage?.resetSession(branch);
435
+ }
436
+ refreshContextUi();
437
+ refreshSessions();
438
+ });
439
+ pi.on("session_info_changed", refreshSessions);
440
+ pi.on("model_select", refreshContextUi);
441
+ pi.on("thinking_level_select", refreshContextUi);
442
+
443
+ const clearControllers = (): void => {
444
+ sidebar = undefined;
445
+ footer = undefined;
446
+ activity = undefined;
447
+ toolUsage = undefined;
448
+ getCurrentBranch = undefined;
449
+ };
450
+
451
+ pi.on("session_start", (_event, ctx) => {
452
+ // Pi resets extension UI before starting/reloading a session. Do not call
453
+ // stale controllers from the previous runtime after that reset.
454
+ clearControllers();
455
+ toolViewMode = toolView.getMode();
456
+ getCurrentBranch = () => ctx.sessionManager.getBranch();
457
+ const branch = ctx.sessionManager.getBranch();
458
+ resetCacheUsage(branch);
459
+ toolUsage = createToolUsageTracker([], getMcpServerNames);
460
+ toolUsage.resetSession(branch);
461
+ if (ctx.mode !== "tui") return;
462
+ const showToolViewModeSelector = (): boolean => !isFabricFullCodeMode(pi.getActiveTools());
463
+
464
+ activity = createActivityTracker(ctx.cwd, refreshContextUi);
465
+ footer = installFooter(
466
+ ctx,
467
+ (tui, theme) => {
468
+ const panels = createDefaultSidebarPanels({
469
+ getContextUsage: () => ctx.getContextUsage(),
470
+ getContextEstimate: () => contextEstimates.getSnapshot(),
471
+ getContextDelta: () => contextEstimates.getDelta(),
472
+ getActivity: () => activity?.getSnapshot(),
473
+ getCacheHitRate: cacheHitRate,
474
+ getModel: () => ctx.model?.id ?? "no-model",
475
+ getThinkingLevel: () => pi.getThinkingLevel(),
476
+ getToolViewMode: () => toolViewMode,
477
+ getToolUsage: () => toolUsage?.getSnapshot(),
478
+ onSelectToolViewMode: (mode): void => {
479
+ void toolView.setMode(mode, ctx);
480
+ },
481
+ showToolViewModeSelector,
482
+ loadSessions: async (): Promise<readonly SessionListItem[]> => {
483
+ const currentSessionId = ctx.sessionManager.getSessionId();
484
+ const sessions = await SessionManager.list(ctx.cwd, ctx.sessionManager.getSessionDir());
485
+ return sessions.map((session) => ({
486
+ id: session.id,
487
+ title: session.name ?? session.firstMessage,
488
+ modified: session.modified,
489
+ messageCount: session.messageCount,
490
+ current: session.id === currentSessionId,
491
+ }));
492
+ },
493
+ onSelectSession: (session): void => {
494
+ if (session.current || switchRequested) return;
495
+ if (!ctx.isIdle()) {
496
+ ctx.ui.notify("Wait for the current run to finish before switching sessions", "warning");
497
+ return;
498
+ }
499
+ switchRequested = true;
500
+ pi.sendUserMessage(`/${SESSION_SWITCH_COMMAND} ${session.id}`, { expandPromptTemplates: true });
501
+ },
502
+ onRenameSession: (session): void => {
503
+ if (!ctx.isIdle()) {
504
+ ctx.ui.notify("Wait for the current run to finish before renaming sessions", "warning");
505
+ return;
506
+ }
507
+ pi.sendUserMessage(`/${SESSION_RENAME_COMMAND} ${session.id}`, { expandPromptTemplates: true });
508
+ },
509
+ onDeleteSession: (session): void => {
510
+ if (session.current) {
511
+ ctx.ui.notify("Cannot delete the currently active session", "warning");
512
+ return;
513
+ }
514
+ if (!ctx.isIdle()) {
515
+ ctx.ui.notify("Wait for the current run to finish before deleting sessions", "warning");
516
+ return;
517
+ }
518
+ pi.sendUserMessage(`/${SESSION_DELETE_COMMAND} ${session.id}`, { expandPromptTemplates: true });
519
+ },
520
+ onDeleteOthers: (): void => {
521
+ if (!ctx.isIdle()) {
522
+ ctx.ui.notify("Wait for the current run to finish before clearing sessions", "warning");
523
+ return;
524
+ }
525
+ pi.sendUserMessage(`/${SESSION_CLEAR_COMMAND}`, { expandPromptTemplates: true });
526
+ },
527
+ onOpenResources: (button): void => {
528
+ if (!ctx.isIdle()) {
529
+ ctx.ui.notify("Wait for the current run to finish before opening resources", "warning");
530
+ return;
531
+ }
532
+ void loadResourceEntries(pi, ctx.cwd, mcpResources, ctx.isProjectTrusted(), button)
533
+ .then((rows) => {
534
+ if (!rows || rows.length === 0) {
535
+ ctx.ui.notify("No resources of this kind loaded", "info");
536
+ return;
537
+ }
538
+ if (button === "agents" && rows.length === 1) {
539
+ showResourceDialog(ctx, button, rows, rows[0]!.content);
540
+ return;
541
+ }
542
+ showResourceDialog(ctx, button, rows);
543
+ })
544
+ .catch((error) => {
545
+ const message = error instanceof Error ? error.message : String(error);
546
+ ctx.ui.notify(`Failed to load resources: ${message}`, "error");
547
+ });
548
+ },
549
+ });
550
+ sidebar = installSidebar(tui, theme, panels);
551
+ if (!sidebar.mounted) {
552
+ ctx.ui.setStatus("pi-ui-extension:sidebar", "Right sidebar requires fullscreen mode");
553
+ }
554
+ },
555
+ () => {
556
+ sidebar?.dispose();
557
+ sidebar = undefined;
558
+ ctx.ui.setStatus("pi-ui-extension:sidebar", undefined);
559
+ },
560
+ );
561
+ });
562
+
563
+ pi.on("session_shutdown", () => {
564
+ unsubscribeToolViewMode();
565
+ unsubscribeMcpStatus();
566
+
567
+ // The sidebar replaces the fullscreen TUI layout root directly, so Pi
568
+ // cannot tear it down on our behalf during session replacement. Dispose
569
+ // both controllers while this session context is still active; otherwise
570
+ // the old sidebar may render once more and access a stale ctx.
571
+ activity?.reset();
572
+ sidebar?.dispose();
573
+ footer?.dispose();
574
+ clearControllers();
575
+ toolViewMode = undefined;
576
+ });
577
+ }
@@ -0,0 +1,64 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { renderLayoutFrame } from "@earendil-works/pi-tui/dist/layout.js";
7
+ import piUiExtension from "../index.ts";
8
+
9
+ test("entry wires early/late MCP status into restored and live usage without double counting", async () => {
10
+ const directory = await mkdtemp(join(tmpdir(), "pi-ui-mcp-"));
11
+ const handlers = new Map<string, any[]>(), bus = new Map<string, any>();
12
+ const listeners = new Set<any>();
13
+ let branch: any[] = [], footer: any;
14
+ const tui: any = {
15
+ [Symbol.for("@earendil-works/pi-tui/viewport")]: true,
16
+ terminal: { columns: 240, rows: 100 }, layoutRoot: { render: () => [], invalidate() {} }, inputListeners: listeners,
17
+ addInputListener(fn: any) { listeners.add(fn); return () => listeners.delete(fn); },
18
+ requestRender() {}, setLayoutRoot(root: any) { this.layoutRoot = root; },
19
+ };
20
+ const theme: any = { fg: (_: string, text: string) => text, bold: (text: string) => text };
21
+ const pi: any = {
22
+ registerTool() {}, registerCommand() {}, registerShortcut() {},
23
+ getCommands: () => [], getActiveTools: () => [], getAllTools: () => [], getThinkingLevel: () => "off",
24
+ on(name: string, fn: any) { handlers.set(name, [...(handlers.get(name) ?? []), fn]); },
25
+ events: { on(name: string, fn: any) { bus.set(name, fn); return () => bus.delete(name); } },
26
+ };
27
+ const ctx: any = {
28
+ mode: "tui", cwd: directory, getSystemPrompt: () => "", getContextUsage: () => undefined,
29
+ sessionManager: { getBranch: () => branch, buildContextEntries: () => [], getSessionId: () => "test", getSessionDir: () => directory },
30
+ ui: { setStatus() {}, setFooter(factory: any) { footer?.dispose(); footer = factory?.(tui, theme, { onBranchChange: () => () => {}, getGitBranch: () => null }); } },
31
+ };
32
+ const emit = async (name: string, event: any = {}) => { for (const fn of handlers.get(name) ?? []) await fn(event, ctx); };
33
+ const status = (names: string[]) => bus.get("pi-mcp-adapter/status/v1")({ version: 1, servers: names.map(name => ({ name })) });
34
+ const details = (server: string) => ({ mode: "script", calls: [{ operation: "call", path: `${server}_ping`, ok: true }] });
35
+ const saved = (server: string) => ({ type: "message", message: { role: "toolResult", toolName: "mcpScript", details: details(server) } });
36
+ const output = () => renderLayoutFrame(tui.layoutRoot, 240, 100, () => {}).lines.join("\n");
37
+ try {
38
+ await piUiExtension(pi);
39
+ status(["early"]);
40
+ branch = [saved("early")];
41
+ await emit("session_start");
42
+ assert.match(output(), /early\s+0\/1/);
43
+ await emit("before_agent_start", { systemPromptOptions: {} });
44
+ branch.push(saved("late"));
45
+ await emit("tool_execution_end", { toolCallId: "call-1", toolName: "mcpScript", isError: false, result: { details: details("late") } });
46
+ assert.doesNotMatch(output(), /late\s+1\/1/);
47
+ status(["early", "late"]);
48
+ assert.match(output(), /late\s+1\/1/);
49
+ status(["early", "late"]);
50
+ assert.match(output(), /late\s+1\/1/);
51
+ branch.push(saved("late"));
52
+ await emit("tool_execution_end", { toolCallId: "call-2", toolName: "mcpScript", isError: false, result: { details: details("late") } });
53
+ assert.match(output(), /late\s+2\/2/);
54
+ branch = [saved("early")];
55
+ await emit("session_tree");
56
+ assert.match(output(), /early\s+0\/1/);
57
+ assert.doesNotMatch(output(), /late\s+2\/2/);
58
+ } finally {
59
+ await emit("session_shutdown");
60
+ await rm(directory, { recursive: true, force: true });
61
+ }
62
+ assert.equal(bus.size, 0);
63
+ assert.equal(listeners.size, 0);
64
+ });
@@ -0,0 +1,27 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mcpResourceEntriesFromStatus } from "./resources.ts";
4
+
5
+ test("builds MCP resource rows from the adapter status snapshot", () => {
6
+ assert.deepEqual(
7
+ mcpResourceEntriesFromStatus({
8
+ version: 1,
9
+ servers: [
10
+ { name: "context7", disabled: false },
11
+ { name: "chrome-devtools", disabled: false },
12
+ { name: "disabled-server", disabled: true },
13
+ { name: "context7", disabled: false },
14
+ ],
15
+ }),
16
+ [
17
+ { name: "context7", description: "MCP server loaded in the current Pi session" },
18
+ { name: "chrome-devtools", description: "MCP server loaded in the current Pi session" },
19
+ ],
20
+ );
21
+ });
22
+
23
+ test("rejects malformed MCP status snapshots", () => {
24
+ assert.deepEqual(mcpResourceEntriesFromStatus(undefined), []);
25
+ assert.deepEqual(mcpResourceEntriesFromStatus({ version: 2, servers: [] }), []);
26
+ assert.deepEqual(mcpResourceEntriesFromStatus({ version: 1, servers: "context7" }), []);
27
+ });