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.
- package/README.md +43 -0
- package/extensions/ctx/README.md +35 -0
- package/extensions/ctx/activity.ts +226 -0
- package/extensions/ctx/data.test.ts +65 -0
- package/extensions/ctx/data.ts +230 -0
- package/extensions/ctx/panel.test.ts +103 -0
- package/extensions/ctx/panel.ts +185 -0
- package/extensions/ctx/tools.test.ts +64 -0
- package/extensions/fabric-detection.test.ts +15 -0
- package/extensions/fabric-detection.ts +7 -0
- package/extensions/footer/index.ts +62 -0
- package/extensions/index.ts +577 -0
- package/extensions/mcp/integration.test.ts +64 -0
- package/extensions/mcp/resources.test.ts +27 -0
- package/extensions/mcp/resources.ts +31 -0
- package/extensions/resource-dialog.test.ts +45 -0
- package/extensions/session/mutations.test.ts +30 -0
- package/extensions/session/mutations.ts +17 -0
- package/extensions/session/panel.test.ts +238 -0
- package/extensions/session/panel.ts +461 -0
- package/extensions/sidebar/default-panels.test.ts +97 -0
- package/extensions/sidebar/default-panels.ts +155 -0
- package/extensions/sidebar/index.ts +212 -0
- package/extensions/sidebar/layout.test.ts +68 -0
- package/extensions/sidebar/layout.ts +75 -0
- package/extensions/sidebar/panel.ts +49 -0
- package/extensions/sidebar/registry.ts +35 -0
- package/extensions/sidebar/resize.test.ts +71 -0
- package/extensions/sidebar/resize.ts +154 -0
- package/extensions/tool-view/controller.test.ts +57 -0
- package/extensions/tool-view/controller.ts +519 -0
- package/extensions/tool-view/panel.test.ts +73 -0
- package/extensions/tool-view/panel.ts +221 -0
- package/extensions/tool-view/trust.test.ts +41 -0
- package/extensions/tool-view/types.ts +6 -0
- package/extensions/tool-view/usage.test.ts +231 -0
- package/extensions/tool-view/usage.ts +303 -0
- package/extensions/tool-view/wheel.test.ts +32 -0
- package/package.json +53 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type McpResourceEntry = {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly description: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
type McpStatusServer = {
|
|
7
|
+
readonly name?: unknown;
|
|
8
|
+
readonly disabled?: unknown;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Convert the adapter's read-only status snapshot into configured, enabled MCP rows. */
|
|
12
|
+
export function mcpResourceEntriesFromStatus(value: unknown): readonly McpResourceEntry[] {
|
|
13
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return [];
|
|
14
|
+
const snapshot = value as { version?: unknown; servers?: unknown };
|
|
15
|
+
if (snapshot.version !== 1 || !Array.isArray(snapshot.servers)) return [];
|
|
16
|
+
|
|
17
|
+
const seen = new Set<string>();
|
|
18
|
+
const rows: McpResourceEntry[] = [];
|
|
19
|
+
for (const rawServer of snapshot.servers) {
|
|
20
|
+
if (typeof rawServer !== "object" || rawServer === null || Array.isArray(rawServer)) continue;
|
|
21
|
+
const server = rawServer as McpStatusServer;
|
|
22
|
+
const name = typeof server.name === "string" ? server.name.trim() : "";
|
|
23
|
+
if (!name || server.disabled === true || seen.has(name)) continue;
|
|
24
|
+
seen.add(name);
|
|
25
|
+
rows.push({
|
|
26
|
+
name,
|
|
27
|
+
description: "MCP server loaded in the current Pi session",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return rows;
|
|
31
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { loadResourceEntries, resourceDialogOptions, type ResourceEntry } from "./index.ts";
|
|
4
|
+
|
|
5
|
+
test("resource categories avoid unrelated package discovery and reuse loaded context content", async (t) => {
|
|
6
|
+
const { SettingsManager, DefaultPackageManager } = await import("@earendil-works/pi-coding-agent");
|
|
7
|
+
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
|
8
|
+
const { tmpdir } = await import("node:os");
|
|
9
|
+
const { join } = await import("node:path");
|
|
10
|
+
t.mock.method(SettingsManager, "create", () => { throw new Error("unexpected settings read"); });
|
|
11
|
+
t.mock.method(DefaultPackageManager.prototype, "resolve", async () => { throw new Error("unexpected package scan"); });
|
|
12
|
+
const mcp = [{name:"demo",description:"MCP server"}];
|
|
13
|
+
assert.strictEqual(await loadResourceEntries({} as any, "/nonexistent", mcp, false, "mcp"), mcp);
|
|
14
|
+
const pi = {getCommands:()=>[{source:"skill",name:"skill:review",description:" Review code "},{source:"extension",name:"other"}]} as any;
|
|
15
|
+
assert.deepEqual(await loadResourceEntries(pi, "/nonexistent", [], false, "skills"), [{name:"review",description:"Review code"}]);
|
|
16
|
+
const directory = await mkdtemp(join(tmpdir(), "resource-context-"));
|
|
17
|
+
try {
|
|
18
|
+
const content = "# Project\n\nLocal context description\n";
|
|
19
|
+
await writeFile(join(directory, "AGENTS.md"), content);
|
|
20
|
+
const rows = await loadResourceEntries({} as any, directory, [], false, "agents");
|
|
21
|
+
assert.ok(rows.some(row => row.content === content && row.description === "Local context description"));
|
|
22
|
+
} finally { await rm(directory, {recursive:true, force:true}); }
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("resource dialog keeps every resource row for selector scrolling", () => {
|
|
26
|
+
const rows: ResourceEntry[] = Array.from({ length: 13 }, (_, index) => ({
|
|
27
|
+
name: `skill-${index + 1}`,
|
|
28
|
+
description: `Description ${index + 1}`,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const options = resourceDialogOptions(rows);
|
|
32
|
+
|
|
33
|
+
assert.equal(options.length, rows.length);
|
|
34
|
+
assert.equal(options[0], "skill-1 — Description 1");
|
|
35
|
+
assert.equal(options[12], "skill-13 — Description 13");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("resource dialog keeps the existing one-line description limit", () => {
|
|
39
|
+
const options = resourceDialogOptions([{
|
|
40
|
+
name: "skill",
|
|
41
|
+
description: "x".repeat(200),
|
|
42
|
+
}]);
|
|
43
|
+
|
|
44
|
+
assert.equal(options[0], `skill — ${"x".repeat(109)}…`);
|
|
45
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { deleteSessionFile } from "./mutations.ts";
|
|
7
|
+
|
|
8
|
+
test("trash failures never permanently delete the session", async () => {
|
|
9
|
+
const directory = await mkdtemp(join(tmpdir(), "session-trash-"));
|
|
10
|
+
const path = join(directory, "session.jsonl");
|
|
11
|
+
try {
|
|
12
|
+
await writeFile(path, "session data\n");
|
|
13
|
+
for (const failure of [new Error("spawn trash ENOENT"), new Error("timed out"), { code: 1, stderr: "permission denied\n" }, { code: 2, stderr: "" }]) {
|
|
14
|
+
const pi = { async exec() { if (failure instanceof Error) throw failure; return failure; } } as any;
|
|
15
|
+
const result = await deleteSessionFile(pi, path);
|
|
16
|
+
assert.equal(result.ok, false);
|
|
17
|
+
if (!result.ok) assert.ok(result.error.length > 0);
|
|
18
|
+
assert.equal(await readFile(path, "utf8"), "session data\n");
|
|
19
|
+
}
|
|
20
|
+
} finally {
|
|
21
|
+
await rm(directory, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("successful trash execution passes the session path to trash", async () => {
|
|
26
|
+
const calls: unknown[][] = [];
|
|
27
|
+
const pi = { async exec(...args: unknown[]) { calls.push(args); return { code: 0, stderr: "" }; } } as any;
|
|
28
|
+
assert.deepEqual(await deleteSessionFile(pi, "/tmp/session.jsonl"), { ok: true, method: "trash" });
|
|
29
|
+
assert.deepEqual(calls, [["trash", ["/tmp/session.jsonl"], { timeout: 10_000 }]]);
|
|
30
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type DeleteSessionResult =
|
|
4
|
+
| { readonly ok: true; readonly method: "trash" }
|
|
5
|
+
| { readonly ok: false; readonly error: string };
|
|
6
|
+
|
|
7
|
+
/** Only use recoverable deletion. A trash failure must never trigger permanent deletion. */
|
|
8
|
+
export async function deleteSessionFile(pi: ExtensionAPI, path: string): Promise<DeleteSessionResult> {
|
|
9
|
+
try {
|
|
10
|
+
const result = await pi.exec("trash", [path], { timeout: 10_000 });
|
|
11
|
+
if (result.code === 0) return { ok: true, method: "trash" };
|
|
12
|
+
const hint = result.stderr.trim().split(/\r?\n/, 1)[0];
|
|
13
|
+
return { ok: false, error: hint || `trash exited with code ${result.code}` };
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import type { TuiInputListener } from "@earendil-works/pi-tui";
|
|
4
|
+
import { SessionPanel, type SessionListItem } from "./panel.ts";
|
|
5
|
+
|
|
6
|
+
const theme = { fg: (_color: string, text: string) => text } as any;
|
|
7
|
+
|
|
8
|
+
function session(id: string, title: string): SessionListItem {
|
|
9
|
+
return {
|
|
10
|
+
id,
|
|
11
|
+
title,
|
|
12
|
+
modified: new Date(0),
|
|
13
|
+
messageCount: 1,
|
|
14
|
+
current: false,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function flushAsync(): Promise<void> {
|
|
19
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("clicking the Sessions title refreshes the saved session list", async () => {
|
|
23
|
+
let listener: TuiInputListener | undefined;
|
|
24
|
+
let loads = 0;
|
|
25
|
+
const snapshots = [
|
|
26
|
+
[session("old", "Old session")],
|
|
27
|
+
[session("new", "New session")],
|
|
28
|
+
];
|
|
29
|
+
const tui = {
|
|
30
|
+
addInputListener(next: TuiInputListener) {
|
|
31
|
+
listener = next;
|
|
32
|
+
return () => {};
|
|
33
|
+
},
|
|
34
|
+
requestRender() {},
|
|
35
|
+
currentLayout: undefined as any,
|
|
36
|
+
} as any;
|
|
37
|
+
const panel = new SessionPanel(theme, {
|
|
38
|
+
tui,
|
|
39
|
+
getDesiredWidth: () => 40,
|
|
40
|
+
loadSessions: async () => snapshots[Math.min(loads++, snapshots.length - 1)]!,
|
|
41
|
+
onSelect() {},
|
|
42
|
+
onRename() {},
|
|
43
|
+
onOpenResources() {},
|
|
44
|
+
onDelete() {},
|
|
45
|
+
onDeleteOthers() {},
|
|
46
|
+
now: () => 0,
|
|
47
|
+
});
|
|
48
|
+
tui.currentLayout = {
|
|
49
|
+
root: {
|
|
50
|
+
component: panel,
|
|
51
|
+
rect: { x: 10, y: 5, width: 40, height: 7 },
|
|
52
|
+
clip: { x: 10, y: 5, width: 40, height: 7 },
|
|
53
|
+
children: [],
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
panel.refresh();
|
|
58
|
+
await flushAsync();
|
|
59
|
+
assert.match(panel.render(40).join("\n"), /Old session/);
|
|
60
|
+
assert.match(panel.render(40)[0]!, /SESSIONS ↻/);
|
|
61
|
+
|
|
62
|
+
assert.deepEqual(listener?.("\u001b[<0;20;6M"), { consume: true });
|
|
63
|
+
assert.deepEqual(listener?.("\u001b[<0;20;6m"), { consume: true });
|
|
64
|
+
await flushAsync();
|
|
65
|
+
|
|
66
|
+
assert.equal(loads, 2);
|
|
67
|
+
const output = panel.render(40).join("\n");
|
|
68
|
+
assert.match(output, /New session/);
|
|
69
|
+
assert.doesNotMatch(output, /Old session/);
|
|
70
|
+
panel.dispose();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("renders and opens the MCP resource button", async () => {
|
|
74
|
+
let listener: TuiInputListener | undefined;
|
|
75
|
+
let opened: string | undefined;
|
|
76
|
+
const tui = {
|
|
77
|
+
addInputListener(next: TuiInputListener) {
|
|
78
|
+
listener = next;
|
|
79
|
+
return () => {};
|
|
80
|
+
},
|
|
81
|
+
requestRender() {},
|
|
82
|
+
currentLayout: undefined as any,
|
|
83
|
+
} as any;
|
|
84
|
+
const panel = new SessionPanel(theme, {
|
|
85
|
+
tui,
|
|
86
|
+
getDesiredWidth: () => 40,
|
|
87
|
+
loadSessions: async () => [session("one", "One session")],
|
|
88
|
+
onSelect() {},
|
|
89
|
+
onRename() {},
|
|
90
|
+
onDelete() {},
|
|
91
|
+
onDeleteOthers() {},
|
|
92
|
+
onOpenResources(button) { opened = button; },
|
|
93
|
+
now: () => 0,
|
|
94
|
+
});
|
|
95
|
+
tui.currentLayout = {
|
|
96
|
+
root: {
|
|
97
|
+
component: panel,
|
|
98
|
+
rect: { x: 10, y: 5, width: 40, height: 5 },
|
|
99
|
+
clip: { x: 10, y: 5, width: 40, height: 5 },
|
|
100
|
+
children: [],
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
panel.refresh();
|
|
105
|
+
await flushAsync();
|
|
106
|
+
assert.match(panel.render(40).join("\n"), /MCP/);
|
|
107
|
+
|
|
108
|
+
assert.deepEqual(listener?.("\u001b[<0;43;9M"), { consume: true });
|
|
109
|
+
assert.deepEqual(listener?.("\u001b[<0;43;9m"), { consume: true });
|
|
110
|
+
await flushAsync();
|
|
111
|
+
|
|
112
|
+
assert.equal(opened, "mcp");
|
|
113
|
+
panel.dispose();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("releasing outside the Sessions title cancels a refresh click", async () => {
|
|
117
|
+
let listener: TuiInputListener | undefined;
|
|
118
|
+
let loads = 0;
|
|
119
|
+
const tui = {
|
|
120
|
+
addInputListener(next: TuiInputListener) {
|
|
121
|
+
listener = next;
|
|
122
|
+
return () => {};
|
|
123
|
+
},
|
|
124
|
+
requestRender() {},
|
|
125
|
+
currentLayout: undefined as any,
|
|
126
|
+
} as any;
|
|
127
|
+
const panel = new SessionPanel(theme, {
|
|
128
|
+
tui,
|
|
129
|
+
getDesiredWidth: () => 40,
|
|
130
|
+
loadSessions: async () => {
|
|
131
|
+
loads++;
|
|
132
|
+
return [];
|
|
133
|
+
},
|
|
134
|
+
onSelect() {},
|
|
135
|
+
onRename() {},
|
|
136
|
+
onDelete() {},
|
|
137
|
+
onDeleteOthers() {},
|
|
138
|
+
onOpenResources() {},
|
|
139
|
+
});
|
|
140
|
+
tui.currentLayout = {
|
|
141
|
+
root: {
|
|
142
|
+
component: panel,
|
|
143
|
+
rect: { x: 10, y: 5, width: 40, height: 7 },
|
|
144
|
+
clip: { x: 10, y: 5, width: 40, height: 7 },
|
|
145
|
+
children: [],
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
panel.refresh();
|
|
150
|
+
await flushAsync();
|
|
151
|
+
listener?.("\u001b[<0;20;6M");
|
|
152
|
+
listener?.("\u001b[<0;20;7m");
|
|
153
|
+
await flushAsync();
|
|
154
|
+
|
|
155
|
+
assert.equal(loads, 1);
|
|
156
|
+
panel.dispose();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("renders ⊗ and clears every other session without refreshing", async () => {
|
|
160
|
+
let listener: TuiInputListener | undefined;
|
|
161
|
+
let loads = 0;
|
|
162
|
+
let cleared = 0;
|
|
163
|
+
const tui = {
|
|
164
|
+
addInputListener(next: TuiInputListener) {
|
|
165
|
+
listener = next;
|
|
166
|
+
return () => {};
|
|
167
|
+
},
|
|
168
|
+
requestRender() {},
|
|
169
|
+
currentLayout: undefined as any,
|
|
170
|
+
} as any;
|
|
171
|
+
const panel = new SessionPanel(theme, {
|
|
172
|
+
tui,
|
|
173
|
+
getDesiredWidth: () => 40,
|
|
174
|
+
loadSessions: async () => {
|
|
175
|
+
loads++;
|
|
176
|
+
return [session("one", "One session")];
|
|
177
|
+
},
|
|
178
|
+
onSelect() {},
|
|
179
|
+
onRename() {},
|
|
180
|
+
onDelete() {},
|
|
181
|
+
onDeleteOthers() { cleared++; },
|
|
182
|
+
onOpenResources() {},
|
|
183
|
+
now: () => 0,
|
|
184
|
+
});
|
|
185
|
+
tui.currentLayout = {
|
|
186
|
+
root: {
|
|
187
|
+
component: panel,
|
|
188
|
+
rect: { x: 10, y: 5, width: 40, height: 7 },
|
|
189
|
+
clip: { x: 10, y: 5, width: 40, height: 7 },
|
|
190
|
+
children: [],
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
panel.refresh();
|
|
195
|
+
await flushAsync();
|
|
196
|
+
assert.match(panel.render(40)[0]!, /⊗/);
|
|
197
|
+
|
|
198
|
+
// ⊗ sits at the right edge of the title row: content column 33 of 38.
|
|
199
|
+
assert.deepEqual(listener?.("\u001b[<0;46;6M"), { consume: true });
|
|
200
|
+
assert.deepEqual(listener?.("\u001b[<0;46;6m"), { consume: true });
|
|
201
|
+
await flushAsync();
|
|
202
|
+
|
|
203
|
+
assert.equal(cleared, 1);
|
|
204
|
+
assert.equal(loads, 1);
|
|
205
|
+
panel.dispose();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("hides ⊗ on narrow panels", async () => {
|
|
209
|
+
const tui = {
|
|
210
|
+
addInputListener: () => () => {},
|
|
211
|
+
requestRender() {},
|
|
212
|
+
currentLayout: undefined as any,
|
|
213
|
+
} as any;
|
|
214
|
+
const panel = new SessionPanel(theme, {
|
|
215
|
+
tui,
|
|
216
|
+
getDesiredWidth: () => 16,
|
|
217
|
+
loadSessions: async () => [],
|
|
218
|
+
onSelect() {},
|
|
219
|
+
onRename() {},
|
|
220
|
+
onDelete() {},
|
|
221
|
+
onDeleteOthers() {},
|
|
222
|
+
onOpenResources() {},
|
|
223
|
+
now: () => 0,
|
|
224
|
+
});
|
|
225
|
+
tui.currentLayout = {
|
|
226
|
+
root: {
|
|
227
|
+
component: panel,
|
|
228
|
+
rect: { x: 0, y: 0, width: 16, height: 7 },
|
|
229
|
+
clip: { x: 0, y: 0, width: 16, height: 7 },
|
|
230
|
+
children: [],
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
panel.refresh();
|
|
235
|
+
await flushAsync();
|
|
236
|
+
assert.doesNotMatch(panel.render(16)[0]!, /⊗/);
|
|
237
|
+
panel.dispose();
|
|
238
|
+
});
|